diff options
Diffstat (limited to 'comm/calendar/base/src')
40 files changed, 11236 insertions, 0 deletions
diff --git a/comm/calendar/base/src/CalAlarm.jsm b/comm/calendar/base/src/CalAlarm.jsm new file mode 100644 index 0000000000..d6f0faffaa --- /dev/null +++ b/comm/calendar/base/src/CalAlarm.jsm @@ -0,0 +1,693 @@ +/* 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 = ["CalAlarm"]; + +var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs"); +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalDateTime: "resource:///modules/CalDateTime.jsm", + CalDuration: "resource:///modules/CalDuration.jsm", +}); + +const ALARM_RELATED_ABSOLUTE = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE; +const ALARM_RELATED_START = Ci.calIAlarm.ALARM_RELATED_START; +const ALARM_RELATED_END = Ci.calIAlarm.ALARM_RELATED_END; + +/** + * Constructor for `calIAlarm` objects. + * + * @class + * @implements {calIAlarm} + * @param {string} [icalString] - Optional iCal string for initializing existing alarms. + */ +function CalAlarm(icalString) { + this.wrappedJSObject = this; + this.mProperties = new Map(); + this.mPropertyParams = {}; + this.mAttendees = []; + this.mAttachments = []; + if (icalString) { + this.icalString = icalString; + } +} + +CalAlarm.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIAlarm"]), + classID: Components.ID("{b8db7c7f-c168-4e11-becb-f26c1c4f5f8f}"), + + mProperties: null, + mPropertyParams: null, + mAction: null, + mAbsoluteDate: null, + mOffset: null, + mDuration: null, + mAttendees: null, + mAttachments: null, + mSummary: null, + mDescription: null, + mLastAck: null, + mImmutable: false, + mRelated: 0, + mRepeat: 0, + + /** + * calIAlarm + */ + + ensureMutable() { + if (this.mImmutable) { + throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE); + } + }, + + get isMutable() { + return !this.mImmutable; + }, + + makeImmutable() { + if (this.mImmutable) { + return; + } + + const objectMembers = ["mAbsoluteDate", "mOffset", "mDuration", "mLastAck"]; + for (let member of objectMembers) { + if (this[member] && this[member].isMutable) { + this[member].makeImmutable(); + } + } + + // Properties + for (let propval of this.mProperties.values()) { + if (propval?.isMutable) { + propval.makeImmutable(); + } + } + + this.mImmutable = true; + }, + + clone() { + let cloned = new CalAlarm(); + + cloned.mImmutable = false; + + const simpleMembers = ["mAction", "mSummary", "mDescription", "mRelated", "mRepeat"]; + + const arrayMembers = ["mAttendees", "mAttachments"]; + + const objectMembers = ["mAbsoluteDate", "mOffset", "mDuration", "mLastAck"]; + + for (let member of simpleMembers) { + cloned[member] = this[member]; + } + + for (let member of arrayMembers) { + let newArray = []; + for (let oldElem of this[member]) { + newArray.push(oldElem.clone()); + } + cloned[member] = newArray; + } + + for (let member of objectMembers) { + if (this[member] && this[member].clone) { + cloned[member] = this[member].clone(); + } else { + cloned[member] = this[member]; + } + } + + // X-Props + cloned.mProperties = new Map(); + for (let [name, value] of this.mProperties.entries()) { + if (value instanceof lazy.CalDateTime || value instanceof Ci.calIDateTime) { + value = value.clone(); + } + + cloned.mProperties.set(name, value); + + let propBucket = this.mPropertyParams[name]; + if (propBucket) { + let newBucket = {}; + for (let param in propBucket) { + newBucket[param] = propBucket[param]; + } + cloned.mPropertyParams[name] = newBucket; + } + } + return cloned; + }, + + get related() { + return this.mRelated; + }, + set related(aValue) { + this.ensureMutable(); + switch (aValue) { + case ALARM_RELATED_ABSOLUTE: + this.mOffset = null; + break; + case ALARM_RELATED_START: + case ALARM_RELATED_END: + this.mAbsoluteDate = null; + break; + } + + this.mRelated = aValue; + }, + + get action() { + return this.mAction || "DISPLAY"; + }, + set action(aValue) { + this.ensureMutable(); + this.mAction = aValue; + }, + + get description() { + if (this.action == "AUDIO") { + return null; + } + return this.mDescription; + }, + set description(aValue) { + this.ensureMutable(); + this.mDescription = aValue; + }, + + get summary() { + if (this.mAction == "DISPLAY" || this.mAction == "AUDIO") { + return null; + } + return this.mSummary; + }, + set summary(aValue) { + this.ensureMutable(); + this.mSummary = aValue; + }, + + get offset() { + return this.mOffset; + }, + set offset(aValue) { + if (aValue && !(aValue instanceof lazy.CalDuration) && !(aValue instanceof Ci.calIDuration)) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + if (this.related != ALARM_RELATED_START && this.related != ALARM_RELATED_END) { + throw Components.Exception("", Cr.NS_ERROR_FAILURE); + } + this.ensureMutable(); + this.mOffset = aValue; + }, + + get alarmDate() { + return this.mAbsoluteDate; + }, + set alarmDate(aValue) { + if (aValue && !(aValue instanceof lazy.CalDateTime) && !(aValue instanceof Ci.calIDateTime)) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + if (this.related != ALARM_RELATED_ABSOLUTE) { + throw Components.Exception("", Cr.NS_ERROR_FAILURE); + } + this.ensureMutable(); + this.mAbsoluteDate = aValue; + }, + + get repeat() { + if (!this.mDuration) { + return 0; + } + return this.mRepeat || 0; + }, + set repeat(aValue) { + this.ensureMutable(); + if (aValue === null) { + this.mRepeat = null; + } else { + this.mRepeat = parseInt(aValue, 10); + if (isNaN(this.mRepeat)) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + } + }, + + get repeatOffset() { + if (!this.mRepeat) { + return null; + } + return this.mDuration; + }, + set repeatOffset(aValue) { + this.ensureMutable(); + if ( + aValue !== null && + !(aValue instanceof lazy.CalDuration) && + !(aValue instanceof Ci.calIDuration) + ) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + this.mDuration = aValue; + }, + + get repeatDate() { + if ( + this.related != ALARM_RELATED_ABSOLUTE || + !this.mAbsoluteDate || + !this.mRepeat || + !this.mDuration + ) { + return null; + } + + let alarmDate = this.mAbsoluteDate.clone(); + + // All Day events are handled as 00:00:00 + alarmDate.isDate = false; + alarmDate.addDuration(this.mDuration); + return alarmDate; + }, + + getAttendees() { + let attendees; + if (this.action == "AUDIO" || this.action == "DISPLAY") { + attendees = []; + } else { + attendees = this.mAttendees.concat([]); + } + return attendees; + }, + + addAttendee(aAttendee) { + // Make sure its not duplicate + this.deleteAttendee(aAttendee); + + // Now check if its valid + if (this.action == "AUDIO" || this.action == "DISPLAY") { + throw new Error("Alarm type AUDIO/DISPLAY may not have attendees"); + } + + // And add it (again) + this.mAttendees.push(aAttendee); + }, + + deleteAttendee(aAttendee) { + let deleteId = aAttendee.id; + for (let i = 0; i < this.mAttendees.length; i++) { + if (this.mAttendees[i].id == deleteId) { + this.mAttendees.splice(i, 1); + break; + } + } + }, + + clearAttendees() { + this.mAttendees = []; + }, + + getAttachments() { + let attachments; + if (this.action == "AUDIO") { + attachments = this.mAttachments.length ? [this.mAttachments[0]] : []; + } else if (this.action == "DISPLAY") { + attachments = []; + } else { + attachments = this.mAttachments.concat([]); + } + return attachments; + }, + + addAttachment(aAttachment) { + // Make sure its not duplicate + this.deleteAttachment(aAttachment); + + // Now check if its valid + if (this.action == "AUDIO" && this.mAttachments.length) { + throw new Error("Alarm type AUDIO may only have one attachment"); + } else if (this.action == "DISPLAY") { + throw new Error("Alarm type DISPLAY may not have attachments"); + } + + // And add it (again) + this.mAttachments.push(aAttachment); + }, + + deleteAttachment(aAttachment) { + let deleteHash = aAttachment.hashId; + for (let i = 0; i < this.mAttachments.length; i++) { + if (this.mAttachments[i].hashId == deleteHash) { + this.mAttachments.splice(i, 1); + break; + } + } + }, + + clearAttachments() { + this.mAttachments = []; + }, + + get icalString() { + let comp = this.icalComponent; + return comp ? comp.serializeToICS() : ""; + }, + set icalString(val) { + this.ensureMutable(); + this.icalComponent = cal.icsService.parseICS(val); + }, + + promotedProps: { + ACTION: "action", + TRIGGER: "offset", + REPEAT: "repeat", + DURATION: "duration", + SUMMARY: "summary", + DESCRIPTION: "description", + "X-MOZ-LASTACK": "lastAck", + + // These have complex setters and will be ignored in setProperty + ATTACH: true, + ATTENDEE: true, + }, + + get icalComponent() { + let comp = cal.icsService.createIcalComponent("VALARM"); + + // Set up action (REQUIRED) + let actionProp = cal.icsService.createIcalProperty("ACTION"); + actionProp.value = this.action; + comp.addProperty(actionProp); + + // Set up trigger (REQUIRED) + let triggerProp = cal.icsService.createIcalProperty("TRIGGER"); + if (this.related == ALARM_RELATED_ABSOLUTE && this.mAbsoluteDate) { + // Set the trigger to a specific datetime + triggerProp.setParameter("VALUE", "DATE-TIME"); + triggerProp.valueAsDatetime = this.mAbsoluteDate.getInTimezone(cal.dtz.UTC); + } else if (this.related != ALARM_RELATED_ABSOLUTE && this.mOffset) { + triggerProp.valueAsIcalString = this.mOffset.icalString; + if (this.related == ALARM_RELATED_END) { + // An alarm related to the end of the event. + triggerProp.setParameter("RELATED", "END"); + } + } else { + // No offset or absolute date is not valid. + throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); + } + comp.addProperty(triggerProp); + + // Set up repeat and duration (OPTIONAL, but if one exists, the other + // MUST also exist) + if (this.repeat && this.repeatOffset) { + let repeatProp = cal.icsService.createIcalProperty("REPEAT"); + let durationProp = cal.icsService.createIcalProperty("DURATION"); + + repeatProp.value = this.repeat; + durationProp.valueAsIcalString = this.repeatOffset.icalString; + + comp.addProperty(repeatProp); + comp.addProperty(durationProp); + } + + // Set up attendees (REQUIRED for EMAIL action) + /* TODO should we be strict here? + if (this.action == "EMAIL" && !this.getAttendees().length) { + throw Cr.NS_ERROR_NOT_INITIALIZED; + } */ + for (let attendee of this.getAttendees()) { + comp.addProperty(attendee.icalProperty); + } + + /* TODO should we be strict here? + if (this.action == "EMAIL" && !this.attachments.length) { + throw Cr.NS_ERROR_NOT_INITIALIZED; + } */ + + for (let attachment of this.getAttachments()) { + comp.addProperty(attachment.icalProperty); + } + + // Set up summary (REQUIRED for EMAIL) + if (this.summary || this.action == "EMAIL") { + let summaryProp = cal.icsService.createIcalProperty("SUMMARY"); + // Summary needs to have a non-empty value + summaryProp.value = this.summary || cal.l10n.getCalString("alarmDefaultSummary"); + comp.addProperty(summaryProp); + } + + // Set up the description (REQUIRED for DISPLAY and EMAIL) + if (this.description || this.action == "DISPLAY" || this.action == "EMAIL") { + let descriptionProp = cal.icsService.createIcalProperty("DESCRIPTION"); + // description needs to have a non-empty value + descriptionProp.value = this.description || cal.l10n.getCalString("alarmDefaultDescription"); + comp.addProperty(descriptionProp); + } + + // Set up lastAck + if (this.lastAck) { + let lastAckProp = cal.icsService.createIcalProperty("X-MOZ-LASTACK"); + lastAckProp.value = this.lastAck; + comp.addProperty(lastAckProp); + } + + // Set up X-Props. mProperties contains only non-promoted props + // eslint-disable-next-line array-bracket-spacing + for (let [propName, propValue] of this.mProperties.entries()) { + let icalprop = cal.icsService.createIcalProperty(propName); + icalprop.value = propValue; + + // Add parameters + let propBucket = this.mPropertyParams[propName]; + if (propBucket) { + for (let paramName in propBucket) { + try { + icalprop.setParameter(paramName, propBucket[paramName]); + } catch (e) { + if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG( + "Warning: Invalid alarm parameter value " + paramName + "=" + propBucket[paramName] + ); + } else { + throw e; + } + } + } + } + comp.addProperty(icalprop); + } + return comp; + }, + set icalComponent(aComp) { + this.ensureMutable(); + if (!aComp || aComp.componentType != "VALARM") { + // Invalid Component + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + let actionProp = aComp.getFirstProperty("ACTION"); + let triggerProp = aComp.getFirstProperty("TRIGGER"); + let repeatProp = aComp.getFirstProperty("REPEAT"); + let durationProp = aComp.getFirstProperty("DURATION"); + let summaryProp = aComp.getFirstProperty("SUMMARY"); + let descriptionProp = aComp.getFirstProperty("DESCRIPTION"); + let lastAckProp = aComp.getFirstProperty("X-MOZ-LASTACK"); + + if (actionProp) { + this.action = actionProp.value; + } else { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + if (triggerProp) { + if (triggerProp.getParameter("VALUE") == "DATE-TIME") { + this.mAbsoluteDate = triggerProp.valueAsDatetime; + this.related = ALARM_RELATED_ABSOLUTE; + } else { + this.mOffset = cal.createDuration(triggerProp.valueAsIcalString); + + let related = triggerProp.getParameter("RELATED"); + this.related = related == "END" ? ALARM_RELATED_END : ALARM_RELATED_START; + } + } else { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + if (durationProp && repeatProp) { + this.repeatOffset = cal.createDuration(durationProp.valueAsIcalString); + this.repeat = repeatProp.value; + } else if (durationProp || repeatProp) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } else { + this.repeatOffset = null; + this.repeat = 0; + } + + // Set up attendees + this.clearAttendees(); + for (let attendeeProp of cal.iterate.icalProperty(aComp, "ATTENDEE")) { + let attendee = new lazy.CalAttendee(); + attendee.icalProperty = attendeeProp; + this.addAttendee(attendee); + } + + // Set up attachments + this.clearAttachments(); + for (let attachProp of cal.iterate.icalProperty(aComp, "ATTACH")) { + let attach = new lazy.CalAttachment(); + attach.icalProperty = attachProp; + this.addAttachment(attach); + } + + // Set up summary + this.summary = summaryProp ? summaryProp.value : null; + + // Set up description + this.description = descriptionProp ? descriptionProp.value : null; + + // Set up the alarm lastack. We can't use valueAsDatetime here since + // the default for an X-Prop is TEXT and in older versions we didn't set + // VALUE=DATE-TIME. + this.lastAck = lastAckProp ? cal.createDateTime(lastAckProp.valueAsIcalString) : null; + + this.mProperties = new Map(); + this.mPropertyParams = {}; + + // Other properties + for (let prop of cal.iterate.icalProperty(aComp)) { + if (!this.promotedProps[prop.propertyName]) { + this.setProperty(prop.propertyName, prop.value); + + for (let [paramName, param] of cal.iterate.icalParameter(prop)) { + if (!(prop.propertyName in this.mPropertyParams)) { + this.mPropertyParams[prop.propertyName] = {}; + } + this.mPropertyParams[prop.propertyName][paramName] = param; + } + } + } + }, + + hasProperty(aName) { + return this.getProperty(aName.toUpperCase()) != null; + }, + + getProperty(aName) { + let name = aName.toUpperCase(); + if (name in this.promotedProps) { + if (this.promotedProps[name] === true) { + // Complex promoted props will return undefined + return undefined; + } + return this[this.promotedProps[name]]; + } + return this.mProperties.get(name); + }, + + setProperty(aName, aValue) { + this.ensureMutable(); + let name = aName.toUpperCase(); + if (name in this.promotedProps) { + if (this.promotedProps[name] === true) { + cal.WARN(`Attempted to set complex property ${name} to a simple value ${aValue}`); + } else { + this[this.promotedProps[name]] = aValue; + } + } else { + this.mProperties.set(name, aValue); + } + return aValue; + }, + + deleteProperty(aName) { + this.ensureMutable(); + let name = aName.toUpperCase(); + if (name in this.promotedProps) { + this[this.promotedProps[name]] = null; + } else { + this.mProperties.delete(name); + } + }, + + get properties() { + return [...this.mProperties.entries()]; + }, + + toString(aItem) { + function alarmString(aPrefix) { + if (!aItem || aItem.isEvent()) { + return aPrefix + "Event"; + } else if (aItem.isTodo()) { + return aPrefix + "Task"; + } + return aPrefix; + } + + if (this.related == ALARM_RELATED_ABSOLUTE && this.mAbsoluteDate) { + // this is an absolute alarm. Use the calendar default timezone and + // format it. + let formatDate = this.mAbsoluteDate.getInTimezone(cal.dtz.defaultTimezone); + return cal.dtz.formatter.formatDateTime(formatDate); + } else if (this.related != ALARM_RELATED_ABSOLUTE && this.mOffset) { + // Relative alarm length + let alarmlen = Math.abs(this.mOffset.inSeconds / 60); + if (alarmlen == 0) { + // No need to get the other information if the alarm is at the start + // of the event/task. + if (this.related == ALARM_RELATED_START) { + return cal.l10n.getString("calendar-alarms", alarmString("reminderTitleAtStart")); + } else if (this.related == ALARM_RELATED_END) { + return cal.l10n.getString("calendar-alarms", alarmString("reminderTitleAtEnd")); + } + } + + let unit; + if (alarmlen % 1440 == 0) { + // Alarm is in days + unit = "unitDays"; + alarmlen /= 1440; + } else if (alarmlen % 60 == 0) { + unit = "unitHours"; + alarmlen /= 60; + } else { + unit = "unitMinutes"; + } + let localeUnitString = cal.l10n.getCalString(unit); + let unitString = PluralForm.get(alarmlen, localeUnitString).replace("#1", alarmlen); + let originStringName = "reminderCustomOrigin"; + + // Origin + switch (this.related) { + case ALARM_RELATED_START: + originStringName += "Begin"; + break; + case ALARM_RELATED_END: + originStringName += "End"; + break; + } + + if (this.offset.isNegative) { + originStringName += "Before"; + } else { + originStringName += "After"; + } + + let originString = cal.l10n.getString("calendar-alarms", alarmString(originStringName)); + return cal.l10n.getString("calendar-alarms", "reminderCustomTitle", [ + unitString, + originString, + ]); + } + // This is an incomplete alarm, but then again we should never reach + // this state. + return "[Incomplete calIAlarm]"; + }, +}; diff --git a/comm/calendar/base/src/CalAlarmMonitor.jsm b/comm/calendar/base/src/CalAlarmMonitor.jsm new file mode 100644 index 0000000000..0412e72850 --- /dev/null +++ b/comm/calendar/base/src/CalAlarmMonitor.jsm @@ -0,0 +1,233 @@ +/* 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 = ["CalAlarmMonitor"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const lazy = {}; +ChromeUtils.defineModuleGetter(lazy, "CalEvent", "resource:///modules/CalEvent.jsm"); + +function peekAlarmWindow() { + return Services.wm.getMostRecentWindow("Calendar:AlarmWindow"); +} + +/** + * The alarm monitor takes care of playing the alarm sound and opening one copy + * of the calendar-alarm-dialog. Both depend on their respective prefs to be + * set. This monitor is only used for DISPLAY type alarms. + */ +function CalAlarmMonitor() { + this.wrappedJSObject = this; + this.mAlarms = []; + // A map from itemId to item. + this._notifyingItems = new Map(); + + this.mSound = Cc["@mozilla.org/sound;1"].createInstance(Ci.nsISound); + + Services.obs.addObserver(this, "alarm-service-startup"); + Services.obs.addObserver(this, "alarm-service-shutdown"); +} + +var calAlarmMonitorClassID = Components.ID("{4b7ae030-ed79-11d9-8cd6-0800200c9a66}"); +var calAlarmMonitorInterfaces = [Ci.nsIObserver, Ci.calIAlarmServiceObserver]; +CalAlarmMonitor.prototype = { + mAlarms: null, + + // This is a work-around for the fact that there is a delay between when + // we call openWindow and when it appears via getMostRecentWindow. If an + // alarm is fired in that time-frame, it will actually end up in another window. + mWindowOpening: null, + + // nsISound instance used for playing all sounds + mSound: null, + + classID: calAlarmMonitorClassID, + QueryInterface: cal.generateQI(["nsIObserver", "calIAlarmServiceObserver"]), + classInfo: cal.generateCI({ + contractID: "@mozilla.org/calendar/alarm-monitor;1", + classDescription: "Calendar Alarm Monitor", + classID: calAlarmMonitorClassID, + interfaces: calAlarmMonitorInterfaces, + flags: Ci.nsIClassInfo.SINGLETON, + }), + + /** + * nsIObserver + */ + observe(aSubject, aTopic, aData) { + let alarmService = Cc["@mozilla.org/calendar/alarm-service;1"].getService(Ci.calIAlarmService); + switch (aTopic) { + case "alarm-service-startup": + alarmService.addObserver(this); + break; + case "alarm-service-shutdown": + alarmService.removeObserver(this); + break; + case "alertclickcallback": { + let item = this._notifyingItems.get(aData); + if (item) { + let calWindow = cal.window.getCalendarWindow(); + if (calWindow) { + calWindow.openEventDialogForViewing(item, true); + } + } + break; + } + case "alertfinished": + this._notifyingItems.delete(aData); + break; + } + }, + + /** + * calIAlarmServiceObserver + */ + onAlarm(aItem, aAlarm) { + if (aAlarm.action != "DISPLAY") { + // This monitor only looks for DISPLAY alarms. + return; + } + + this.mAlarms.push([aItem, aAlarm]); + + if (Services.prefs.getBoolPref("calendar.alarms.playsound", true)) { + // We want to make sure the user isn't flooded with alarms so we + // limit this using a preference. For example, if the user has 20 + // events that fire an alarm in the same minute, then the alarm + // sound will only play 5 times. All alarms will be shown in the + // dialog nevertheless. + let maxAlarmSoundCount = Services.prefs.getIntPref("calendar.alarms.maxsoundsperminute", 5); + let now = new Date(); + + if (!this.mLastAlarmSoundDate || now - this.mLastAlarmSoundDate >= 60000) { + // Last alarm was long enough ago, reset counters. Note + // subtracting JSDate results in microseconds. + this.mAlarmSoundCount = 0; + this.mLastAlarmSoundDate = now; + } else { + // Otherwise increase the counter + this.mAlarmSoundCount++; + } + + if (maxAlarmSoundCount > this.mAlarmSoundCount) { + // Only ring the alarm sound if we haven't hit the max count. + try { + let soundURL; + if (Services.prefs.getIntPref("calendar.alarms.soundType", 0) == 0) { + soundURL = "chrome://calendar/content/sound.wav"; + } else { + soundURL = Services.prefs.getStringPref("calendar.alarms.soundURL", null); + } + if (soundURL && soundURL.length > 0) { + soundURL = Services.io.newURI(soundURL); + this.mSound.play(soundURL); + } else { + this.mSound.beep(); + } + } catch (exc) { + cal.ERROR("Error playing alarm sound: " + exc); + } + } + } + + if (!Services.prefs.getBoolPref("calendar.alarms.show", true)) { + return; + } + + let calAlarmWindow = peekAlarmWindow(); + if (!calAlarmWindow && (!this.mWindowOpening || this.mWindowOpening.closed)) { + this.mWindowOpening = Services.ww.openWindow( + null, + "chrome://calendar/content/calendar-alarm-dialog.xhtml", + "_blank", + "chrome,dialog=yes,all,resizable", + this + ); + } + if (!this.mWindowOpening) { + calAlarmWindow.addWidgetFor(aItem, aAlarm); + } + }, + + /** + * @see {calIAlarmServiceObserver} + * @param {calIItemBase} item - The item to notify about. + */ + onNotification(item) { + // Don't notify about canceled events. + if (item.status == "CANCELLED") { + return; + } + // Don't notify if you declined this event invitation. + if ( + (item instanceof lazy.CalEvent || item instanceof Ci.calIEvent) && + item.calendar instanceof Ci.calISchedulingSupport && + item.calendar.isInvitation(item) && + item.calendar.getInvitedAttendee(item)?.participationStatus == "DECLINED" + ) { + return; + } + + let alert = Cc["@mozilla.org/alert-notification;1"].createInstance(Ci.nsIAlertNotification); + let alertsService = Cc["@mozilla.org/alerts-service;1"].getService(Ci.nsIAlertsService); + alert.init( + item.id, // name + "chrome://messenger/skin/icons/new-mail-alert.png", + item.title, + item.getProperty("description"), + true, // clickable + item.id // cookie + ); + this._notifyingItems.set(item.id, item); + alertsService.showAlert(alert, this); + }, + + window_onLoad() { + let calAlarmWindow = this.mWindowOpening; + this.mWindowOpening = null; + if (this.mAlarms.length > 0) { + for (let [item, alarm] of this.mAlarms) { + calAlarmWindow.addWidgetFor(item, alarm); + } + } else { + // Uh oh, it seems the alarms were removed even before the window + // finished loading. Looks like we can close it again + calAlarmWindow.closeIfEmpty(); + } + }, + + onRemoveAlarmsByItem(aItem) { + let calAlarmWindow = peekAlarmWindow(); + this.mAlarms = this.mAlarms.filter(([thisItem, alarm]) => { + let ret = aItem.hashId != thisItem.hashId; + if (!ret && calAlarmWindow) { + // window is open + calAlarmWindow.removeWidgetFor(thisItem, alarm); + } + return ret; + }); + }, + + onRemoveAlarmsByCalendar(calendar) { + let calAlarmWindow = peekAlarmWindow(); + this.mAlarms = this.mAlarms.filter(([thisItem, alarm]) => { + let ret = calendar.id != thisItem.calendar.id; + + if (!ret && calAlarmWindow) { + // window is open + calAlarmWindow.removeWidgetFor(thisItem, alarm); + } + return ret; + }); + }, + + onAlarmsLoaded(aCalendar) { + // the alarm dialog won't close while alarms are loading, check again now + let calAlarmWindow = peekAlarmWindow(); + if (calAlarmWindow && this.mAlarms.length == 0) { + calAlarmWindow.closeIfEmpty(); + } + }, +}; diff --git a/comm/calendar/base/src/CalAlarmService.jsm b/comm/calendar/base/src/CalAlarmService.jsm new file mode 100644 index 0000000000..916582a9af --- /dev/null +++ b/comm/calendar/base/src/CalAlarmService.jsm @@ -0,0 +1,827 @@ +/* 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 = ["CalAlarmService"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +const lazy = {}; +ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm"); + +var kHoursBetweenUpdates = 6; + +function nowUTC() { + return cal.dtz.jsDateToDateTime(new Date()).getInTimezone(cal.dtz.UTC); +} + +function newTimerWithCallback(aCallback, aDelay, aRepeating) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + + timer.initWithCallback( + aCallback, + aDelay, + aRepeating ? timer.TYPE_REPEATING_PRECISE : timer.TYPE_ONE_SHOT + ); + return timer; +} + +/** + * Keeps track of seemingly immutable items with alarms that we can't dismiss. + * Some servers quietly discard our modifications to repeating events which will + * cause dismissed alarms to re-appear if we do not keep track. + * + * We track the items by their "hashId" property storing it in the calendar + * property "alarms.ignored". + */ +const IgnoredAlarmsStore = { + /** + * @type {number} + */ + maxItemsPerCalendar: 1500, + + _getCache(item) { + return item.calendar.getProperty("alarms.ignored")?.split(",") || []; + }, + + /** + * Adds an item to the store. No alarms will be created for this item again. + * + * @param {calIItemBase} item + */ + add(item) { + let cache = this._getCache(item); + let id = item.parentItem.hashId; + if (!cache.includes(id)) { + if (cache.length >= this.maxItemsPerCalendar) { + cache[0] = id; + } else { + cache.push(id); + } + } + item.calendar.setProperty("alarms.ignored", cache.join(",")); + }, + + /** + * Returns true if the item's hashId is in the store. + * + * @param {calIItemBase} item + * @returns {boolean} + */ + has(item) { + return this._getCache(item).includes(item.parentItem.hashId); + }, +}; + +function CalAlarmService() { + this.wrappedJSObject = this; + + this.mLoadedCalendars = {}; + this.mTimerMap = {}; + this.mNotificationTimerMap = {}; + this.mObservers = new cal.data.ListenerSet(Ci.calIAlarmServiceObserver); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "gNotificationsTimes", + "calendar.notifications.times", + "", + () => this.initAlarms(cal.manager.getCalendars()) + ); + + this.calendarObserver = { + QueryInterface: ChromeUtils.generateQI(["calIObserver"]), + alarmService: this, + + calendarsInBatch: new Set(), + + // calIObserver: + onStartBatch(calendar) { + this.calendarsInBatch.add(calendar); + }, + onEndBatch(calendar) { + this.calendarsInBatch.delete(calendar); + }, + onLoad(calendar) { + // ignore any onLoad events until initial getItems() call of startup has finished: + if (calendar && this.alarmService.mLoadedCalendars[calendar.id]) { + // a refreshed calendar signals that it has been reloaded + // (and cannot notify detailed changes), thus reget all alarms of it: + this.alarmService.initAlarms([calendar]); + } + }, + + onAddItem(aItem) { + // If we're in a batch, ignore this notification. We're going to reload anyway. + if (!this.calendarsInBatch.has(aItem.calendar)) { + this.alarmService.addAlarmsForOccurrences(aItem); + } + }, + onModifyItem(aNewItem, aOldItem) { + // If we're in a batch, ignore this notification. We're going to reload anyway. + if (this.calendarsInBatch.has(aNewItem.calendar)) { + return; + } + + if (!aNewItem.recurrenceId) { + // deleting an occurrence currently calls modifyItem(newParent, *oldOccurrence*) + aOldItem = aOldItem.parentItem; + } + + this.onDeleteItem(aOldItem); + this.onAddItem(aNewItem); + }, + onDeleteItem(aDeletedItem) { + this.alarmService.removeAlarmsForOccurrences(aDeletedItem); + }, + onError(aCalendar, aErrNo, aMessage) {}, + onPropertyChanged(aCalendar, aName, aValue, aOldValue) { + switch (aName) { + case "suppressAlarms": + case "disabled": + case "notifications.times": + this.alarmService.initAlarms([aCalendar]); + break; + } + }, + onPropertyDeleting(aCalendar, aName) { + this.onPropertyChanged(aCalendar, aName); + }, + }; + + this.calendarManagerObserver = { + QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver"]), + alarmService: this, + + onCalendarRegistered(aCalendar) { + this.alarmService.observeCalendar(aCalendar); + // initial refresh of alarms for new calendar: + this.alarmService.initAlarms([aCalendar]); + }, + onCalendarUnregistering(aCalendar) { + // XXX todo: we need to think about calendar unregistration; + // there may still be dangling items (-> alarm dialog), + // dismissing those alarms may write data... + this.alarmService.unobserveCalendar(aCalendar); + delete this.alarmService.mLoadedCalendars[aCalendar.id]; + }, + onCalendarDeleting(aCalendar) { + this.alarmService.unobserveCalendar(aCalendar); + delete this.alarmService.mLoadedCalendars[aCalendar.id]; + }, + }; +} + +var calAlarmServiceClassID = Components.ID("{7a9200dd-6a64-4fff-a798-c5802186e2cc}"); +var calAlarmServiceInterfaces = [Ci.calIAlarmService, Ci.nsIObserver]; +CalAlarmService.prototype = { + mRangeStart: null, + mRangeEnd: null, + mUpdateTimer: null, + mStarted: false, + mTimerMap: null, + mObservers: null, + mTimezone: null, + + classID: calAlarmServiceClassID, + QueryInterface: cal.generateQI(["calIAlarmService", "nsIObserver"]), + classInfo: cal.generateCI({ + classID: calAlarmServiceClassID, + contractID: "@mozilla.org/calendar/alarm-service;1", + classDescription: "Calendar Alarm Service", + interfaces: calAlarmServiceInterfaces, + flags: Ci.nsIClassInfo.SINGLETON, + }), + + _logger: console.createInstance({ + prefix: "calendar.alarms", + maxLogLevel: "Warn", + maxLogLevelPref: "calendar.alarms.loglevel", + }), + + /** + * nsIObserver + */ + observe(aSubject, aTopic, aData) { + // This will also be called on app-startup, but nothing is done yet, to + // prevent unwanted dialogs etc. See bug 325476 and 413296 + if (aTopic == "profile-after-change" || aTopic == "wake_notification") { + this.shutdown(); + this.startup(); + } + if (aTopic == "xpcom-shutdown") { + this.shutdown(); + } + }, + + /** + * calIAlarmService APIs + */ + get timezone() { + // TODO Do we really need this? Do we ever set the timezone to something + // different than the default timezone? + return this.mTimezone || cal.dtz.defaultTimezone; + }, + + set timezone(aTimezone) { + this.mTimezone = aTimezone; + }, + + async snoozeAlarm(aItem, aAlarm, aDuration) { + // Right now we only support snoozing all alarms for the given item for + // aDuration. + + // Make sure we're working with the parent, otherwise we'll accidentally + // create an exception + let newEvent = aItem.parentItem.clone(); + let alarmTime = nowUTC(); + + // Set the last acknowledged time to now. + newEvent.alarmLastAck = alarmTime; + + alarmTime = alarmTime.clone(); + alarmTime.addDuration(aDuration); + + let propName = "X-MOZ-SNOOZE-TIME"; + if (aItem.parentItem != aItem) { + // This is the *really* hard case where we've snoozed a single + // instance of a recurring event. We need to not only know that + // there was a snooze, but also which occurrence was snoozed. Part + // of me just wants to create a local db of snoozes here... + propName = "X-MOZ-SNOOZE-TIME-" + aItem.recurrenceId.nativeTime; + } + newEvent.setProperty(propName, alarmTime.icalString); + + // calling modifyItem will cause us to get the right callback + // and update the alarm properly + let modifiedItem = await newEvent.calendar.modifyItem(newEvent, aItem.parentItem); + + if (modifiedItem.getProperty(propName) == alarmTime.icalString) { + return; + } + + // The server did not persist our changes for some reason. + // Include the item in the ignored list so we skip displaying alarms for + // this item in the future. + IgnoredAlarmsStore.add(aItem); + }, + + async dismissAlarm(aItem, aAlarm) { + if (cal.acl.isCalendarWritable(aItem.calendar) && cal.acl.userCanModifyItem(aItem)) { + let now = nowUTC(); + // We want the parent item, otherwise we're going to accidentally + // create an exception. We've relnoted (for 0.1) the slightly odd + // behavior this can cause if you move an event after dismissing an + // alarm + let oldParent = aItem.parentItem; + let newParent = oldParent.clone(); + newParent.alarmLastAck = now; + // Make sure to clear out any snoozes that were here. + if (aItem.recurrenceId) { + newParent.deleteProperty("X-MOZ-SNOOZE-TIME-" + aItem.recurrenceId.nativeTime); + } else { + newParent.deleteProperty("X-MOZ-SNOOZE-TIME"); + } + + let modifiedItem = await newParent.calendar.modifyItem(newParent, oldParent); + if (modifiedItem.alarmLastAck && now.compare(modifiedItem.alarmLastAck) == 0) { + return; + } + + // The server did not persist our changes for some reason. + // Include the item in the ignored list so we skip displaying alarms for + // this item in the future. + IgnoredAlarmsStore.add(aItem); + } + // if the calendar of the item is r/o, we simple remove the alarm + // from the list without modifying the item, so this works like + // effectively dismissing from a user's pov, since the alarm neither + // popups again in the current user session nor will be added after + // next restart, since it is missed then already + this.removeAlarmsForItem(aItem); + }, + + addObserver(aObserver) { + this.mObservers.add(aObserver); + }, + + removeObserver(aObserver) { + this.mObservers.delete(aObserver); + }, + + startup() { + if (this.mStarted) { + return; + } + + Services.obs.addObserver(this, "profile-after-change"); + Services.obs.addObserver(this, "xpcom-shutdown"); + Services.obs.addObserver(this, "wake_notification"); + + // Make sure the alarm monitor is alive so it's observing the notification. + Cc["@mozilla.org/calendar/alarm-monitor;1"].getService(Ci.calIAlarmServiceObserver); + // Tell people that we're alive so they can start monitoring alarms. + Services.obs.notifyObservers(null, "alarm-service-startup"); + + cal.manager.addObserver(this.calendarManagerObserver); + + for (let calendar of cal.manager.getCalendars()) { + this.observeCalendar(calendar); + } + + /* set up a timer to update alarms every N hours */ + let timerCallback = { + alarmService: this, + notify() { + let now = nowUTC(); + let start; + if (this.alarmService.mRangeEnd) { + // This is a subsequent search, so we got all the past alarms before + start = this.alarmService.mRangeEnd.clone(); + } else { + // This is our first search for alarms. We're going to look for + // alarms +/- 1 month from now. If someone sets an alarm more than + // a month ahead of an event, or doesn't start Thunderbird + // for a month, they'll miss some, but that's a slim chance + start = now.clone(); + start.month -= Ci.calIAlarmService.MAX_SNOOZE_MONTHS; + this.alarmService.mRangeStart = start.clone(); + } + let until = now.clone(); + until.month += Ci.calIAlarmService.MAX_SNOOZE_MONTHS; + + // We don't set timers for every future alarm, only those within 6 hours + let end = now.clone(); + end.hour += kHoursBetweenUpdates; + this.alarmService.mRangeEnd = end.getInTimezone(cal.dtz.UTC); + + this.alarmService.findAlarms(cal.manager.getCalendars(), start, until); + }, + }; + timerCallback.notify(); + + this.mUpdateTimer = newTimerWithCallback(timerCallback, kHoursBetweenUpdates * 3600000, true); + + this.mStarted = true; + }, + + shutdown() { + if (!this.mStarted) { + return; + } + + // Tell people that we're no longer running. + Services.obs.notifyObservers(null, "alarm-service-shutdown"); + + if (this.mUpdateTimer) { + this.mUpdateTimer.cancel(); + this.mUpdateTimer = null; + } + + cal.manager.removeObserver(this.calendarManagerObserver); + + // Stop observing all calendars. This will also clear the timers. + for (let calendar of cal.manager.getCalendars()) { + this.unobserveCalendar(calendar); + } + + this.mRangeEnd = null; + + Services.obs.removeObserver(this, "profile-after-change"); + Services.obs.removeObserver(this, "xpcom-shutdown"); + Services.obs.removeObserver(this, "wake_notification"); + + this.mStarted = false; + }, + + observeCalendar(calendar) { + calendar.addObserver(this.calendarObserver); + }, + + unobserveCalendar(calendar) { + calendar.removeObserver(this.calendarObserver); + this.disposeCalendarTimers([calendar]); + this.mObservers.notify("onRemoveAlarmsByCalendar", [calendar]); + }, + + addAlarmsForItem(aItem) { + if ((aItem.isTodo() && aItem.isCompleted) || IgnoredAlarmsStore.has(aItem)) { + // If this is a task and it is completed or the id is in the ignored list, + // don't add the alarm. + return; + } + + let showMissed = Services.prefs.getBoolPref("calendar.alarms.showmissed", true); + + let alarms = aItem.getAlarms(); + for (let alarm of alarms) { + let alarmDate = cal.alarms.calculateAlarmDate(aItem, alarm); + + if (!alarmDate || alarm.action != "DISPLAY") { + // Only take care of DISPLAY alarms with an alarm date. + continue; + } + + // Handle all day events. This is kinda weird, because they don't have + // a well defined startTime. We just consider the start/end to be + // midnight in the user's timezone. + if (alarmDate.isDate) { + alarmDate = alarmDate.getInTimezone(this.timezone); + alarmDate.isDate = false; + } + alarmDate = alarmDate.getInTimezone(cal.dtz.UTC); + + // Check for snooze + let snoozeDate; + if (aItem.parentItem == aItem) { + snoozeDate = aItem.getProperty("X-MOZ-SNOOZE-TIME"); + } else { + snoozeDate = aItem.parentItem.getProperty( + "X-MOZ-SNOOZE-TIME-" + aItem.recurrenceId.nativeTime + ); + } + + if ( + snoozeDate && + !(snoozeDate instanceof lazy.CalDateTime) && + !(snoozeDate instanceof Ci.calIDateTime) + ) { + snoozeDate = cal.createDateTime(snoozeDate); + } + + // an alarm can only be snoozed to a later time, if earlier it's from another alarm. + if (snoozeDate && snoozeDate.compare(alarmDate) > 0) { + // If the alarm was snoozed, the snooze time is more important. + alarmDate = snoozeDate; + } + + let now = nowUTC(); + if (alarmDate.timezone.isFloating) { + now = cal.dtz.now(); + now.timezone = cal.dtz.floating; + } + + if (alarmDate.compare(now) >= 0) { + // We assume that future alarms haven't been acknowledged + // Delay is in msec, so don't forget to multiply + let timeout = alarmDate.subtractDate(now).inSeconds * 1000; + + // No sense in keeping an extra timeout for an alarm that's past + // our range. + let timeUntilRefresh = this.mRangeEnd.subtractDate(now).inSeconds * 1000; + if (timeUntilRefresh < timeout) { + continue; + } + + this.addTimer(aItem, alarm, timeout); + } else if ( + showMissed && + cal.acl.isCalendarWritable(aItem.calendar) && + cal.acl.userCanModifyItem(aItem) + ) { + // This alarm is in the past and the calendar is writable, so we + // could snooze or dismiss alarms. See if it has been previously + // ack'd. + let lastAck = aItem.parentItem.alarmLastAck; + if (lastAck && lastAck.compare(alarmDate) >= 0) { + // The alarm was previously dismissed or snoozed, no further + // action required. + continue; + } else { + // The alarm was not snoozed or dismissed, fire it now. + this.alarmFired(aItem, alarm); + } + } + } + + this.addNotificationForItem(aItem); + }, + + removeAlarmsForItem(aItem) { + // make sure already fired alarms are purged out of the alarm window: + this.mObservers.notify("onRemoveAlarmsByItem", [aItem]); + // Purge alarms specifically for this item (i.e exception) + for (let alarm of aItem.getAlarms()) { + this.removeTimer(aItem, alarm); + } + + this.removeNotificationForItem(aItem); + }, + + /** + * Get the timeouts before notifications are fired for an item. + * + * @param {calIItemBase} item - A calendar item instance. + * @returns {number[]} Timeouts of notifications in milliseconds in ascending order. + */ + calculateNotificationTimeouts(item) { + let now = nowUTC(); + let until = now.clone(); + until.month += 1; + // We only care about items no more than a month ahead. + if (!cal.item.checkIfInRange(item, now, until)) { + return []; + } + let startDate = item[cal.dtz.startDateProp(item)]; + let endDate = item[cal.dtz.endDateProp(item)]; + let timeouts = []; + // The calendar level notifications setting overrides the global setting. + let prefValue = ( + item.calendar.getProperty("notifications.times") || this.gNotificationsTimes + ).split(","); + for (let entry of prefValue) { + entry = entry.trim(); + if (!entry) { + continue; + } + let [tag, value] = entry.split(":"); + if (!value) { + value = tag; + tag = ""; + } + let duration; + try { + duration = cal.createDuration(value); + } catch (e) { + this._logger.error(`Failed to parse ${entry}`, e); + continue; + } + let fireDate; + if (tag == "END" && endDate) { + fireDate = endDate.clone(); + } else if (startDate) { + fireDate = startDate.clone(); + } else { + continue; + } + fireDate.addDuration(duration); + let timeout = fireDate.subtractDate(now).inSeconds * 1000; + if (timeout > 0) { + timeouts.push(timeout); + } + } + return timeouts.sort((x, y) => x - y); + }, + + /** + * Set up notification timers for an item. + * + * @param {calIItemBase} item - A calendar item instance. + */ + addNotificationForItem(item) { + let alarmTimerCallback = { + notify: () => { + this.mObservers.notify("onNotification", [item]); + this.removeFiredNotificationTimer(item); + }, + }; + let timeouts = this.calculateNotificationTimeouts(item); + let timers = timeouts.map(timeout => newTimerWithCallback(alarmTimerCallback, timeout, false)); + + if (timers.length > 0) { + this._logger.debug( + `addNotificationForItem hashId=${item.hashId}: adding ${timers.length} timers, timeouts=${timeouts}` + ); + this.mNotificationTimerMap[item.calendar.id] = + this.mNotificationTimerMap[item.calendar.id] || {}; + this.mNotificationTimerMap[item.calendar.id][item.hashId] = timers; + } + }, + + /** + * Remove notification timers for an item. + * + * @param {calIItemBase} item - A calendar item instance. + */ + removeNotificationForItem(item) { + if ( + !this.mNotificationTimerMap[item.calendar.id] || + !this.mNotificationTimerMap[item.calendar.id][item.hashId] + ) { + return; + } + + for (let timer of this.mNotificationTimerMap[item.calendar.id][item.hashId]) { + timer.cancel(); + } + + delete this.mNotificationTimerMap[item.calendar.id][item.hashId]; + + // If the calendar map is empty, remove it from the timer map + if (Object.keys(this.mNotificationTimerMap[item.calendar.id]).length == 0) { + delete this.mNotificationTimerMap[item.calendar.id]; + } + }, + + /** + * Remove the first notification timers for an item to release some memory. + * + * @param {calIItemBase} item - A calendar item instance. + */ + removeFiredNotificationTimer(item) { + // The first timer is fired first. + let removed = this.mNotificationTimerMap[item.calendar.id][item.hashId].shift(); + + let remainingTimersCount = this.mNotificationTimerMap[item.calendar.id][item.hashId].length; + this._logger.debug( + `removeFiredNotificationTimer hashId=${item.hashId}: removed=${removed.delay}, remaining ${remainingTimersCount} timers` + ); + if (remainingTimersCount == 0) { + delete this.mNotificationTimerMap[item.calendar.id][item.hashId]; + } + + // If the calendar map is empty, remove it from the timer map + if (Object.keys(this.mNotificationTimerMap[item.calendar.id]).length == 0) { + delete this.mNotificationTimerMap[item.calendar.id]; + } + }, + + getOccurrencesInRange(aItem) { + // We search 1 month in each direction for alarms. Therefore, + // we need occurrences between initial start date and 1 month from now + let until = nowUTC(); + until.month += 1; + + if (aItem && aItem.recurrenceInfo) { + return aItem.recurrenceInfo.getOccurrences(this.mRangeStart, until, 0); + } + return cal.item.checkIfInRange(aItem, this.mRangeStart, until) ? [aItem] : []; + }, + + addAlarmsForOccurrences(aParentItem) { + let occs = this.getOccurrencesInRange(aParentItem); + + // Add an alarm for each occurrence + occs.forEach(this.addAlarmsForItem, this); + }, + + removeAlarmsForOccurrences(aParentItem) { + let occs = this.getOccurrencesInRange(aParentItem); + + // Remove alarm for each occurrence + occs.forEach(this.removeAlarmsForItem, this); + }, + + addTimer(aItem, aAlarm, aTimeout) { + this.mTimerMap[aItem.calendar.id] = this.mTimerMap[aItem.calendar.id] || {}; + this.mTimerMap[aItem.calendar.id][aItem.hashId] = + this.mTimerMap[aItem.calendar.id][aItem.hashId] || {}; + + let self = this; + let alarmTimerCallback = { + notify() { + self.alarmFired(aItem, aAlarm); + }, + }; + + let timer = newTimerWithCallback(alarmTimerCallback, aTimeout, false); + this.mTimerMap[aItem.calendar.id][aItem.hashId][aAlarm.icalString] = timer; + }, + + removeTimer(aItem, aAlarm) { + /* Is the calendar in the timer map */ + if ( + aItem.calendar.id in this.mTimerMap && + /* ...and is the item in the calendar map */ + aItem.hashId in this.mTimerMap[aItem.calendar.id] && + /* ...and is the alarm in the item map ? */ + aAlarm.icalString in this.mTimerMap[aItem.calendar.id][aItem.hashId] + ) { + // First cancel the existing timer + let timer = this.mTimerMap[aItem.calendar.id][aItem.hashId][aAlarm.icalString]; + timer.cancel(); + + // Remove the alarm from the item map + delete this.mTimerMap[aItem.calendar.id][aItem.hashId][aAlarm.icalString]; + + // If the item map is empty, remove it from the calendar map + if (this.mTimerMap[aItem.calendar.id][aItem.hashId].toSource() == "({})") { + delete this.mTimerMap[aItem.calendar.id][aItem.hashId]; + } + + // If the calendar map is empty, remove it from the timer map + if (this.mTimerMap[aItem.calendar.id].toSource() == "({})") { + delete this.mTimerMap[aItem.calendar.id]; + } + } + }, + + disposeCalendarTimers(aCalendars) { + for (let calendar of aCalendars) { + if (calendar.id in this.mTimerMap) { + for (let hashId in this.mTimerMap[calendar.id]) { + let itemTimerMap = this.mTimerMap[calendar.id][hashId]; + for (let icalString in itemTimerMap) { + let timer = itemTimerMap[icalString]; + timer.cancel(); + } + } + delete this.mTimerMap[calendar.id]; + } + if (calendar.id in this.mNotificationTimerMap) { + for (let timers of Object.values(this.mNotificationTimerMap[calendar.id])) { + for (let timer of timers) { + timer.cancel(); + } + } + delete this.mNotificationTimerMap[calendar.id]; + } + } + }, + + async findAlarms(aCalendars, aStart, aUntil) { + const calICalendar = Ci.calICalendar; + let filter = + calICalendar.ITEM_FILTER_COMPLETED_ALL | + calICalendar.ITEM_FILTER_CLASS_OCCURRENCES | + calICalendar.ITEM_FILTER_TYPE_ALL; + + await Promise.all( + aCalendars.map(async calendar => { + if (calendar.getProperty("suppressAlarms") && calendar.getProperty("disabled")) { + this.mLoadedCalendars[calendar.id] = true; + this.mObservers.notify("onAlarmsLoaded", [calendar]); + return; + } + + // Assuming that suppressAlarms does not change anymore until next refresh. + this.mLoadedCalendars[calendar.id] = false; + + for await (let items of cal.iterate.streamValues( + calendar.getItems(filter, 0, aStart, aUntil) + )) { + await new Promise((resolve, reject) => { + cal.iterate.forEach( + items, + item => { + try { + this.removeAlarmsForItem(item); + this.addAlarmsForItem(item); + } catch (e) { + console.error("Promise was rejected: " + e); + this.mLoadedCalendars[calendar.id] = true; + this.mObservers.notify("onAlarmsLoaded", [calendar]); + reject(e); + } + }, + () => { + resolve(); + } + ); + }); + } + + // The calendar has been loaded, so until now, onLoad events can be ignored. + this.mLoadedCalendars[calendar.id] = true; + + // Notify observers that the alarms for the calendar have been loaded. + this.mObservers.notify("onAlarmsLoaded", [calendar]); + }) + ); + }, + + initAlarms(aCalendars) { + // Purge out all alarm timers belonging to the refreshed/loaded calendars + this.disposeCalendarTimers(aCalendars); + + // Purge out all alarms from dialog belonging to the refreshed/loaded calendars + for (let calendar of aCalendars) { + this.mLoadedCalendars[calendar.id] = false; + this.mObservers.notify("onRemoveAlarmsByCalendar", [calendar]); + } + + // Total refresh similar to startup. We're going to look for + // alarms +/- 1 month from now. If someone sets an alarm more than + // a month ahead of an event, or doesn't start Thunderbird + // for a month, they'll miss some, but that's a slim chance + let start = nowUTC(); + let until = start.clone(); + start.month -= Ci.calIAlarmService.MAX_SNOOZE_MONTHS; + until.month += Ci.calIAlarmService.MAX_SNOOZE_MONTHS; + this.findAlarms(aCalendars, start, until); + }, + + alarmFired(aItem, aAlarm) { + if ( + !aItem.calendar.getProperty("suppressAlarms") && + !aItem.calendar.getProperty("disabled") && + aItem.getProperty("STATUS") != "CANCELLED" + ) { + this.mObservers.notify("onAlarm", [aItem, aAlarm]); + } + }, + + get isLoading() { + for (let calId in this.mLoadedCalendars) { + // we need to exclude calendars which failed to load explicitly to + // prevent the alaram dialog to stay opened after dismissing all + // alarms if there is a network calendar that failed to load + let currentStatus = cal.manager.getCalendarById(calId).getProperty("currentStatus"); + if (!this.mLoadedCalendars[calId] && Components.isSuccessCode(currentStatus)) { + return true; + } + } + return false; + }, +}; diff --git a/comm/calendar/base/src/CalAttachment.jsm b/comm/calendar/base/src/CalAttachment.jsm new file mode 100644 index 0000000000..df21188c8e --- /dev/null +++ b/comm/calendar/base/src/CalAttachment.jsm @@ -0,0 +1,169 @@ +/* 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 = ["CalAttachment"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/** + * Constructor for `calIAttachment` objects. + * + * @class + * @implements {calIAttachment} + * @param {string} [icalString] - Optional iCal string for initializing existing attachments. + */ +function CalAttachment(icalString) { + this.wrappedJSObject = this; + this.mProperties = new Map(); + if (icalString) { + this.icalString = icalString; + } +} + +CalAttachment.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIAttachment"]), + classID: Components.ID("{5f76b352-ab75-4c2b-82c9-9206dbbf8571}"), + + mData: null, + mHashId: null, + + get hashId() { + if (!this.mHashId) { + let cryptoHash = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); + let data = new TextEncoder().encode(this.rawData); + cryptoHash.init(cryptoHash.MD5); + cryptoHash.update(data, data.length); + this.mHashId = cryptoHash.finish(true); + } + return this.mHashId; + }, + + /** + * calIAttachment + */ + + get uri() { + let uri = null; + if (this.getParameter("VALUE") != "BINARY") { + // If this is not binary data, its likely an uri. Attempt to convert + // and throw otherwise. + try { + uri = Services.io.newURI(this.mData); + } catch (e) { + // Its possible that the uri contains malformed data. Often + // callers don't expect an exception here, so we just catch + // it and return null. + } + } + + return uri; + }, + set uri(aUri) { + // An uri is the default format, remove any value type parameters + this.deleteParameter("VALUE"); + this.setData(aUri.spec); + }, + + get rawData() { + return this.mData; + }, + set rawData(aData) { + // Setting the raw data lets us assume this is binary data. Make sure + // the value parameter is set + this.setParameter("VALUE", "BINARY"); + this.setData(aData); + }, + + get formatType() { + return this.getParameter("FMTTYPE"); + }, + set formatType(aType) { + this.setParameter("FMTTYPE", aType); + }, + + get encoding() { + return this.getParameter("ENCODING"); + }, + set encoding(aValue) { + this.setParameter("ENCODING", aValue); + }, + + get icalProperty() { + let icalatt = cal.icsService.createIcalProperty("ATTACH"); + + for (let [key, value] of this.mProperties.entries()) { + try { + icalatt.setParameter(key, value); + } catch (e) { + if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid attachment parameter value " + key + "=" + value); + } else { + throw e; + } + } + } + + if (this.mData) { + icalatt.value = this.mData; + } + return icalatt; + }, + + set icalProperty(attProp) { + // Reset the property bag for the parameters, it will be re-initialized + // from the ical property. + this.mProperties = new Map(); + this.setData(attProp.value); + + for (let [name, value] of cal.iterate.icalParameter(attProp)) { + this.setParameter(name, value); + } + }, + + get icalString() { + let comp = this.icalProperty; + return comp ? comp.icalString : ""; + }, + set icalString(val) { + let prop = cal.icsService.createIcalPropertyFromString(val); + if (prop.propertyName != "ATTACH") { + throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE); + } + this.icalProperty = prop; + }, + + getParameter(aName) { + return this.mProperties.get(aName); + }, + + setParameter(aName, aValue) { + if (aValue || aValue === 0) { + return this.mProperties.set(aName, aValue); + } + return this.mProperties.delete(aName); + }, + + deleteParameter(aName) { + this.mProperties.delete(aName); + }, + + clone() { + let newAttachment = new CalAttachment(); + newAttachment.mData = this.mData; + newAttachment.mHashId = this.mHashId; + for (let [name, value] of this.mProperties.entries()) { + newAttachment.mProperties.set(name, value); + } + return newAttachment; + }, + + setData(aData) { + // Sets the data and invalidates the hash so it will be recalculated + this.mHashId = null; + this.mData = aData; + return this.mData; + }, +}; diff --git a/comm/calendar/base/src/CalAttendee.jsm b/comm/calendar/base/src/CalAttendee.jsm new file mode 100644 index 0000000000..5edceabded --- /dev/null +++ b/comm/calendar/base/src/CalAttendee.jsm @@ -0,0 +1,212 @@ +/* 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/. */ + +/* import-globals-from calItemBase.js */ + +var EXPORTED_SYMBOLS = ["CalAttendee"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +Services.scriptloader.loadSubScript("resource:///components/calItemBase.js"); + +/** + * Constructor for `calIAttendee` objects. + * + * @class + * @implements {calIAttendee} + * @param {string} [icalString] - Optional iCal string for initializing existing attendees. + */ +function CalAttendee(icalString) { + this.wrappedJSObject = this; + this.mProperties = new Map(); + if (icalString) { + this.icalString = icalString; + } +} + +CalAttendee.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIAttendee"]), + classID: Components.ID("{5c8dcaa3-170c-4a73-8142-d531156f664d}"), + + mImmutable: false, + get isMutable() { + return !this.mImmutable; + }, + + modify() { + if (this.mImmutable) { + throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE); + } + }, + + makeImmutable() { + this.mImmutable = true; + }, + + clone() { + let a = new CalAttendee(); + + if (this.mIsOrganizer) { + a.isOrganizer = true; + } + + const allProps = ["id", "commonName", "rsvp", "role", "participationStatus", "userType"]; + for (let prop of allProps) { + a[prop] = this[prop]; + } + + for (let [key, value] of this.mProperties.entries()) { + a.setProperty(key, value); + } + + return a; + }, + // XXX enforce legal values for our properties; + + icalAttendeePropMap: [ + { cal: "rsvp", ics: "RSVP" }, + { cal: "commonName", ics: "CN" }, + { cal: "participationStatus", ics: "PARTSTAT" }, + { cal: "userType", ics: "CUTYPE" }, + { cal: "role", ics: "ROLE" }, + ], + + mIsOrganizer: false, + get isOrganizer() { + return this.mIsOrganizer; + }, + set isOrganizer(bool) { + this.mIsOrganizer = bool; + }, + + // icalatt is a calIcalProperty of type attendee + set icalProperty(icalatt) { + this.modify(); + this.id = icalatt.valueAsIcalString; + this.mIsOrganizer = icalatt.propertyName == "ORGANIZER"; + + let promotedProps = {}; + for (let prop of this.icalAttendeePropMap) { + this[prop.cal] = icalatt.getParameter(prop.ics); + // Don't copy these to the property bag. + promotedProps[prop.ics] = true; + } + + // Reset the property bag for the parameters, it will be re-initialized + // from the ical property. + this.mProperties = new Map(); + + for (let [name, value] of cal.iterate.icalParameter(icalatt)) { + if (!promotedProps[name]) { + this.setProperty(name, value); + } + } + }, + + get icalProperty() { + let icalatt; + if (this.mIsOrganizer) { + icalatt = cal.icsService.createIcalProperty("ORGANIZER"); + } else { + icalatt = cal.icsService.createIcalProperty("ATTENDEE"); + } + + if (!this.id) { + throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); + } + icalatt.valueAsIcalString = this.id; + for (let i = 0; i < this.icalAttendeePropMap.length; i++) { + let prop = this.icalAttendeePropMap[i]; + if (this[prop.cal]) { + try { + icalatt.setParameter(prop.ics, this[prop.cal]); + } catch (e) { + if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid attendee parameter value " + prop.ics + "=" + this[prop.cal]); + } else { + throw e; + } + } + } + } + for (let [key, value] of this.mProperties.entries()) { + try { + icalatt.setParameter(key, value); + } catch (e) { + if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid attendee parameter value " + key + "=" + value); + } else { + throw e; + } + } + } + return icalatt; + }, + + get icalString() { + let comp = this.icalProperty; + return comp ? comp.icalString : ""; + }, + set icalString(val) { + let prop = cal.icsService.createIcalPropertyFromString(val); + if (prop.propertyName != "ORGANIZER" && prop.propertyName != "ATTENDEE") { + throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE); + } + this.icalProperty = prop; + }, + + get properties() { + return [...this.mProperties.entries()]; + }, + + // The has/get/set/deleteProperty methods are case-insensitive. + getProperty(aName) { + return this.mProperties.get(aName.toUpperCase()); + }, + setProperty(aName, aValue) { + this.modify(); + if (aValue || !isNaN(parseInt(aValue, 10))) { + this.mProperties.set(aName.toUpperCase(), aValue); + } else { + this.mProperties.delete(aName.toUpperCase()); + } + }, + deleteProperty(aName) { + this.modify(); + this.mProperties.delete(aName.toUpperCase()); + }, + + mId: null, + get id() { + return this.mId; + }, + set id(aId) { + this.modify(); + // RFC 1738 para 2.1 says we should be using lowercase mailto: urls + // we enforce prepending the mailto prefix for email type ids as migration code bug 1199942 + this.mId = aId ? cal.email.prependMailTo(aId) : null; + }, + + toString() { + const emailRE = new RegExp("^mailto:", "i"); + let stringRep = (this.id || "").replace(emailRE, ""); + let commonName = this.commonName; + + if (commonName) { + stringRep = commonName + " <" + stringRep + ">"; + } + + return stringRep; + }, +}; + +makeMemberAttr(CalAttendee, "mCommonName", "commonName", null); +makeMemberAttr(CalAttendee, "mRsvp", "rsvp", null); +makeMemberAttr(CalAttendee, "mRole", "role", null); +makeMemberAttr(CalAttendee, "mParticipationStatus", "participationStatus", "NEEDS-ACTION"); +makeMemberAttr(CalAttendee, "mUserType", "userType", "INDIVIDUAL"); diff --git a/comm/calendar/base/src/CalCalendarManager.jsm b/comm/calendar/base/src/CalCalendarManager.jsm new file mode 100644 index 0000000000..117737a635 --- /dev/null +++ b/comm/calendar/base/src/CalCalendarManager.jsm @@ -0,0 +1,1076 @@ +/* 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/. */ + +/* import-globals-from calCachedCalendar.js */ + +var EXPORTED_SYMBOLS = ["CalCalendarManager"]; + +const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); +const { Preferences } = ChromeUtils.importESModule("resource://gre/modules/Preferences.sys.mjs"); +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +const { calCachedCalendar } = ChromeUtils.import("resource:///components/calCachedCalendar.js"); +const { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); + +var REGISTRY_BRANCH = "calendar.registry."; +var MAX_INT = Math.pow(2, 31) - 1; +var MIN_INT = -MAX_INT; + +function CalCalendarManager() { + this.wrappedJSObject = this; + this.mObservers = new cal.data.ListenerSet(Ci.calICalendarManagerObserver); + this.mCalendarObservers = new cal.data.ListenerSet(Ci.calIObserver); + + this.providerImplementations = {}; +} + +var calCalendarManagerClassID = Components.ID("{f42585e7-e736-4600-985d-9624c1c51992}"); +var calCalendarManagerInterfaces = [Ci.calICalendarManager, Ci.calIStartupService, Ci.nsIObserver]; +CalCalendarManager.prototype = { + classID: calCalendarManagerClassID, + QueryInterface: cal.generateQI(["calICalendarManager", "calIStartupService", "nsIObserver"]), + classInfo: cal.generateCI({ + classID: calCalendarManagerClassID, + contractID: "@mozilla.org/calendar/manager;1", + classDescription: "Calendar Manager", + interfaces: calCalendarManagerInterfaces, + flags: Ci.nsIClassInfo.SINGLETON, + }), + + get networkCalendarCount() { + return this.mNetworkCalendarCount; + }, + get readOnlyCalendarCount() { + return this.mReadonlyCalendarCount; + }, + get calendarCount() { + return this.mCalendarCount; + }, + + // calIStartupService: + startup(aCompleteListener) { + AddonManager.addAddonListener(gCalendarManagerAddonListener); + this.mCache = null; + this.mCalObservers = null; + this.mRefreshTimer = {}; + this.setupOfflineObservers(); + this.mNetworkCalendarCount = 0; + this.mReadonlyCalendarCount = 0; + this.mCalendarCount = 0; + + // We only add the observer if the pref is set and only check for the + // pref on startup to avoid checking for every http request + if (Services.prefs.getBoolPref("calendar.network.multirealm", false)) { + Services.obs.addObserver(this, "http-on-examine-response"); + } + + aCompleteListener.onResult(null, Cr.NS_OK); + }, + + shutdown(aCompleteListener) { + for (let id in this.mCache) { + let calendar = this.mCache[id]; + calendar.removeObserver(this.mCalObservers[calendar.id]); + } + + this.cleanupOfflineObservers(); + + AddonManager.removeAddonListener(gCalendarManagerAddonListener); + + // Remove the observer if the pref is set. This might fail when the + // user flips the pref, but we assume he is going to restart anyway + // afterwards. + if (Services.prefs.getBoolPref("calendar.network.multirealm", false)) { + Services.obs.removeObserver(this, "http-on-examine-response"); + } + + aCompleteListener.onResult(null, Cr.NS_OK); + }, + + setupOfflineObservers() { + Services.obs.addObserver(this, "network:offline-status-changed"); + }, + + cleanupOfflineObservers() { + Services.obs.removeObserver(this, "network:offline-status-changed"); + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "timer-callback": { + // Refresh all the calendars that can be refreshed. + for (let calendar of this.getCalendars()) { + maybeRefreshCalendar(calendar); + } + break; + } + case "network:offline-status-changed": { + for (let id in this.mCache) { + let calendar = this.mCache[id]; + if (calendar instanceof calCachedCalendar) { + calendar.onOfflineStatusChanged(aData == "offline"); + } + } + break; + } + case "http-on-examine-response": { + try { + let channel = aSubject.QueryInterface(Ci.nsIHttpChannel); + if (channel.notificationCallbacks) { + // We use the notification callbacks to get the calendar interface, which likely works + // for our requests since getInterface is called from the calendar provider context. + let authHeader = channel.getResponseHeader("WWW-Authenticate"); + let calendar = channel.notificationCallbacks.getInterface(Ci.calICalendar); + if (calendar && !calendar.getProperty("capabilities.realmrewrite.disabled")) { + // The provider may choose to explicitly disable the rewriting, for example if all + // calendars on a domain have the same credentials + let escapedName = calendar.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + authHeader = appendToRealm(authHeader, "(" + escapedName + ")"); + channel.setResponseHeader("WWW-Authenticate", authHeader, false); + } + } + } catch (e) { + if (e.result != Cr.NS_NOINTERFACE && e.result != Cr.NS_ERROR_NOT_AVAILABLE) { + throw e; + } + // Possible reasons we got here: + // - Its not a http channel (wtf? Oh well) + // - The owner is not a calICalendar (looks like its not our deal) + // - The WWW-Authenticate header is missing (that's ok) + } + break; + } + } + }, + + /** + * calICalendarManager interface + */ + createCalendar(type, uri) { + try { + let calendar; + if (Cc["@mozilla.org/calendar/calendar;1?type=" + type]) { + calendar = Cc["@mozilla.org/calendar/calendar;1?type=" + type].createInstance( + Ci.calICalendar + ); + } else if (this.providerImplementations[type]) { + let CalendarProvider = this.providerImplementations[type]; + calendar = new CalendarProvider(); + if (calendar.QueryInterface) { + calendar = calendar.QueryInterface(Ci.calICalendar); + } + } else { + // Don't notify the user with an extra dialog if the provider interface is missing. + return null; + } + + calendar.uri = uri; + return calendar; + } catch (ex) { + let rc = ex; + if (ex instanceof Ci.nsIException) { + rc = ex.result; + } + + let uiMessage = cal.l10n.getCalString("unableToCreateProvider", [uri.spec]); + + // Log the original exception via error console to provide more debug info + cal.ERROR(ex); + + // Log the possibly translated message via the UI. + let paramBlock = Cc["@mozilla.org/embedcomp/dialogparam;1"].createInstance( + Ci.nsIDialogParamBlock + ); + paramBlock.SetNumberStrings(3); + paramBlock.SetString(0, uiMessage); + paramBlock.SetString(1, "0x" + rc.toString(0x10)); + paramBlock.SetString(2, ex); + Services.ww.openWindow( + null, + "chrome://calendar/content/calendar-error-prompt.xhtml", + "_blank", + "chrome,dialog=yes,alwaysRaised=yes", + paramBlock + ); + return null; + } + }, + + /** + * Creates a calendar and takes care of initial setup, including enabled/disabled properties and + * cached calendars. If the provider doesn't exist, returns a dummy calendar that is + * force-disabled. + * + * @param {string} id - The calendar id. + * @param {string} ctype - The calendar type. See {@link calICalendar#type}. + * @param {string} uri - The calendar uri. + * @returns {calICalendar} The initialized calendar or dummy calendar. + */ + initializeCalendar(id, ctype, uri) { + let calendar = this.createCalendar(ctype, uri); + if (calendar) { + calendar.id = id; + if (calendar.getProperty("auto-enabled")) { + calendar.deleteProperty("disabled"); + calendar.deleteProperty("auto-enabled"); + } + + calendar = maybeWrapCachedCalendar(calendar); + } else { + // Create dummy calendar that stays disabled for this run. + calendar = new calDummyCalendar(ctype); + calendar.id = id; + calendar.uri = uri; + // Try to enable on next startup if calendar has been enabled. + if (!calendar.getProperty("disabled")) { + calendar.setProperty("auto-enabled", true); + } + calendar.setProperty("disabled", true); + } + + return calendar; + }, + + /** + * Update calendar registrations for the given type. If the provider is missing then the calendars + * are replaced with a dummy calendar, and vice versa. + * + * @param {string} type - The calendar type to update. See {@link calICalendar#type}. + * @param {boolean} [clearCache=false] - If true, the calendar cache is also cleared. + */ + updateDummyCalendarRegistration(type, clearCache = false) { + let hasImplementation = !!this.providerImplementations[type]; + + let calendars = Object.values(this.mCache).filter(calendar => { + // Calendars backed by providers despite missing provider implementation, or dummy calendars + // despite having a provider implementation. + let isDummyCalendar = calendar instanceof calDummyCalendar; + return calendar.type == type && hasImplementation == isDummyCalendar; + }); + this.updateCalendarRegistration(calendars, clearCache); + }, + + /** + * Update the calendar registrations for the given set of calendars. This essentially unregisters + * the calendar, then sets it up again using id, type and uri. This is similar to what happens on + * startup. + * + * @param {calICalendar[]} calendars - The calendars to update. + * @param {boolean} [clearCache=false] - If true, the calendar cache is also cleared. + */ + updateCalendarRegistration(calendars, clearCache = false) { + let sortOrderPref = Services.prefs.getStringPref("calendar.list.sortOrder", "").split(" "); + let sortOrder = {}; + for (let i = 0; i < sortOrderPref.length; i++) { + sortOrder[sortOrderPref[i]] = i; + } + + let needsRefresh = []; + for (let calendar of calendars) { + try { + this.notifyObservers("onCalendarUnregistering", [calendar]); + this.unsetupCalendar(calendar, clearCache); + + let replacement = this.initializeCalendar(calendar.id, calendar.type, calendar.uri); + replacement.setProperty("initialSortOrderPos", sortOrder[calendar.id]); + + this.setupCalendar(replacement); + needsRefresh.push(replacement); + } catch (e) { + cal.ERROR( + `Can't create calendar for ${calendar.id} (${calendar.type}, ${calendar.uri.spec}): ${e}` + ); + } + } + + // Do this in a second pass so that all provider calendars are available. + for (let calendar of needsRefresh) { + maybeRefreshCalendar(calendar); + this.notifyObservers("onCalendarRegistered", [calendar]); + } + }, + + /** + * Register a calendar provider with the given JavaScript implementation. + * + * @param {string} type - The calendar type string, see {@link calICalendar#type}. + * @param {object} impl - The class that implements calICalendar. + */ + registerCalendarProvider(type, impl) { + this.assureCache(); + + cal.ASSERT( + !this.providerImplementations.hasOwnProperty(type), + "[CalCalendarManager::registerCalendarProvider] provider already exists", + true + ); + + this.providerImplementations[type] = impl; + this.updateDummyCalendarRegistration(type); + }, + + /** + * Unregister a calendar provider by type. Already registered calendars will be replaced by a + * dummy calendar that is force-disabled. + * + * @param {string} type - The calendar type string, see {@link calICalendar#type}. + * @param {boolean} temporary - If true, cached calendars will not be cleared. + */ + unregisterCalendarProvider(type, temporary = false) { + cal.ASSERT( + this.providerImplementations.hasOwnProperty(type), + "[CalCalendarManager::unregisterCalendarProvider] provider doesn't exist or is builtin", + true + ); + delete this.providerImplementations[type]; + this.updateDummyCalendarRegistration(type, !temporary); + }, + + /** + * Checks if a calendar provider has been dynamically registered with the given type. This does + * not check for the built-in XPCOM providers. + * + * @param {string} type - The calendar type string, see {@link calICalendar#type}. + * @returns {boolean} True, if the calendar provider type is registered. + */ + hasCalendarProvider(type) { + return !!this.providerImplementations[type]; + }, + + registerCalendar(calendar) { + this.assureCache(); + + // If the calendar is already registered, bail out + cal.ASSERT( + !calendar.id || !(calendar.id in this.mCache), + "[CalCalendarManager::registerCalendar] calendar already registered!", + true + ); + + if (!calendar.id) { + calendar.id = cal.getUUID(); + } + + Services.prefs.setStringPref(getPrefBranchFor(calendar.id) + "type", calendar.type); + Services.prefs.setStringPref(getPrefBranchFor(calendar.id) + "uri", calendar.uri.spec); + + calendar = maybeWrapCachedCalendar(calendar); + + this.setupCalendar(calendar); + flushPrefs(); + + maybeRefreshCalendar(calendar); + this.notifyObservers("onCalendarRegistered", [calendar]); + }, + + /** + * Sets up a calendar, this is the initialization required during calendar registration. See + * {@link #unsetupCalendar} to revert these steps. + * + * @param {calICalendar} calendar - The calendar to set up. + */ + setupCalendar(calendar) { + this.mCache[calendar.id] = calendar; + + // Add an observer to track readonly-mode triggers + let newObserver = new calMgrCalendarObserver(calendar, this); + calendar.addObserver(newObserver); + this.mCalObservers[calendar.id] = newObserver; + + // Set up statistics + if (calendar.getProperty("requiresNetwork") !== false) { + this.mNetworkCalendarCount++; + } + if (calendar.readOnly) { + this.mReadonlyCalendarCount++; + } + this.mCalendarCount++; + + // Set up the refresh timer + this.setupRefreshTimer(calendar); + }, + + /** + * Reverts the calendar registration setup steps from {@link #setupCalendar}. + * + * @param {calICalendar} calendar - The calendar to undo setup for. + * @param {boolean} [clearCache=false] - If true, the cache is cleared for this calendar. + */ + unsetupCalendar(calendar, clearCache = false) { + if (this.mCache) { + delete this.mCache[calendar.id]; + } + + if (clearCache && calendar.wrappedJSObject instanceof calCachedCalendar) { + calendar.wrappedJSObject.onCalendarUnregistering(); + } + + calendar.removeObserver(this.mCalObservers[calendar.id]); + + if (calendar.readOnly) { + this.mReadonlyCalendarCount--; + } + + if (calendar.getProperty("requiresNetwork") !== false) { + this.mNetworkCalendarCount--; + } + this.mCalendarCount--; + + this.clearRefreshTimer(calendar); + }, + + setupRefreshTimer(aCalendar) { + // Add the refresh timer for this calendar + let refreshInterval = aCalendar.getProperty("refreshInterval"); + if (refreshInterval === null) { + // Default to 30 minutes, in case the value is missing + refreshInterval = 30; + } + + this.clearRefreshTimer(aCalendar); + + if (refreshInterval > 0) { + this.mRefreshTimer[aCalendar.id] = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + + this.mRefreshTimer[aCalendar.id].initWithCallback( + new timerCallback(aCalendar), + refreshInterval * 60000, + Ci.nsITimer.TYPE_REPEATING_SLACK + ); + } + }, + + clearRefreshTimer(aCalendar) { + if (aCalendar.id in this.mRefreshTimer && this.mRefreshTimer[aCalendar.id]) { + this.mRefreshTimer[aCalendar.id].cancel(); + delete this.mRefreshTimer[aCalendar.id]; + } + }, + + unregisterCalendar(calendar) { + this.notifyObservers("onCalendarUnregistering", [calendar]); + this.unsetupCalendar(calendar, true); + + deletePrefBranch(calendar.id); + flushPrefs(); + }, + + removeCalendar(calendar, mode = 0) { + const cICM = Ci.calICalendarManager; + + let removeModes = new Set(calendar.getProperty("capabilities.removeModes") || ["unsubscribe"]); + if (!removeModes.has("unsubscribe") && !removeModes.has("delete")) { + // Removing is not allowed + return; + } + + if (mode & cICM.REMOVE_NO_UNREGISTER && this.mCache && calendar.id in this.mCache) { + throw new Components.Exception("Can't remove a registered calendar"); + } else if (!(mode & cICM.REMOVE_NO_UNREGISTER)) { + this.unregisterCalendar(calendar); + } + + // This observer notification needs to be fired for both unsubscribe + // and delete, we don't differ this at the moment. + this.notifyObservers("onCalendarDeleting", [calendar]); + + // For deleting, we also call the deleteCalendar method from the provider. + if (removeModes.has("delete") && (mode & cICM.REMOVE_NO_DELETE) == 0) { + let wrappedCalendar = calendar.QueryInterface(Ci.calICalendarProvider); + wrappedCalendar.deleteCalendar(calendar, null); + } + }, + + getCalendarById(aId) { + if (aId in this.mCache) { + return this.mCache[aId]; + } + return null; + }, + + getCalendars() { + this.assureCache(); + let calendars = []; + for (let id in this.mCache) { + let calendar = this.mCache[id]; + calendars.push(calendar); + } + return calendars; + }, + + /** + * Load calendars from the pref branch, if they haven't already been loaded. The calendar + * instances will end up in mCache and are refreshed when complete. + */ + assureCache() { + if (this.mCache) { + return; + } + + this.mCache = {}; + this.mCalObservers = {}; + + let allCals = {}; + for (let key of Services.prefs.getChildList(REGISTRY_BRANCH)) { + // merge down all keys + allCals[key.substring(0, key.indexOf(".", REGISTRY_BRANCH.length))] = true; + } + + for (let calBranch in allCals) { + let id = calBranch.substring(REGISTRY_BRANCH.length); + let ctype = Services.prefs.getStringPref(calBranch + ".type", null); + let curi = Services.prefs.getStringPref(calBranch + ".uri", null); + + try { + if (!ctype || !curi) { + // sanity check + deletePrefBranch(id); + continue; + } + + let uri = Services.io.newURI(curi); + let calendar = this.initializeCalendar(id, ctype, uri); + this.setupCalendar(calendar); + } catch (exc) { + cal.ERROR(`Can't create calendar for ${id} (${ctype}, ${curi}): ${exc}`); + } + } + + let shouldResyncGoogleCalDav = false; + if (!Services.prefs.prefHasUserValue("calendar.caldav.googleResync")) { + // Some users' calendars got into a bad state due to Google rate-limit + // problems so this code triggers a full resync. + shouldResyncGoogleCalDav = true; + } + + // do refreshing in a second step, when *all* calendars are already available + // via getCalendars(): + for (let calendar of Object.values(this.mCache)) { + let delay = 0; + + // The special-casing of ICS here is a very ugly hack. We can delay most + // cached calendars without an issue, but the ICS implementation has two + // properties which make that dangerous in its case: + // + // 1) ICS files can only be written whole cloth. Since it's a plain file, + // we need to know the entire contents of what we want to write. + // + // 2) It is backed by a memory calendar which it regards as its source of + // truth, and the backing calendar is only populated on a refresh. + // + // The combination of these two means that any update to the ICS calendar + // before the memory calendar is populated will erase everything in the + // calendar (except potentially the added item if that's what we're + // doing). A 15 second window for data loss-inducing updates isn't huge, + // but it's more than we should bet on. + // + // Why not fix this a different way? Trying to populate the memory + // calendar outside of a refresh causes the caching calendar to get + // confused about event ownership and identity, leading to bogus observer + // notifications and potential duplication of events in some parts of the + // interface. Having the ICS calendar refresh itself internally can cause + // disabled calendars to behave improperly, since calendars don't actually + // enforce their own disablement and may not know if they're disabled + // until after we try to refresh. Having the ICS calendar ensure it has + // refreshed itself before trying to make updates would require a fair bit + // of refactoring in its processing queue and, while it should probably + // happen, fingers crossed we can rework the provider architecture to make + // many of these problems less of an issue first. + const canDelay = calendar.getProperty("cache.enabled") && calendar.type != "ics"; + + if (canDelay) { + // If the calendar is cached, we don't need to refresh it RIGHT NOW, so let's wait a + // while and let other things happen first. + delay = 15000; + + if ( + shouldResyncGoogleCalDav && + calendar.type == "caldav" && + calendar.uri.prePath == "https://apidata.googleusercontent.com" + ) { + cal.LOG(`CalDAV: Resetting sync token of ${calendar.name} to perform a full resync`); + let calCachedCalendar = calendar.wrappedJSObject; + let calDavCalendar = calCachedCalendar.mUncachedCalendar.wrappedJSObject; + calDavCalendar.mWebdavSyncToken = null; + calDavCalendar.saveCalendarProperties(); + } + } + setTimeout(() => maybeRefreshCalendar(calendar), delay); + } + + if (shouldResyncGoogleCalDav) { + // Record the fact that we've scheduled a resync, so that we only do it once. + // Store the date instead of a boolean because we might want to use this again some day. + Services.prefs.setIntPref("calendar.caldav.googleResync", Date.now() / 1000); + } + }, + + getCalendarPref_(calendar, name) { + cal.ASSERT(calendar, "Invalid Calendar!"); + cal.ASSERT(calendar.id !== null, "Calendar id needs to be set!"); + cal.ASSERT(name && name.length > 0, "Pref Name must be non-empty!"); + + let branch = getPrefBranchFor(calendar.id) + name; + let value = Preferences.get(branch, null); + + if (typeof value == "string" && value.startsWith("bignum:")) { + let converted = Number(value.substr(7)); + if (!isNaN(converted)) { + value = converted; + } + } + return value; + }, + + setCalendarPref_(calendar, name, value) { + cal.ASSERT(calendar, "Invalid Calendar!"); + cal.ASSERT(calendar.id !== null, "Calendar id needs to be set!"); + cal.ASSERT(name && name.length > 0, "Pref Name must be non-empty!"); + + let branch = getPrefBranchFor(calendar.id) + name; + + if ( + typeof value == "number" && + (value > MAX_INT || value < MIN_INT || !Number.isInteger(value)) + ) { + // This is something the preferences service can't store directly. + // Convert to string and tag it so we know how to handle it. + value = "bignum:" + value; + } + + // Delete before to allow pref-type changes, then set the pref. + Services.prefs.clearUserPref(branch); + if (value !== null && value !== undefined) { + Preferences.set(branch, value); + } + }, + + deleteCalendarPref_(calendar, name) { + cal.ASSERT(calendar, "Invalid Calendar!"); + cal.ASSERT(calendar.id !== null, "Calendar id needs to be set!"); + cal.ASSERT(name && name.length > 0, "Pref Name must be non-empty!"); + Services.prefs.clearUserPref(getPrefBranchFor(calendar.id) + name); + }, + + mObservers: null, + addObserver(aObserver) { + this.mObservers.add(aObserver); + }, + removeObserver(aObserver) { + this.mObservers.delete(aObserver); + }, + notifyObservers(functionName, args) { + this.mObservers.notify(functionName, args); + }, + + mCalendarObservers: null, + addCalendarObserver(aObserver) { + return this.mCalendarObservers.add(aObserver); + }, + removeCalendarObserver(aObserver) { + return this.mCalendarObservers.delete(aObserver); + }, + notifyCalendarObservers(functionName, args) { + this.mCalendarObservers.notify(functionName, args); + }, +}; + +function equalMessage(msg1, msg2) { + if ( + msg1.GetString(0) == msg2.GetString(0) && + msg1.GetString(1) == msg2.GetString(1) && + msg1.GetString(2) == msg2.GetString(2) + ) { + return true; + } + return false; +} + +function calMgrCalendarObserver(calendar, calMgr) { + this.calendar = calendar; + // We compare this to determine if the state actually changed. + this.storedReadOnly = calendar.readOnly; + this.announcedMessages = []; + this.calMgr = calMgr; +} + +calMgrCalendarObserver.prototype = { + calendar: null, + storedReadOnly: null, + calMgr: null, + + QueryInterface: ChromeUtils.generateQI(["nsIWindowMediatorListener", "calIObserver"]), + + // calIObserver: + onStartBatch() { + return this.calMgr.notifyCalendarObservers("onStartBatch", arguments); + }, + onEndBatch() { + return this.calMgr.notifyCalendarObservers("onEndBatch", arguments); + }, + onLoad(calendar) { + return this.calMgr.notifyCalendarObservers("onLoad", arguments); + }, + onAddItem(aItem) { + return this.calMgr.notifyCalendarObservers("onAddItem", arguments); + }, + onModifyItem(aNewItem, aOldItem) { + return this.calMgr.notifyCalendarObservers("onModifyItem", arguments); + }, + onDeleteItem(aDeletedItem) { + return this.calMgr.notifyCalendarObservers("onDeleteItem", arguments); + }, + onError(aCalendar, aErrNo, aMessage) { + this.calMgr.notifyCalendarObservers("onError", arguments); + this.announceError(aCalendar, aErrNo, aMessage); + }, + + onPropertyChanged(aCalendar, aName, aValue, aOldValue) { + this.calMgr.notifyCalendarObservers("onPropertyChanged", arguments); + switch (aName) { + case "requiresNetwork": + this.calMgr.mNetworkCalendarCount += aValue ? 1 : -1; + break; + case "readOnly": + this.calMgr.mReadonlyCalendarCount += aValue ? 1 : -1; + break; + case "refreshInterval": + this.calMgr.setupRefreshTimer(aCalendar); + break; + case "cache.enabled": + this.changeCalendarCache(...arguments); + break; + case "disabled": + if (!aValue && aCalendar.canRefresh) { + aCalendar.refresh(); + } + break; + } + }, + + changeCalendarCache(aCalendar, aName, aValue, aOldValue) { + const cICM = Ci.calICalendarManager; + aOldValue = aOldValue || false; + aValue = aValue || false; + + // hack for bug 1182264 to deal with calendars, which have set cache.enabled, but in fact do + // not support caching (like storage calendars) - this also prevents enabling cache again + if (aCalendar.getProperty("cache.supported") === false) { + if (aCalendar.getProperty("cache.enabled") === true) { + aCalendar.deleteProperty("cache.enabled"); + } + return; + } + + if (aOldValue != aValue) { + // Try to find the current sort order + let sortOrderPref = Services.prefs.getStringPref("calendar.list.sortOrder", "").split(" "); + let initialSortOrderPos = null; + for (let i = 0; i < sortOrderPref.length; ++i) { + if (sortOrderPref[i] == aCalendar.id) { + initialSortOrderPos = i; + } + } + // Enabling or disabling cache on a calendar re-creates + // it so the registerCalendar call can wrap/unwrap the + // calCachedCalendar facade saving the user the need to + // restart Thunderbird and making sure a new Id is used. + this.calMgr.removeCalendar(aCalendar, cICM.REMOVE_NO_DELETE); + let newCal = this.calMgr.createCalendar(aCalendar.type, aCalendar.uri); + newCal.name = aCalendar.name; + + // TODO: if properties get added this list will need to be adjusted, + // ideally we should add a "getProperties" method to calICalendar.idl + // to retrieve all non-transient properties for a calendar. + let propsToCopy = [ + "color", + "disabled", + "auto-enabled", + "cache.enabled", + "refreshInterval", + "suppressAlarms", + "calendar-main-in-composite", + "calendar-main-default", + "readOnly", + "imip.identity.key", + "username", + ]; + for (let prop of propsToCopy) { + newCal.setProperty(prop, aCalendar.getProperty(prop)); + } + + if (initialSortOrderPos != null) { + newCal.setProperty("initialSortOrderPos", initialSortOrderPos); + } + this.calMgr.registerCalendar(newCal); + } else if (aCalendar.wrappedJSObject instanceof calCachedCalendar) { + // any attempt to switch this flag will reset the cached calendar; + // could be useful for users in case the cache may be corrupted. + aCalendar.wrappedJSObject.setupCachedCalendar(); + } + }, + + onPropertyDeleting(aCalendar, aName) { + this.onPropertyChanged(aCalendar, aName, false, true); + }, + + // Error announcer specific functions + announceError(aCalendar, aErrNo, aMessage) { + let paramBlock = Cc["@mozilla.org/embedcomp/dialogparam;1"].createInstance( + Ci.nsIDialogParamBlock + ); + let props = Services.strings.createBundle("chrome://calendar/locale/calendar.properties"); + let errMsg; + paramBlock.SetNumberStrings(3); + if (!this.storedReadOnly && this.calendar.readOnly) { + // Major errors change the calendar to readOnly + errMsg = props.formatStringFromName("readOnlyMode", [this.calendar.name]); + } else if (!this.storedReadOnly && !this.calendar.readOnly) { + // Minor errors don't, but still tell the user something went wrong + errMsg = props.formatStringFromName("minorError", [this.calendar.name]); + } else { + // The calendar was already in readOnly mode, but still tell the user + errMsg = props.formatStringFromName("stillReadOnlyError", [this.calendar.name]); + } + + // When possible, change the error number into its name, to + // make it slightly more readable. + let errCode = "0x" + aErrNo.toString(16); + const calIErrors = Ci.calIErrors; + // Check if it is worth enumerating all the error codes. + if (aErrNo & calIErrors.ERROR_BASE) { + for (let err in calIErrors) { + if (calIErrors[err] == aErrNo) { + errCode = err; + } + } + } + + let message; + switch (aErrNo) { + case calIErrors.CAL_UTF8_DECODING_FAILED: + message = props.GetStringFromName("utf8DecodeError"); + break; + case calIErrors.ICS_MALFORMEDDATA: + message = props.GetStringFromName("icsMalformedError"); + break; + case calIErrors.MODIFICATION_FAILED: + errMsg = cal.l10n.getCalString("errorWriting2", [aCalendar.name]); + message = cal.l10n.getCalString("errorWritingDetails"); + if (aMessage) { + message = aMessage + "\n" + message; + } + break; + default: + message = aMessage; + } + + paramBlock.SetString(0, errMsg); + paramBlock.SetString(1, errCode); + paramBlock.SetString(2, message); + + this.storedReadOnly = this.calendar.readOnly; + let errorCode = cal.l10n.getCalString("errorCode", [errCode]); + let errorDescription = cal.l10n.getCalString("errorDescription", [message]); + let summary = errMsg + " " + errorCode + ". " + errorDescription; + + // Log warnings in error console. + // Report serious errors in both error console and in prompt window. + if (aErrNo == calIErrors.MODIFICATION_FAILED) { + console.error(summary); + this.announceParamBlock(paramBlock); + } else { + cal.WARN(summary); + } + }, + + announceParamBlock(paramBlock) { + function awaitLoad(event) { + promptWindow.addEventListener("unload", awaitUnload, { capture: false, once: true }); + } + let awaitUnload = event => { + // unloaded (user closed prompt window), + // remove paramBlock and unload listener. + try { + // remove the message that has been shown from + // the list of all announced messages. + this.announcedMessages = this.announcedMessages.filter(msg => { + return !equalMessage(msg, paramBlock); + }); + } catch (e) { + console.error(e); + } + }; + + // silently don't do anything if this message already has been + // announced without being acknowledged. + if (this.announcedMessages.some(equalMessage.bind(null, paramBlock))) { + return; + } + + // this message hasn't been announced recently, remember the details of + // the message for future reference. + this.announcedMessages.push(paramBlock); + + // Will remove paramBlock from announced messages when promptWindow is + // closed. (Closing fires unloaded event, but promptWindow is also + // unloaded [to clean it?] before loading, so wait for detected load + // event before detecting unload event that signifies user closed this + // prompt window.) + let promptUrl = "chrome://calendar/content/calendar-error-prompt.xhtml"; + let features = "chrome,dialog=yes,alwaysRaised=yes"; + let promptWindow = Services.ww.openWindow(null, promptUrl, "_blank", features, paramBlock); + promptWindow.addEventListener("load", awaitLoad, { capture: false, once: true }); + }, +}; + +function calDummyCalendar(type) { + this.initProviderBase(); + this.type = type; +} +calDummyCalendar.prototype = { + __proto__: cal.provider.BaseClass.prototype, + + getProperty(aName) { + switch (aName) { + case "force-disabled": + return true; + default: + return this.__proto__.__proto__.getProperty.apply(this, arguments); + } + }, +}; + +function getPrefBranchFor(id) { + return REGISTRY_BRANCH + id + "."; +} + +/** + * Removes a calendar from the preferences. + * + * @param {string} id - ID of the calendar to remove. + */ +function deletePrefBranch(id) { + for (let prefName of Services.prefs.getChildList(getPrefBranchFor(id))) { + Services.prefs.clearUserPref(prefName); + } +} + +/** + * Helper to refresh a calendar, if it can be refreshed and isn't disabled. + * + * @param {calICalendar} calendar - The calendar to refresh. + */ +function maybeRefreshCalendar(calendar) { + if (!calendar.getProperty("disabled") && calendar.canRefresh) { + let refreshInterval = calendar.getProperty("refreshInterval"); + if (refreshInterval != "0") { + calendar.refresh(); + } + } +} + +/** + * Wrap a calendar using {@link calCachedCalendar}, if the cache is supported and enabled. + * Otherwise just return the passed in calendar. + * + * @param {calICalendar} calendar - The calendar to potentially wrap. + * @returns {calICalendar} The potentially wrapped calendar. + */ +function maybeWrapCachedCalendar(calendar) { + if ( + calendar.getProperty("cache.supported") !== false && + (calendar.getProperty("cache.enabled") || calendar.getProperty("cache.always")) + ) { + calendar = new calCachedCalendar(calendar); + } + return calendar; +} + +/** + * Helper function to flush the preferences file. If the application crashes + * after a calendar has been created using the prefs registry, then the calendar + * won't show up. Writing the prefs helps counteract. + */ +function flushPrefs() { + Services.prefs.savePrefFile(null); +} + +/** + * Callback object for the refresh timer. Should be called as an object, i.e + * let foo = new timerCallback(calendar); + * + * @param aCalendar The calendar to refresh on notification + */ +function timerCallback(aCalendar) { + this.notify = function (aTimer) { + if (!aCalendar.getProperty("disabled") && aCalendar.canRefresh) { + aCalendar.refresh(); + } + }; +} + +var gCalendarManagerAddonListener = { + onDisabling(aAddon, aNeedsRestart) { + if (!this.queryUninstallProvider(aAddon)) { + // If the addon should not be disabled, then re-enable it. + aAddon.userDisabled = false; + } + }, + + onUninstalling(aAddon, aNeedsRestart) { + if (!this.queryUninstallProvider(aAddon)) { + // If the addon should not be uninstalled, then cancel the uninstall. + aAddon.cancelUninstall(); + } + }, + + queryUninstallProvider(aAddon) { + const uri = "chrome://calendar/content/calendar-providerUninstall-dialog.xhtml"; + const features = "chrome,titlebar,resizable,modal"; + let affectedCalendars = cal.manager + .getCalendars() + .filter(calendar => calendar.providerID == aAddon.id); + if (!affectedCalendars.length) { + // If no calendars are affected, then everything is fine. + return true; + } + + let args = { shouldUninstall: false, extension: aAddon }; + + // Now find a window. The best choice would be the most recent + // addons window, otherwise the most recent calendar window, or we + // create a new toplevel window. + let win = + Services.wm.getMostRecentWindow("Extension:Manager") || cal.window.getCalendarWindow(); + if (win) { + win.openDialog(uri, "CalendarProviderUninstallDialog", features, args); + } else { + // Use the window watcher to open a parentless window. + Services.ww.openWindow(null, uri, "CalendarProviderUninstallWindow", features, args); + } + + // Now that we are done, check if the dialog was accepted or canceled. + return args.shouldUninstall; + }, +}; + +function appendToRealm(authHeader, appendStr) { + let isEscaped = false; + let idx = authHeader.search(/realm="(.*?)(\\*)"/); + if (idx > -1) { + let remain = authHeader.substr(idx + 7); + idx += 7; + while (remain.length && !isEscaped) { + let match = remain.match(/(.*?)(\\*)"/); + idx += match[0].length; + + isEscaped = match[2].length % 2 == 0; + if (!isEscaped) { + remain = remain.substr(match[0].length); + } + } + return authHeader.substr(0, idx - 1) + " " + appendStr + authHeader.substr(idx - 1); + } + return authHeader; +} diff --git a/comm/calendar/base/src/CalDateTime.jsm b/comm/calendar/base/src/CalDateTime.jsm new file mode 100644 index 0000000000..8c7daefa32 --- /dev/null +++ b/comm/calendar/base/src/CalDateTime.jsm @@ -0,0 +1,202 @@ +/* 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 = ["CalDateTime"]; + +const { ICAL, unwrap, unwrapSetter } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm"); + +const lazy = {}; +ChromeUtils.defineModuleGetter(lazy, "CalDuration", "resource:///modules/CalDuration.jsm"); +ChromeUtils.defineModuleGetter(lazy, "CalTimezone", "resource:///modules/CalTimezone.jsm"); + +var UNIX_TIME_TO_PRTIME = 1000000; + +function CalDateTime(innerObject) { + this.wrappedJSObject = this; + this.innerObject = innerObject || ICAL.Time.epochTime.clone(); +} + +CalDateTime.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIDateTime"]), + classID: Components.ID("{36783242-ec94-4d8a-9248-d2679edd55b9}"), + + isMutable: true, + makeImmutable() { + this.isMutable = false; + }, + clone() { + return new CalDateTime(this.innerObject.clone()); + }, + + isValid: true, + innerObject: null, + + get nativeTime() { + return this.innerObject.toUnixTime() * UNIX_TIME_TO_PRTIME; + }, + set nativeTime(val) { + this.innerObject.fromUnixTime(val / UNIX_TIME_TO_PRTIME); + }, + + get year() { + return this.innerObject.year; + }, + set year(val) { + this.innerObject.year = parseInt(val, 10); + }, + + get month() { + return this.innerObject.month - 1; + }, + set month(val) { + this.innerObject.month = val + 1; + }, + + get day() { + return this.innerObject.day; + }, + set day(val) { + this.innerObject.day = parseInt(val, 10); + }, + + get hour() { + return this.innerObject.hour; + }, + set hour(val) { + this.innerObject.hour = parseInt(val, 10); + }, + + get minute() { + return this.innerObject.minute; + }, + set minute(val) { + this.innerObject.minute = parseInt(val, 10); + }, + + get second() { + return this.innerObject.second; + }, + set second(val) { + this.innerObject.second = parseInt(val, 10); + }, + + get timezone() { + return new lazy.CalTimezone(this.innerObject.zone); + }, + set timezone(rawval) { + unwrapSetter( + ICAL.Timezone, + rawval, + function (val) { + this.innerObject.zone = val; + return val; + }, + this + ); + }, + + resetTo(year, month, day, hour, minute, second, timezone) { + this.innerObject.fromData({ + year, + month: month + 1, + day, + hour, + minute, + second, + }); + this.timezone = timezone; + }, + + reset() { + this.innerObject.reset(); + }, + + get timezoneOffset() { + return this.innerObject.utcOffset(); + }, + get isDate() { + return this.innerObject.isDate; + }, + set isDate(val) { + this.innerObject.isDate = !!val; + }, + + get weekday() { + return this.innerObject.dayOfWeek() - 1; + }, + get yearday() { + return this.innerObject.dayOfYear(); + }, + + toString() { + return this.innerObject.toString(); + }, + + toJSON() { + return this.toString(); + }, + + getInTimezone: unwrap(ICAL.Timezone, function (val) { + return new CalDateTime(this.innerObject.convertToZone(val)); + }), + + addDuration: unwrap(ICAL.Duration, function (val) { + this.innerObject.addDuration(val); + }), + + subtractDate: unwrap(ICAL.Time, function (val) { + return new lazy.CalDuration(this.innerObject.subtractDateTz(val)); + }), + + compare: unwrap(ICAL.Time, function (val) { + let a = this.innerObject; + let b = val; + + // If either this or aOther is floating, both objects are treated + // as floating for the comparison. + if (a.zone == ICAL.Timezone.localTimezone || b.zone == ICAL.Timezone.localTimezone) { + a = a.convertToZone(ICAL.Timezone.localTimezone); + b = b.convertToZone(ICAL.Timezone.localTimezone); + } + + if (a.isDate || b.isDate) { + // Calendar expects 20120101 and 20120101T010101 to be equal + return a.compareDateOnlyTz(b, a.zone); + } + // If both are dates or date-times, then just do the normal compare + return a.compare(b); + }), + + get startOfWeek() { + return new CalDateTime(this.innerObject.startOfWeek()); + }, + get endOfWeek() { + return new CalDateTime(this.innerObject.endOfWeek()); + }, + get startOfMonth() { + return new CalDateTime(this.innerObject.startOfMonth()); + }, + get endOfMonth() { + return new CalDateTime(this.innerObject.endOfMonth()); + }, + get startOfYear() { + return new CalDateTime(this.innerObject.startOfYear()); + }, + get endOfYear() { + return new CalDateTime(this.innerObject.endOfYear()); + }, + + get icalString() { + return this.innerObject.toICALString(); + }, + set icalString(val) { + let jcalString; + if (val.length > 10) { + jcalString = ICAL.design.icalendar.value["date-time"].fromICAL(val); + } else { + jcalString = ICAL.design.icalendar.value.date.fromICAL(val); + } + this.innerObject = ICAL.Time.fromString(jcalString); + }, +}; diff --git a/comm/calendar/base/src/CalDefaultACLManager.jsm b/comm/calendar/base/src/CalDefaultACLManager.jsm new file mode 100644 index 0000000000..ca660f8e67 --- /dev/null +++ b/comm/calendar/base/src/CalDefaultACLManager.jsm @@ -0,0 +1,97 @@ +/* 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 = ["CalDefaultACLManager"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +function CalDefaultACLManager() { + this.mCalendarEntries = {}; +} + +CalDefaultACLManager.prototype = { + QueryInterface: ChromeUtils.generateQI(["calICalendarACLManager"]), + classID: Components.ID("{7463258c-6ef3-40a2-89a9-bb349596e927}"), + + mCalendarEntries: null, + + /* calICalendarACLManager */ + _getCalendarEntryCached(aCalendar) { + let calUri = aCalendar.uri.spec; + if (!(calUri in this.mCalendarEntries)) { + this.mCalendarEntries[calUri] = new calDefaultCalendarACLEntry(this, aCalendar); + } + + return this.mCalendarEntries[calUri]; + }, + getCalendarEntry(aCalendar, aListener) { + let entry = this._getCalendarEntryCached(aCalendar); + aListener.onOperationComplete(aCalendar, Cr.NS_OK, Ci.calIOperationListener.GET, null, entry); + }, + getItemEntry(aItem) { + let calEntry = this._getCalendarEntryCached(aItem.calendar); + return new calDefaultItemACLEntry(calEntry); + }, +}; + +function calDefaultCalendarACLEntry(aMgr, aCalendar) { + this.mACLManager = aMgr; + this.mCalendar = aCalendar; +} + +calDefaultCalendarACLEntry.prototype = { + QueryInterface: ChromeUtils.generateQI(["calICalendarACLEntry"]), + + mACLManager: null, + + /* calICalendarACLCalendarEntry */ + get aclManager() { + return this.mACLManager; + }, + + hasAccessControl: false, + userIsOwner: true, + userCanAddItems: true, + userCanDeleteItems: true, + + _getIdentities() { + let identities = []; + cal.email.iterateIdentities(id => identities.push(id)); + return identities; + }, + + getUserAddresses() { + let identities = this.getUserIdentities(); + let addresses = identities.map(id => id.email); + return addresses; + }, + + getUserIdentities() { + let identity = cal.provider.getEmailIdentityOfCalendar(this.mCalendar); + if (identity) { + return [identity]; + } + return this._getIdentities(); + }, + getOwnerIdentities() { + return this._getIdentities(); + }, + + refresh() {}, +}; + +function calDefaultItemACLEntry(aCalendarEntry) { + this.calendarEntry = aCalendarEntry; +} + +calDefaultItemACLEntry.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIItemACLEntry"]), + + /* calIItemACLEntry */ + calendarEntry: null, + userCanModify: true, + userCanRespond: true, + userCanViewAll: true, + userCanViewDateAndTime: true, +}; diff --git a/comm/calendar/base/src/CalDeletedItems.jsm b/comm/calendar/base/src/CalDeletedItems.jsm new file mode 100644 index 0000000000..2db386b7c5 --- /dev/null +++ b/comm/calendar/base/src/CalDeletedItems.jsm @@ -0,0 +1,200 @@ +/* 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 = ["CalDeletedItems"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { FileUtils } = ChromeUtils.importESModule("resource://gre/modules/FileUtils.sys.mjs"); + +/** + * Handles remembering deleted items. + * + * This is (currently) not a real trashcan. Only ids and time deleted is stored. + * Note also that the code doesn't strictly check the calendar of the item, + * except when a calendar id is passed to getDeletedDate. + */ +function CalDeletedItems() { + this.wrappedJSObject = this; + + this.completedNotifier = { + handleResult() {}, + handleError() {}, + handleCompletion() {}, + }; +} + +var calDeletedItemsClassID = Components.ID("{8e6799af-e7e9-4e6c-9a82-a2413e86d8c3}"); +var calDeletedItemsInterfaces = [Ci.calIDeletedItems, Ci.nsIObserver, Ci.calIObserver]; +CalDeletedItems.prototype = { + classID: calDeletedItemsClassID, + QueryInterface: cal.generateQI(["calIDeletedItems", "nsIObserver", "calIObserver"]), + classInfo: cal.generateCI({ + classID: calDeletedItemsClassID, + contractID: "@mozilla.org/calendar/deleted-items-manager;1", + classDescription: "Database containing information about deleted items", + interfaces: calDeletedItemsInterfaces, + flags: Ci.nsIClassInfo.SINGLETON, + }), + + DB_SCHEMA_VERSION: 1, + STALE_TIME: (30 * 24 * 60 * 60) / 1000 /* 30 days */, + + // To make the tests more failsafe, we have an internal notifier function. + // As the deleted items store is just meant to be a hint, this should not + // be used in real code. + completedNotifier: null, + + flush() { + this.ensureStatements(); + this.stmtFlush.params.stale_time = cal.dtz.now().nativeTime - this.STALE_TIME; + this.stmtFlush.executeAsync(this.completedNotifier); + }, + + getDeletedDate(aId, aCalId) { + this.ensureStatements(); + let stmt; + if (aCalId) { + stmt = this.stmtGetWithCal; + stmt.params.calId = aCalId; + } else { + stmt = this.stmtGet; + } + + stmt.params.id = aId; + try { + if (stmt.executeStep()) { + let date = cal.createDateTime(); + date.nativeTime = stmt.row.time_deleted; + return date.getInTimezone(cal.dtz.defaultTimezone); + } + } catch (e) { + cal.ERROR(e); + } finally { + stmt.reset(); + } + return null; + }, + + markDeleted(aItem) { + this.ensureStatements(); + this.stmtMarkDelete.params.calId = aItem.calendar.id; + this.stmtMarkDelete.params.id = aItem.id; + this.stmtMarkDelete.params.time = cal.dtz.now().nativeTime; + this.stmtMarkDelete.params.rid = (aItem.recurrenceId && aItem.recurrenceId.nativeTime) || ""; + this.stmtMarkDelete.executeAsync(this.completedNotifier); + }, + + unmarkDeleted(aItem) { + this.ensureStatements(); + this.stmtUnmarkDelete.params.id = aItem.id; + this.stmtUnmarkDelete.executeAsync(this.completedNotifier); + }, + + initDB() { + if (this.mDB) { + // Looks like we've already initialized, exit early + return; + } + + let file = FileUtils.getFile("ProfD", ["calendar-data", "deleted.sqlite"]); + this.mDB = Services.storage.openDatabase(file); + + // If this database needs changing, please start using a real schema + // management, i.e using PRAGMA user_version and upgrading + if (!this.mDB.tableExists("cal_deleted_items")) { + const v1_schema = "cal_id TEXT, id TEXT, time_deleted INTEGER, recurrence_id INTEGER"; + const v1_index = + "CREATE INDEX idx_deleteditems ON cal_deleted_items(id,cal_id,recurrence_id)"; + + this.mDB.createTable("cal_deleted_items", v1_schema); + this.mDB.executeSimpleSQL(v1_index); + this.mDB.executeSimpleSQL("PRAGMA user_version = 1"); + } + + // We will not init the statements now, we can still do that the + // first time this interface is used. What we should do though is + // to clean up at shutdown + cal.addShutdownObserver(this.shutdown.bind(this)); + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "profile-after-change") { + // Make sure to observe calendar changes so we know when things are + // deleted. We don't initialize the statements until first use. + cal.manager.addCalendarObserver(this); + } + }, + + ensureStatements() { + if (!this.mDB) { + this.initDB(); + } + + if (!this.stmtMarkDelete) { + let stmt = + "INSERT OR REPLACE INTO cal_deleted_items (cal_id, id, time_deleted, recurrence_id) VALUES(:calId, :id, :time, :rid)"; + this.stmtMarkDelete = this.mDB.createStatement(stmt); + } + if (!this.stmtUnmarkDelete) { + let stmt = "DELETE FROM cal_deleted_items WHERE id = :id"; + this.stmtUnmarkDelete = this.mDB.createStatement(stmt); + } + if (!this.stmtGetWithCal) { + let stmt = "SELECT time_deleted FROM cal_deleted_items WHERE cal_id = :calId AND id = :id"; + this.stmtGetWithCal = this.mDB.createStatement(stmt); + } + if (!this.stmtGet) { + let stmt = "SELECT time_deleted FROM cal_deleted_items WHERE id = :id"; + this.stmtGet = this.mDB.createStatement(stmt); + } + if (!this.stmtFlush) { + let stmt = "DELETE FROM cal_deleted_items WHERE time_deleted < :stale_time"; + this.stmtFlush = this.mDB.createStatement(stmt); + } + }, + + shutdown() { + try { + let stmts = [ + this.stmtMarkDelete, + this.stmtUnmarkDelete, + this.stmtGet, + this.stmtGetWithCal, + this.stmtFlush, + ]; + for (let stmt of stmts) { + stmt.finalize(); + } + + if (this.mDB) { + this.mDB.asyncClose(); + this.mDB = null; + } + } catch (e) { + cal.ERROR("Error closing deleted items database: " + e); + } + + cal.manager.removeCalendarObserver(this); + }, + + // calIObserver + onStartBatch() {}, + onEndBatch() {}, + onModifyItem() {}, + onError() {}, + onPropertyChanged() {}, + onPropertyDeleting() {}, + + onAddItem(aItem) { + this.unmarkDeleted(aItem); + }, + + onDeleteItem(aItem) { + this.markDeleted(aItem); + }, + + onLoad() { + this.flush(); + }, +}; diff --git a/comm/calendar/base/src/CalDuration.jsm b/comm/calendar/base/src/CalDuration.jsm new file mode 100644 index 0000000000..cb289bdde4 --- /dev/null +++ b/comm/calendar/base/src/CalDuration.jsm @@ -0,0 +1,106 @@ +/* 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 = ["CalDuration"]; + +const { ICAL, unwrap } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm"); + +function CalDuration(innerObject) { + this.innerObject = innerObject || new ICAL.Duration(); + this.wrappedJSObject = this; +} + +CalDuration.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIDuration"]), + classID: Components.ID("{7436f480-c6fc-4085-9655-330b1ee22288}"), + + get icalDuration() { + return this.innerObject; + }, + set icalDuration(val) { + this.innerObject = val; + }, + + isMutable: true, + makeImmutable() { + this.isMutable = false; + }, + clone() { + return new CalDuration(this.innerObject.clone()); + }, + + get isNegative() { + return this.innerObject.isNegative; + }, + set isNegative(val) { + this.innerObject.isNegative = !!val; + }, + + get weeks() { + return this.innerObject.weeks; + }, + set weeks(val) { + this.innerObject.weeks = parseInt(val, 10); + }, + + get days() { + return this.innerObject.days; + }, + set days(val) { + this.innerObject.days = parseInt(val, 10); + }, + + get hours() { + return this.innerObject.hours; + }, + set hours(val) { + this.innerObject.hours = parseInt(val, 10); + }, + + get minutes() { + return this.innerObject.minutes; + }, + set minutes(val) { + this.innerObject.minutes = parseInt(val, 10); + }, + + get seconds() { + return this.innerObject.seconds; + }, + set seconds(val) { + this.innerObject.seconds = parseInt(val, 10); + }, + + get inSeconds() { + return this.innerObject.toSeconds(); + }, + set inSeconds(val) { + this.innerObject.fromSeconds(val); + }, + + addDuration: unwrap(ICAL.Duration, function (val) { + this.innerObject.fromSeconds(this.innerObject.toSeconds() + val.toSeconds()); + }), + + compare: unwrap(ICAL.Duration, function (val) { + return this.innerObject.compare(val); + }), + + reset() { + this.innerObject.reset(); + }, + normalize() { + this.innerObject.normalize(); + }, + toString() { + return this.innerObject.toString(); + }, + + get icalString() { + return this.innerObject.toString(); + }, + set icalString(val) { + this.innerObject = ICAL.Duration.fromString(val); + }, +}; diff --git a/comm/calendar/base/src/CalEvent.jsm b/comm/calendar/base/src/CalEvent.jsm new file mode 100644 index 0000000000..fa5225e520 --- /dev/null +++ b/comm/calendar/base/src/CalEvent.jsm @@ -0,0 +1,225 @@ +/* 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/. */ + +/* import-globals-from calItemBase.js */ + +var EXPORTED_SYMBOLS = ["CalEvent"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +Services.scriptloader.loadSubScript("resource:///components/calItemBase.js"); + +/** + * Constructor for `calIEvent` objects. + * + * @class + * @implements {calIEvent} + * @param {string} [icalString] - Optional iCal string for initializing existing events. + */ +function CalEvent(icalString) { + this.initItemBase(); + + this.eventPromotedProps = { + DTSTART: true, + DTEND: true, + __proto__: this.itemBasePromotedProps, + }; + + if (icalString) { + this.icalString = icalString; + } +} +var calEventClassID = Components.ID("{974339d5-ab86-4491-aaaf-2b2ca177c12b}"); +var calEventInterfaces = [Ci.calIItemBase, Ci.calIEvent, Ci.calIInternalShallowCopy]; +CalEvent.prototype = { + __proto__: calItemBase.prototype, + + classID: calEventClassID, + QueryInterface: cal.generateQI(["calIItemBase", "calIEvent", "calIInternalShallowCopy"]), + classInfo: cal.generateCI({ + classID: calEventClassID, + contractID: "@mozilla.org/calendar/event;1", + classDescription: "Calendar Event", + interfaces: calEventInterfaces, + }), + + cloneShallow(aNewParent) { + let cloned = new CalEvent(); + this.cloneItemBaseInto(cloned, aNewParent); + return cloned; + }, + + createProxy(aRecurrenceId) { + cal.ASSERT(!this.mIsProxy, "Tried to create a proxy for an existing proxy!", true); + + let proxy = new CalEvent(); + + // override proxy's DTSTART/DTEND/RECURRENCE-ID + // before master is set (and item might get immutable): + let endDate = aRecurrenceId.clone(); + endDate.addDuration(this.duration); + proxy.endDate = endDate; + proxy.startDate = aRecurrenceId; + + proxy.initializeProxy(this, aRecurrenceId); + proxy.mDirty = false; + + return proxy; + }, + + makeImmutable() { + this.makeItemBaseImmutable(); + }, + + isEvent() { + return true; + }, + + get duration() { + if (this.endDate && this.startDate) { + return this.endDate.subtractDate(this.startDate); + } + // Return a null-duration if we don't have an end date + return cal.createDuration(); + }, + + get recurrenceStartDate() { + return this.startDate; + }, + + icsEventPropMap: [ + { cal: "DTSTART", ics: "startTime" }, + { cal: "DTEND", ics: "endTime" }, + ], + + set icalString(value) { + this.icalComponent = cal.icsService.parseICS(value); + }, + + get icalString() { + let calcomp = cal.icsService.createIcalComponent("VCALENDAR"); + cal.item.setStaticProps(calcomp); + calcomp.addSubcomponent(this.icalComponent); + return calcomp.serializeToICS(); + }, + + get icalComponent() { + let icalcomp = cal.icsService.createIcalComponent("VEVENT"); + this.fillIcalComponentFromBase(icalcomp); + this.mapPropsToICS(icalcomp, this.icsEventPropMap); + + for (let [name, value] of this.properties) { + try { + // When deleting a property of an occurrence, the property is not deleted + // but instead set to null, so we need to prevent adding those properties. + let wasReset = this.mIsProxy && value === null; + if (!this.eventPromotedProps[name] && !wasReset) { + let icalprop = cal.icsService.createIcalProperty(name); + icalprop.value = value; + let propBucket = this.mPropertyParams[name]; + if (propBucket) { + for (let paramName in propBucket) { + try { + icalprop.setParameter(paramName, propBucket[paramName]); + } catch (e) { + if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG( + "Warning: Invalid event parameter value " + + paramName + + "=" + + propBucket[paramName] + ); + } else { + throw e; + } + } + } + } + icalcomp.addProperty(icalprop); + } + } catch (e) { + cal.ERROR("failed to set " + name + " to " + value + ": " + e + "\n"); + } + } + return icalcomp; + }, + + eventPromotedProps: null, + + set icalComponent(event) { + this.modify(); + if (event.componentType != "VEVENT") { + event = event.getFirstSubcomponent("VEVENT"); + if (!event) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + } + + this.mEndDate = undefined; + this.setItemBaseFromICS(event); + this.mapPropsFromICS(event, this.icsEventPropMap); + + this.importUnpromotedProperties(event, this.eventPromotedProps); + + // Importing didn't really change anything + this.mDirty = false; + }, + + isPropertyPromoted(name) { + // avoid strict undefined property warning + return this.eventPromotedProps[name] || false; + }, + + set startDate(value) { + this.modify(); + + // We're about to change the start date of an item which probably + // could break the associated calIRecurrenceInfo. We're calling + // the appropriate method here to adjust the internal structure in + // order to free clients from worrying about such details. + if (this.parentItem == this) { + let rec = this.recurrenceInfo; + if (rec) { + rec.onStartDateChange(value, this.startDate); + } + } + + this.setProperty("DTSTART", value); + }, + + get startDate() { + return this.getProperty("DTSTART"); + }, + + mEndDate: undefined, + get endDate() { + let endDate = this.mEndDate; + if (endDate === undefined) { + endDate = this.getProperty("DTEND"); + if (!endDate && this.startDate) { + endDate = this.startDate.clone(); + let dur = this.getProperty("DURATION"); + if (dur) { + // If there is a duration set on the event, calculate the right end time. + endDate.addDuration(cal.createDuration(dur)); + } else if (endDate.isDate) { + // If the start time is a date-time the event ends on the same calendar + // date and time of day. If the start time is a date the events + // non-inclusive end is the end of the calendar date. + endDate.day += 1; + } + } + this.mEndDate = endDate; + } + return endDate; + }, + + set endDate(value) { + this.deleteProperty("DURATION"); // setting endDate once removes DURATION + this.setProperty("DTEND", value); + this.mEndDate = value; + }, +}; diff --git a/comm/calendar/base/src/CalFreeBusyService.jsm b/comm/calendar/base/src/CalFreeBusyService.jsm new file mode 100644 index 0000000000..822f37acda --- /dev/null +++ b/comm/calendar/base/src/CalFreeBusyService.jsm @@ -0,0 +1,89 @@ +/* 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 = ["CalFreeBusyService"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +function CalFreeBusyListener(numOperations, finalListener) { + this.mFinalListener = finalListener; + this.mNumOperations = numOperations; + + this.opGroup = new cal.data.OperationGroup(() => { + this.notifyResult(null); + }); +} +CalFreeBusyListener.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIGenericOperationListener"]), + + mFinalListener: null, + mNumOperations: 0, + opGroup: null, + + notifyResult(result) { + let listener = this.mFinalListener; + if (listener) { + if (!this.opGroup.isPending) { + this.mFinalListener = null; + } + listener.onResult(this.opGroup, result); + } + }, + + // calIGenericOperationListener: + onResult(aOperation, aResult) { + if (this.mFinalListener) { + if (!aOperation || !aOperation.isPending) { + --this.mNumOperations; + if (this.mNumOperations <= 0) { + this.opGroup.notifyCompleted(); + } + } + let opStatus = aOperation ? aOperation.status : Cr.NS_OK; + if (Components.isSuccessCode(opStatus) && aResult && Array.isArray(aResult)) { + this.notifyResult(aResult); + } else { + this.notifyResult([]); + } + } + }, +}; + +function CalFreeBusyService() { + this.wrappedJSObject = this; + this.mProviders = new Set(); +} +CalFreeBusyService.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIFreeBusyProvider", "calIFreeBusyService"]), + classID: Components.ID("{29c56cd5-d36e-453a-acde-0083bd4fe6d3}"), + + mProviders: null, + + // calIFreeBusyProvider: + getFreeBusyIntervals(aCalId, aRangeStart, aRangeEnd, aBusyTypes, aListener) { + let groupListener = new CalFreeBusyListener(this.mProviders.size, aListener); + if (this.mProviders.size == 0) { + groupListener.onResult(null, []); + } + for (let provider of this.mProviders.values()) { + let operation = provider.getFreeBusyIntervals( + aCalId, + aRangeStart, + aRangeEnd, + aBusyTypes, + groupListener + ); + groupListener.opGroup.add(operation); + } + return groupListener.opGroup; + }, + + // calIFreeBusyService: + addProvider(aProvider) { + this.mProviders.add(aProvider.QueryInterface(Ci.calIFreeBusyProvider)); + }, + removeProvider(aProvider) { + this.mProviders.delete(aProvider.QueryInterface(Ci.calIFreeBusyProvider)); + }, +}; diff --git a/comm/calendar/base/src/CalICSService.jsm b/comm/calendar/base/src/CalICSService.jsm new file mode 100644 index 0000000000..fc9d53500e --- /dev/null +++ b/comm/calendar/base/src/CalICSService.jsm @@ -0,0 +1,604 @@ +/* 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 = ["CalIcalProperty", "CalICSService"]; + +const { ICAL, unwrapSetter, unwrapSingle, wrapGetter } = ChromeUtils.import( + "resource:///modules/calendar/Ical.jsm" +); +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const lazy = {}; +ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm"); +ChromeUtils.defineModuleGetter(lazy, "CalDuration", "resource:///modules/CalDuration.jsm"); +ChromeUtils.defineModuleGetter(lazy, "CalTimezone", "resource:///modules/CalTimezone.jsm"); + +function CalIcalProperty(innerObject) { + this.innerObject = innerObject || new ICAL.Property(); + this.wrappedJSObject = this; +} + +CalIcalProperty.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIIcalProperty"]), + classID: Components.ID("{423ac3f0-f612-48b3-953f-47f7f8fd705b}"), + + get icalString() { + return this.innerObject.toICALString() + ICAL.newLineChar; + }, + get icalProperty() { + return this.innerObject; + }, + set icalProperty(val) { + this.innerObject = val; + }, + + get parent() { + return this.innerObject.parent; + }, + toString() { + return this.innerObject.toICAL(); + }, + + get value() { + // Unescaped value for properties of TEXT, escaped otherwise. + if (this.innerObject.type == "text") { + return this.innerObject.getValues().join(","); + } + return this.valueAsIcalString; + }, + set value(val) { + // Unescaped value for properties of TEXT, escaped otherwise. + if (this.innerObject.type == "text") { + this.innerObject.setValue(val); + return; + } + this.valueAsIcalString = val; + }, + + get valueAsIcalString() { + let propertyStr = this.innerObject.toICALString(); + if (propertyStr.match(/:/g).length == 1) { + // For property containing only one colon, e.g. `GEO:latitude;longitude`, + // the left hand side must be the property name, the right hand side must + // be property value. + return propertyStr.slice(propertyStr.indexOf(":") + 1); + } + // For property containing many or no colons, retrieve the property value + // according to its type. An example is + // `ATTENDEE;MEMBER="mailto:foo@example.com": mailto:bar@example.com` + let type = this.innerObject.type; + return this.innerObject + .getValues() + .map(val => { + if (type == "text") { + return ICAL.stringify.value(val, type, ICAL.design.icalendar); + } else if (typeof val == "number" || typeof val == "string") { + return val; + } else if ("toICALString" in val) { + return val.toICALString(); + } + return val.toString(); + }) + .join(","); + }, + set valueAsIcalString(val) { + let mockLine = this.propertyName + ":" + val; + let prop = ICAL.Property.fromString(mockLine, ICAL.design.icalendar); + + if (this.innerObject.isMultiValue) { + this.innerObject.setValues(prop.getValues()); + } else { + this.innerObject.setValue(prop.getFirstValue()); + } + }, + + get valueAsDatetime() { + let val = this.innerObject.getFirstValue(); + let isIcalTime = + val && typeof val == "object" && "icalclass" in val && val.icalclass == "icaltime"; + return isIcalTime ? new lazy.CalDateTime(val) : null; + }, + set valueAsDatetime(rawval) { + unwrapSetter( + ICAL.Time, + rawval, + function (val) { + if ( + val && + val.zone && + val.zone != ICAL.Timezone.utcTimezone && + val.zone != ICAL.Timezone.localTimezone + ) { + this.innerObject.setParameter("TZID", val.zone.tzid); + if (this.parent) { + let tzref = wrapGetter(lazy.CalTimezone, val.zone); + this.parent.addTimezoneReference(tzref); + } + } else { + this.innerObject.removeParameter("TZID"); + } + this.innerObject.setValue(val); + }, + this + ); + }, + + get propertyName() { + return this.innerObject.name.toUpperCase(); + }, + + getParameter(name) { + // Unfortunately getting the "VALUE" parameter won't work, since in + // jCal it has been translated to the value type id. + if (name == "VALUE") { + let defaultType = this.innerObject.getDefaultType(); + if (this.innerObject.type != defaultType) { + // Default type doesn't match object type, so we have a VALUE + // parameter + return this.innerObject.type.toUpperCase(); + } + } + + return this.innerObject.getParameter(name.toLowerCase()); + }, + setParameter(name, value) { + // Similar problems for setting the value parameter. Calendar code + // expects setting the value parameter to just change the value type + // and attempt to use the previous value as the new one. To do this in + // ICAL.js we need to save the value, reset the type and then try to + // set the value again. + if (name == "VALUE") { + let oldValues; + let type = this.innerObject.type; + let designSet = this.innerObject._designSet; + + let wasMultiValue = this.innerObject.isMultiValue; + if (wasMultiValue) { + oldValues = this.innerObject.getValues(); + } else { + let oldValue = this.innerObject.getFirstValue(); + oldValues = oldValue ? [oldValue] : []; + } + + this.innerObject.resetType(value.toLowerCase()); + try { + oldValues = oldValues.map(oldValue => { + let strvalue = ICAL.stringify.value(oldValue.toString(), type, designSet); + return ICAL.parse._parseValue(strvalue, value, designSet); + }); + } catch (e) { + // If there was an error reparsing the value, then just keep it + // empty. + oldValues = null; + } + + if (oldValues && oldValues.length) { + if (wasMultiValue && this.innerObject.isMultiValue) { + this.innerObject.setValues(oldValues); + } else { + this.innerObject.setValue(oldValues.join(",")); + } + } + } else { + this.innerObject.setParameter(name.toLowerCase(), value); + } + }, + removeParameter(name) { + // Again, VALUE needs special handling. Removing the value parameter is + // kind of like resetting it to the default type. So find out the + // default type and then set the value parameter to it. + if (name == "VALUE") { + let propname = this.innerObject.name.toLowerCase(); + if (propname in ICAL.design.icalendar.property) { + let details = ICAL.design.icalendar.property[propname]; + if ("defaultType" in details) { + this.setParameter("VALUE", details.defaultType); + } + } + } else { + this.innerObject.removeParameter(name.toLowerCase()); + } + }, + + clearXParameters() { + cal.WARN( + "calIICSService::clearXParameters is no longer implemented, please use removeParameter" + ); + }, + + paramIterator: null, + getFirstParameterName() { + let innerObject = this.innerObject; + this.paramIterator = (function* () { + let defaultType = innerObject.getDefaultType(); + if (defaultType != innerObject.type) { + yield "VALUE"; + } + + let paramNames = Object.keys(innerObject.jCal[1] || {}); + for (let name of paramNames) { + yield name.toUpperCase(); + } + })(); + return this.getNextParameterName(); + }, + + getNextParameterName() { + if (this.paramIterator) { + let next = this.paramIterator.next(); + if (next.done) { + this.paramIterator = null; + } + + return next.value; + } + return this.getFirstParameterName(); + }, +}; + +function calIcalComponent(innerObject) { + this.innerObject = innerObject || new ICAL.Component(); + this.wrappedJSObject = this; + this.mReferencedZones = {}; +} + +calIcalComponent.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIIcalComponent"]), + classID: Components.ID("{51ac96fd-1279-4439-a85b-6947b37f4cea}"), + + clone() { + return new calIcalComponent(new ICAL.Component(this.innerObject.toJSON())); + }, + + get parent() { + return wrapGetter(calIcalComponent, this.innerObject.parent); + }, + + get icalTimezone() { + return this.innerObject.name == "vtimezone" ? this.innerObject : null; + }, + get icalComponent() { + return this.innerObject; + }, + set icalComponent(val) { + this.innerObject = val; + }, + + componentIterator: null, + getFirstSubcomponent(kind) { + if (kind == "ANY") { + kind = null; + } else if (kind) { + kind = kind.toLowerCase(); + } + let innerObject = this.innerObject; + this.componentIterator = (function* () { + let comps = innerObject.getAllSubcomponents(kind); + if (comps) { + for (let comp of comps) { + yield new calIcalComponent(comp); + } + } + })(); + return this.getNextSubcomponent(kind); + }, + getNextSubcomponent(kind) { + if (this.componentIterator) { + let next = this.componentIterator.next(); + if (next.done) { + this.componentIterator = null; + } + + return next.value; + } + return this.getFirstSubcomponent(kind); + }, + + get componentType() { + return this.innerObject.name.toUpperCase(); + }, + + get uid() { + return this.innerObject.getFirstPropertyValue("uid"); + }, + set uid(val) { + this.innerObject.updatePropertyWithValue("uid", val); + }, + + get prodid() { + return this.innerObject.getFirstPropertyValue("prodid"); + }, + set prodid(val) { + this.innerObject.updatePropertyWithValue("prodid", val); + }, + + get version() { + return this.innerObject.getFirstPropertyValue("version"); + }, + set version(val) { + this.innerObject.updatePropertyWithValue("version", val); + }, + + get method() { + return this.innerObject.getFirstPropertyValue("method"); + }, + set method(val) { + this.innerObject.updatePropertyWithValue("method", val); + }, + + get status() { + return this.innerObject.getFirstPropertyValue("status"); + }, + set status(val) { + this.innerObject.updatePropertyWithValue("status", val); + }, + + get summary() { + return this.innerObject.getFirstPropertyValue("summary"); + }, + set summary(val) { + this.innerObject.updatePropertyWithValue("summary", val); + }, + + get description() { + return this.innerObject.getFirstPropertyValue("description"); + }, + set description(val) { + this.innerObject.updatePropertyWithValue("description", val); + }, + + get location() { + return this.innerObject.getFirstPropertyValue("location"); + }, + set location(val) { + this.innerObject.updatePropertyWithValue("location", val); + }, + + get categories() { + return this.innerObject.getFirstPropertyValue("categories"); + }, + set categories(val) { + this.innerObject.updatePropertyWithValue("categories", val); + }, + + get URL() { + return this.innerObject.getFirstPropertyValue("url"); + }, + set URL(val) { + this.innerObject.updatePropertyWithValue("url", val); + }, + + get priority() { + // If there is no value for this integer property, then we must return + // the designated INVALID_VALUE. + const INVALID_VALUE = Ci.calIIcalComponent.INVALID_VALUE; + let prop = this.innerObject.getFirstProperty("priority"); + let val = prop ? prop.getFirstValue() : null; + return val === null ? INVALID_VALUE : val; + }, + set priority(val) { + this.innerObject.updatePropertyWithValue("priority", val); + }, + + _setTimeAttr(propName, val) { + let prop = this.innerObject.updatePropertyWithValue(propName, val); + if ( + val && + val.zone && + val.zone != ICAL.Timezone.utcTimezone && + val.zone != ICAL.Timezone.localTimezone + ) { + prop.setParameter("TZID", val.zone.tzid); + this.addTimezoneReference(wrapGetter(lazy.CalTimezone, val.zone)); + } else { + prop.removeParameter("TZID"); + } + }, + + get startTime() { + return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("dtstart")); + }, + set startTime(val) { + unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "dtstart"), this); + }, + + get endTime() { + return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("dtend")); + }, + set endTime(val) { + unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "dtend"), this); + }, + + get duration() { + return wrapGetter(lazy.CalDuration, this.innerObject.getFirstPropertyValue("duration")); + }, + + get dueTime() { + return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("due")); + }, + set dueTime(val) { + unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "due"), this); + }, + + get stampTime() { + return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("dtstamp")); + }, + set stampTime(val) { + unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "dtstamp"), this); + }, + + get createdTime() { + return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("created")); + }, + set createdTime(val) { + unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "created"), this); + }, + + get completedTime() { + return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("completed")); + }, + set completedTime(val) { + unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "completed"), this); + }, + + get lastModified() { + return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("last-modified")); + }, + set lastModified(val) { + unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "last-modified"), this); + }, + + get recurrenceId() { + return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("recurrence-id")); + }, + set recurrenceId(val) { + unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "recurrence-id"), this); + }, + + serializeToICS() { + return this.innerObject.toString() + ICAL.newLineChar; + }, + toString() { + return this.innerObject.toString(); + }, + + addSubcomponent(comp) { + comp.getReferencedTimezones().forEach(this.addTimezoneReference, this); + let jscomp = unwrapSingle(ICAL.Component, comp); + this.innerObject.addSubcomponent(jscomp); + }, + + propertyIterator: null, + getFirstProperty(kind) { + if (kind == "ANY") { + kind = null; + } else if (kind) { + kind = kind.toLowerCase(); + } + let innerObject = this.innerObject; + this.propertyIterator = (function* () { + let props = innerObject.getAllProperties(kind); + if (!props) { + return; + } + for (let prop of props) { + let hell = prop.getValues(); + if (hell.length > 1) { + // Uh oh, multiple property values. Our code expects each as one + // property. I hate API incompatibility! + for (let devil of hell) { + let thisprop = new ICAL.Property(prop.toJSON(), prop.parent); + thisprop.removeAllValues(); + thisprop.setValue(devil); + yield new CalIcalProperty(thisprop); + } + } else { + yield new CalIcalProperty(prop); + } + } + })(); + + return this.getNextProperty(kind); + }, + + getNextProperty(kind) { + if (this.propertyIterator) { + let next = this.propertyIterator.next(); + if (next.done) { + this.propertyIterator = null; + } + + return next.value; + } + return this.getFirstProperty(kind); + }, + + _getNextParentVCalendar() { + let vcalendar = this; // eslint-disable-line consistent-this + while (vcalendar && vcalendar.componentType != "VCALENDAR") { + vcalendar = vcalendar.parent; + } + return vcalendar || this; + }, + + addProperty(prop) { + try { + let datetime = prop.valueAsDatetime; + if (datetime && datetime.timezone) { + this._getNextParentVCalendar().addTimezoneReference(datetime.timezone); + } + } catch (e) { + // If there is an issue adding the timezone reference, don't make + // that break adding the property. + } + + let jsprop = unwrapSingle(ICAL.Property, prop); + this.innerObject.addProperty(jsprop); + }, + + addTimezoneReference(timezone) { + if (timezone) { + if (!(timezone.tzid in this.mReferencedZones) && this.componentType == "VCALENDAR") { + let comp = timezone.icalComponent; + if (comp) { + this.addSubcomponent(comp); + } + } + + this.mReferencedZones[timezone.tzid] = timezone; + } + }, + + getReferencedTimezones(aCount) { + return Object.keys(this.mReferencedZones).map(timezone => this.mReferencedZones[timezone]); + }, + + serializeToICSStream() { + let data = this.innerObject.toString(); + let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + stream.setUTF8Data(data, data.length); + return stream; + }, +}; + +function CalICSService() { + this.wrappedJSObject = this; +} + +CalICSService.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIICSService"]), + classID: Components.ID("{c61cb903-4408-41b3-bc22-da0b27efdfe1}"), + + parseICS(serialized) { + let comp = ICAL.parse(serialized); + return new calIcalComponent(new ICAL.Component(comp)); + }, + + parseICSAsync(serialized, listener) { + let worker = new ChromeWorker("resource:///components/calICSService-worker.js"); + worker.onmessage = function (event) { + let icalComp = new calIcalComponent(new ICAL.Component(event.data)); + listener.onParsingComplete(Cr.OK, icalComp); + }; + worker.onerror = function (event) { + cal.ERROR(`Parsing failed; ${event.message}. ICS data:\n${serialized}`); + listener.onParsingComplete(Cr.NS_ERROR_FAILURE, null); + }; + worker.postMessage(serialized); + }, + + createIcalComponent(kind) { + return new calIcalComponent(new ICAL.Component(kind.toLowerCase())); + }, + + createIcalProperty(kind) { + return new CalIcalProperty(new ICAL.Property(kind.toLowerCase())); + }, + + createIcalPropertyFromString(str) { + return new CalIcalProperty(ICAL.Property.fromString(str.trim(), ICAL.design.icalendar)); + }, +}; diff --git a/comm/calendar/base/src/CalIcsParser.jsm b/comm/calendar/base/src/CalIcsParser.jsm new file mode 100644 index 0000000000..67ea32ba24 --- /dev/null +++ b/comm/calendar/base/src/CalIcsParser.jsm @@ -0,0 +1,334 @@ +/* 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 = ["CalIcsParser"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + CalEvent: "resource:///modules/CalEvent.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +function CalIcsParser() { + this.wrappedJSObject = this; + this.mItems = []; + this.mParentlessItems = []; + this.mComponents = []; + this.mProperties = []; +} +CalIcsParser.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIIcsParser"]), + classID: Components.ID("{6fe88047-75b6-4874-80e8-5f5800f14984}"), + + processIcalComponent(rootComp, aAsyncParsing) { + let calComp; + // libical returns the vcalendar component if there is just one vcalendar. + // If there are multiple vcalendars, it returns an xroot component, with + // vcalendar children. We need to handle both cases. + if (rootComp) { + if (rootComp.componentType == "VCALENDAR") { + calComp = rootComp; + } else { + calComp = rootComp.getFirstSubcomponent("VCALENDAR"); + } + } + + if (!calComp) { + let message = "Parser Error. Could not find 'VCALENDAR' component.\n"; + try { + // we try to also provide the parsed component - if that fails due to an error in + // libical, we append the error message of the caught exception, which includes + // already a stack trace. + cal.ERROR(message + rootComp + "\n" + cal.STACK(10)); + } catch (e) { + cal.ERROR(message + e); + } + } + + let self = this; + let state = new parserState(this, aAsyncParsing); + + while (calComp) { + // Get unknown properties from the VCALENDAR + for (let prop of cal.iterate.icalProperty(calComp)) { + if (prop.propertyName != "VERSION" && prop.propertyName != "PRODID") { + this.mProperties.push(prop); + } + } + + let isGCal = /^-\/\/Google Inc\/\/Google Calendar /.test(calComp.prodid); + for (let subComp of cal.iterate.icalSubcomponent(calComp)) { + state.submit(subComp, isGCal); + } + calComp = rootComp.getNextSubcomponent("VCALENDAR"); + } + + // eslint-disable-next-line mozilla/use-returnValue + state.join(() => { + let fakedParents = {}; + // tag "exceptions", i.e. items with rid: + for (let item of state.excItems) { + let parent = state.uid2parent[item.id]; + + if (!parent) { + // a parentless one, fake a master and override it's occurrence + parent = item.isEvent() ? new lazy.CalEvent() : new lazy.CalTodo(); + parent.id = item.id; + parent.setProperty("DTSTART", item.recurrenceId); + parent.setProperty("X-MOZ-FAKED-MASTER", "1"); // this tag might be useful in the future + parent.recurrenceInfo = new lazy.CalRecurrenceInfo(parent); + fakedParents[item.id] = true; + state.uid2parent[item.id] = parent; + state.items.push(parent); + } + if (item.id in fakedParents) { + let rdate = cal.createRecurrenceDate(); + rdate.date = item.recurrenceId; + parent.recurrenceInfo.appendRecurrenceItem(rdate); + // we'll keep the parentless-API until we switch over using itip-process for import (e.g. in dnd code) + self.mParentlessItems.push(item); + } + + parent.recurrenceInfo.modifyException(item, true); + } + + if (Object.keys(state.tzErrors).length > 0) { + // Use an alert rather than a prompt because problems may appear in + // remote subscribed calendars the user cannot change. + if (Cc["@mozilla.org/alerts-service;1"]) { + let notifier = Cc["@mozilla.org/alerts-service;1"].getService(Ci.nsIAlertsService); + let title = cal.l10n.getCalString("TimezoneErrorsAlertTitle"); + let text = cal.l10n.getCalString("TimezoneErrorsSeeConsole"); + try { + let alert = Cc["@mozilla.org/alert-notification;1"].createInstance( + Ci.nsIAlertNotification + ); + alert.init(title, "", title, text); + notifier.showAlert(alert); + } catch (e) { + // The notifier may not be available, e.g. on xpcshell tests + } + } + } + + // We are done, push the items to the parser and notify the listener + self.mItems = self.mItems.concat(state.items); + self.mComponents = self.mComponents.concat(state.extraComponents); + + if (aAsyncParsing) { + aAsyncParsing.onParsingComplete(Cr.NS_OK, self); + } + }); + }, + + parseString(aICSString, aAsyncParsing) { + if (aAsyncParsing) { + let self = this; + + // We are using two types of very similar listeners here: + // aAsyncParsing is a calIcsParsingListener that returns the ics + // parser containing the processed items. + // The listener passed to parseICSAsync is a calICsComponentParsingListener + // required by the ics service, that receives the parsed root component. + cal.icsService.parseICSAsync(aICSString, { + onParsingComplete(rc, rootComp) { + if (Components.isSuccessCode(rc)) { + self.processIcalComponent(rootComp, aAsyncParsing); + } else { + cal.ERROR("Error Parsing ICS: " + rc); + aAsyncParsing.onParsingComplete(rc, self); + } + }, + }); + } else { + try { + let icalComp = cal.icsService.parseICS(aICSString); + this.processIcalComponent(icalComp); + } catch (exc) { + cal.ERROR(exc.message + " when parsing\n" + aICSString); + } + } + }, + + parseFromStream(aStream, aAsyncParsing) { + // Read in the string. Note that it isn't a real string at this point, + // because likely, the file is utf8. The multibyte chars show up as multiple + // 'chars' in this string. So call it an array of octets for now. + + let stringData = NetUtil.readInputStreamToString(aStream, aStream.available(), { + charset: "utf-8", + }); + this.parseString(stringData, aAsyncParsing); + }, + + getItems() { + return this.mItems.concat([]); + }, + + getParentlessItems() { + return this.mParentlessItems.concat([]); + }, + + getProperties() { + return this.mProperties.concat([]); + }, + + getComponents() { + return this.mComponents.concat([]); + }, +}; + +/** + * The parser state, which helps process ical components without clogging up the + * event queue. + * + * @param aParser The parser that is using this state + */ +function parserState(aParser, aListener) { + this.parser = aParser; + this.listener = aListener; + + this.extraComponents = []; + this.items = []; + this.uid2parent = {}; + this.excItems = []; + this.tzErrors = {}; +} + +parserState.prototype = { + parser: null, + joinFunc: null, + threadCount: 0, + + extraComponents: null, + items: null, + uid2parent: null, + excItems: null, + tzErrors: null, + listener: null, + + /** + * Checks if the timezones are missing and notifies the user via error console + * + * @param item The item to check for + * @param date The datetime object to check with + */ + checkTimezone(item, date) { + function isPhantomTimezone(timezone) { + return !timezone.icalComponent && !timezone.isUTC && !timezone.isFloating; + } + + if (date && isPhantomTimezone(date.timezone)) { + let tzid = date.timezone.tzid; + let hid = item.hashId + "#" + tzid; + if (!(hid in this.tzErrors)) { + // For now, publish errors to console and alert user. + // In future, maybe make them available through an interface method + // so this UI code can be removed from the parser, and caller can + // choose whether to alert, or show user the problem items and ask + // for fixes, or something else. + let msgArgs = [tzid, item.title, cal.dtz.formatter.formatDateTime(date)]; + let msg = cal.l10n.getCalString("unknownTimezoneInItem", msgArgs); + + cal.ERROR(msg + "\n" + item.icalString); + this.tzErrors[hid] = true; + } + } + }, + + /** + * Submit processing of a subcomponent to the event queue + * + * @param subComp The component to process + * @param isGCal If this is a Google Calendar invitation + */ + submit(subComp, isGCal) { + let self = this; + let runner = { + run() { + let item = null; + switch (subComp.componentType) { + case "VEVENT": + item = new lazy.CalEvent(); + item.icalComponent = subComp; + if (isGCal) { + cal.view.fixGoogleCalendarDescription(item); + } + self.checkTimezone(item, item.startDate); + self.checkTimezone(item, item.endDate); + break; + case "VTODO": + item = new lazy.CalTodo(); + item.icalComponent = subComp; + self.checkTimezone(item, item.entryDate); + self.checkTimezone(item, item.dueDate); + // completed is defined to be in UTC + break; + case "VTIMEZONE": + // this should already be attached to the relevant + // events in the calendar, so there's no need to + // do anything with it here. + break; + default: + self.extraComponents.push(subComp); + break; + } + + if (item) { + let rid = item.recurrenceId; + if (rid) { + self.excItems.push(item); + } else { + self.items.push(item); + if (item.recurrenceInfo) { + self.uid2parent[item.id] = item; + } + } + } + self.threadCount--; + self.checkCompletion(); + }, + }; + + this.threadCount++; + if (this.listener) { + // If we have a listener, we are doing this asynchronously. Go ahead + // and use the thread manager to dispatch the above runner + Services.tm.currentThread.dispatch(runner, Ci.nsIEventTarget.DISPATCH_NORMAL); + } else { + // No listener means synchonous. Just run the runner instead + runner.run(); + } + }, + + /** + * Checks if the processing of all events has completed. If a join function + * has been set, this function is called. + * + * @returns True, if all tasks have been completed + */ + checkCompletion() { + if (this.joinFunc && this.threadCount == 0) { + this.joinFunc(); + return true; + } + return false; + }, + + /** + * Sets a join function that is called when all tasks have been completed + * + * @param joinFunc The join function to call + */ + join(joinFunc) { + this.joinFunc = joinFunc; + this.checkCompletion(); + }, +}; diff --git a/comm/calendar/base/src/CalIcsSerializer.jsm b/comm/calendar/base/src/CalIcsSerializer.jsm new file mode 100644 index 0000000000..ebdf84f9e7 --- /dev/null +++ b/comm/calendar/base/src/CalIcsSerializer.jsm @@ -0,0 +1,77 @@ +/* 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 = ["CalIcsSerializer"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +function CalIcsSerializer() { + this.wrappedJSObject = this; + this.mItems = []; + this.mProperties = []; + this.mComponents = []; +} +CalIcsSerializer.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIIcsSerializer"]), + classID: Components.ID("{207a6682-8ff1-4203-9160-729ec28c8766}"), + + addItems(aItems) { + if (aItems.length > 0) { + this.mItems = this.mItems.concat(aItems); + } + }, + + addProperty(aProperty) { + this.mProperties.push(aProperty); + }, + + addComponent(aComponent) { + this.mComponents.push(aComponent); + }, + + serializeToString() { + let calComp = this.getIcalComponent(); + return calComp.serializeToICS(); + }, + + serializeToInputStream(aStream) { + let calComp = this.getIcalComponent(); + return calComp.serializeToICSStream(); + }, + + serializeToStream(aStream) { + let str = this.serializeToString(); + + // Convert the javascript string to an array of bytes, using the + // UTF8 encoder + let convStream = Cc["@mozilla.org/intl/converter-output-stream;1"].createInstance( + Ci.nsIConverterOutputStream + ); + convStream.init(aStream, "UTF-8"); + + convStream.writeString(str); + convStream.close(); + }, + + getIcalComponent() { + let calComp = cal.icsService.createIcalComponent("VCALENDAR"); + cal.item.setStaticProps(calComp); + + // xxx todo: think about that the below code doesn't clone the properties/components, + // thus ownership is moved to returned VCALENDAR... + + for (let prop of this.mProperties) { + calComp.addProperty(prop); + } + for (let comp of this.mComponents) { + calComp.addSubcomponent(comp); + } + + for (let item of cal.iterate.items(this.mItems)) { + calComp.addSubcomponent(item.icalComponent); + } + + return calComp; + }, +}; diff --git a/comm/calendar/base/src/CalItipItem.jsm b/comm/calendar/base/src/CalItipItem.jsm new file mode 100644 index 0000000000..aecf8671a1 --- /dev/null +++ b/comm/calendar/base/src/CalItipItem.jsm @@ -0,0 +1,212 @@ +/* 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 = ["CalItipItem"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +function CalItipItem() { + this.wrappedJSObject = this; + this.mCurrentItemIndex = 0; +} +CalItipItem.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIItipItem"]), + classID: Components.ID("{f41392ab-dcad-4bad-818f-b3d1631c4d93}"), + + mIsInitialized: false, + + mSender: null, + get sender() { + return this.mSender; + }, + set sender(aValue) { + this.mSender = aValue; + }, + + mIsSend: false, + get isSend() { + return this.mIsSend; + }, + set isSend(aValue) { + this.mIsSend = aValue; + }, + + mReceivedMethod: "REQUEST", + get receivedMethod() { + return this.mReceivedMethod; + }, + set receivedMethod(aMethod) { + this.mReceivedMethod = aMethod.toUpperCase(); + }, + + mResponseMethod: "REPLY", + get responseMethod() { + if (!this.mIsInitialized) { + throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); + } + return this.mResponseMethod; + }, + set responseMethod(aMethod) { + this.mResponseMethod = aMethod.toUpperCase(); + }, + + mAutoResponse: null, + get autoResponse() { + return this.mAutoResponse; + }, + set autoResponse(aValue) { + this.mAutoResponse = aValue; + }, + + mTargetCalendar: null, + get targetCalendar() { + return this.mTargetCalendar; + }, + set targetCalendar(aValue) { + this.mTargetCalendar = aValue; + }, + + mIdentity: null, + get identity() { + return this.mIdentity; + }, + set identity(aValue) { + this.mIdentity = aValue; + }, + + mLocalStatus: null, + get localStatus() { + return this.mLocalStatus; + }, + set localStatus(aValue) { + this.mLocalStatus = aValue; + }, + + mItemList: {}, + + init(aIcalString) { + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + parser.parseString(aIcalString); + + // - User specific alarms as well as X-MOZ- properties are irrelevant w.r.t. iTIP messages, + // should not be sent out and should not be relevant for incoming messages + // - faked master items + // so clean them out: + + function cleanItem(item) { + // the following changes will bump LAST-MODIFIED/DTSTAMP, we want to preserve the originals: + let stamp = item.stampTime; + let lastModified = item.lastModifiedTime; + item.clearAlarms(); + item.alarmLastAck = null; + item.deleteProperty("RECEIVED-SEQUENCE"); + item.deleteProperty("RECEIVED-DTSTAMP"); + for (let [name] of item.properties) { + if (name != "X-MOZ-FAKED-MASTER" && name.substr(0, "X-MOZ-".length) == "X-MOZ-") { + item.deleteProperty(name); + } + } + // never publish an organizer's RECEIVED params: + item.getAttendees().forEach(att => { + att.deleteProperty("RECEIVED-SEQUENCE"); + att.deleteProperty("RECEIVED-DTSTAMP"); + }); + + // according to RfC 6638, the following items must not be exposed in client side + // email scheduling messages, so let's remove it if present + let removeSchedulingParams = aCalUser => { + aCalUser.deleteProperty("SCHEDULE-AGENT"); + aCalUser.deleteProperty("SCHEDULE-FORCE-SEND"); + aCalUser.deleteProperty("SCHEDULE-STATUS"); + }; + item.getAttendees().forEach(removeSchedulingParams); + // we're graceful here as some PUBLISHed events may violate RfC by having no organizer + if (item.organizer) { + removeSchedulingParams(item.organizer); + } + + item.setProperty("DTSTAMP", stamp); + item.setProperty("LAST-MODIFIED", lastModified); // need to be last to undirty the item + } + + this.mItemList = []; + for (let item of cal.iterate.items(parser.getItems())) { + cleanItem(item); + // only push non-faked master items or + // the overridden instances of faked master items + // to the list: + if (item == item.parentItem) { + if (!item.hasProperty("X-MOZ-FAKED-MASTER")) { + this.mItemList.push(item); + } + } else if (item.parentItem.hasProperty("X-MOZ-FAKED-MASTER")) { + this.mItemList.push(item); + } + } + + // We set both methods now for safety's sake. It's the ItipProcessor's + // responsibility to properly ascertain what the correct response + // method is (using user feedback, prefs, etc.) for the given + // receivedMethod. The RFC tells us to treat items without a METHOD + // as if they were METHOD:REQUEST. + for (let prop of parser.getProperties()) { + if (prop.propertyName == "METHOD") { + this.mReceivedMethod = prop.value; + this.mResponseMethod = prop.value; + break; + } + } + + this.mIsInitialized = true; + }, + + clone() { + let newItem = new CalItipItem(); + newItem.mItemList = this.mItemList.map(item => item.clone()); + newItem.mReceivedMethod = this.mReceivedMethod; + newItem.mResponseMethod = this.mResponseMethod; + newItem.mAutoResponse = this.mAutoResponse; + newItem.mTargetCalendar = this.mTargetCalendar; + newItem.mIdentity = this.mIdentity; + newItem.mLocalStatus = this.mLocalStatus; + newItem.mSender = this.mSender; + newItem.mIsSend = this.mIsSend; + newItem.mIsInitialized = this.mIsInitialized; + return newItem; + }, + + /** + * This returns both the array and the number of items. An easy way to + * call it is: let itemArray = itipItem.getItemList(); + */ + getItemList() { + if (!this.mIsInitialized) { + throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED); + } + return this.mItemList; + }, + + /** + * Note that this code forces the user to respond to all items in the same + * way, which is a current limitation of the spec. + */ + setAttendeeStatus(aAttendeeId, aStatus) { + // Append "mailto:" to the attendee if it is missing it. + if (!aAttendeeId.match(/^mailto:/i)) { + aAttendeeId = "mailto:" + aAttendeeId; + } + + for (let item of this.mItemList) { + let attendee = item.getAttendeeById(aAttendeeId); + if (attendee) { + // Replies should not have the RSVP property. + // XXX BUG 351589: workaround for updating an attendee + item.removeAttendee(attendee); + attendee = attendee.clone(); + attendee.rsvp = null; + item.addAttendee(attendee); + } + } + }, +}; diff --git a/comm/calendar/base/src/CalMetronome.jsm b/comm/calendar/base/src/CalMetronome.jsm new file mode 100644 index 0000000000..b675e5bc0a --- /dev/null +++ b/comm/calendar/base/src/CalMetronome.jsm @@ -0,0 +1,142 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["CalMetronome"]; + +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +const { EventEmitter } = ChromeUtils.importESModule("resource://gre/modules/EventEmitter.sys.mjs"); + +const MINUTE_IN_MS = 60000; +const HOUR_IN_MS = 3600000; +const DAY_IN_MS = 86400000; + +/** + * Keeps calendar UI/components in sync by ticking regularly. Fires a "minute" + * event every minute on the minute, an "hour" event on the hour, and a "day" + * event at midnight. Each event also fires if longer than the time period in + * question has elapsed since the last event, e.g. because the computer has + * been asleep. + * + * It automatically corrects clock skew: if a minute event is more than one + * second late, the time to the next event is recalculated and should fire a + * few milliseconds late at worst. + * + * @implements nsIObserver + * @implements EventEmitter + */ +var CalMetronome = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + /** + * The time when the minute event last fired, in milliseconds since the epoch. + * + * @type integer + */ + _lastFireTime: 0, + + /** + * The last minute for which the minute event fired. + * + * @type integer (0-59) + */ + _lastMinute: -1, + + /** + * The last hour for which the hour event fired. + * + * @type integer (0-23) + */ + _lastHour: -1, + + /** + * The last day of the week for which the day event fired. + * + * @type integer (0-7) + */ + _lastDay: -1, + + /** + * The timer running everything. + * + * @type nsITimer + */ + _timer: Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer), + + init() { + let now = new Date(); + this._lastFireTime = now.valueOf(); + this._lastHour = now.getHours(); + this._lastDay = now.getDay(); + + EventEmitter.decorate(this); + + Services.obs.addObserver(this, "wake_notification"); + Services.obs.addObserver(this, "quit-application"); + this._startNext(); + }, + + observe(subject, topic, data) { + if (topic == "wake_notification") { + cal.LOGverbose("[CalMetronome] Observed wake_notification"); + this.notify(); + } else if (topic == "quit-application") { + this._timer.cancel(); + Services.obs.removeObserver(this, "wake_notification"); + Services.obs.removeObserver(this, "quit-application"); + } + }, + + _startNext() { + this._timer.cancel(); + + let now = new Date(); + let next = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + now.getHours(), + now.getMinutes() + 1, + 0 + ); + cal.LOGverbose(`[CalMetronome] Scheduling one-off event in ${next - now}ms`); + this._timer.initWithCallback(this, next - now, Ci.nsITimer.TYPE_ONE_SHOT); + }, + + _startRepeating() { + cal.LOGverbose(`[CalMetronome] Starting repeating events`); + this._timer.initWithCallback(this, MINUTE_IN_MS, Ci.nsITimer.TYPE_REPEATING_SLACK); + }, + + notify() { + let now = new Date(); + let elapsedSinceLastFire = now.valueOf() - this._lastFireTime; + this._lastFireTime = now.valueOf(); + + let minute = now.getMinutes(); + if (minute != this._lastMinute || elapsedSinceLastFire > MINUTE_IN_MS) { + this._lastMinute = minute; + this.emit("minute", now); + } + + let hour = now.getHours(); + if (hour != this._lastHour || elapsedSinceLastFire > HOUR_IN_MS) { + this._lastHour = hour; + this.emit("hour", now); + } + + let day = now.getDay(); + if (day != this._lastDay || elapsedSinceLastFire > DAY_IN_MS) { + this._lastDay = day; + this.emit("day", now); + } + + let slack = now.getSeconds(); + if (slack >= 1 && slack < 59) { + this._startNext(); + } else if (this._timer.type == Ci.nsITimer.TYPE_ONE_SHOT) { + this._startRepeating(); + } + }, +}; +CalMetronome.init(); diff --git a/comm/calendar/base/src/CalMimeConverter.jsm b/comm/calendar/base/src/CalMimeConverter.jsm new file mode 100644 index 0000000000..be09812817 --- /dev/null +++ b/comm/calendar/base/src/CalMimeConverter.jsm @@ -0,0 +1,69 @@ +/* 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 = ["CalMimeConverter"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +function CalMimeConverter() { + this.wrappedJSObject = this; +} + +CalMimeConverter.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsISimpleMimeConverter"]), + classID: Components.ID("{c70acb08-464e-4e55-899d-b2c84c5409fa}"), + + mailChannel: null, + uri: null, + + convertToHTML(contentType, data) { + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + parser.parseString(data); + let event = null; + for (let item of parser.getItems()) { + if (item.isEvent()) { + if (item.hasProperty("X-MOZ-FAKED-MASTER")) { + // if it's a faked master, take any overridden item to get a real occurrence: + let exc = item.recurrenceInfo.getExceptionFor(item.startDate); + cal.ASSERT(exc, "unexpected!"); + if (exc) { + item = exc; + } + } + event = item; + break; + } + } + if (!event) { + return ""; + } + + let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem); + itipItem.init(data); + + // this.uri is the message URL that we are processing. + if (this.uri) { + try { + let msgUrl = this.uri.QueryInterface(Ci.nsIMsgMailNewsUrl); + itipItem.sender = msgUrl.mimeHeaders.extractHeader("From", false); + } catch (exc) { + // msgWindow is optional in some scenarios + // (e.g. gloda in action, throws NS_ERROR_INVALID_POINTER then) + } + } + + // msgOverlay needs to be defined irrespectively of the existence of msgWindow to not break + // printing of invitation emails + let msgOverlay = ""; + + if (!Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) { + let dom = cal.invitation.createInvitationOverlay(event, itipItem); + msgOverlay = cal.xml.serializeDOM(dom); + } + + this.mailChannel.imipItem = itipItem; + + return msgOverlay; + }, +}; diff --git a/comm/calendar/base/src/CalPeriod.jsm b/comm/calendar/base/src/CalPeriod.jsm new file mode 100644 index 0000000000..7c3ca3c5e3 --- /dev/null +++ b/comm/calendar/base/src/CalPeriod.jsm @@ -0,0 +1,87 @@ +/* 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 = ["CalPeriod"]; + +const { ICAL, unwrapSetter, wrapGetter } = ChromeUtils.import( + "resource:///modules/calendar/Ical.jsm" +); + +const lazy = {}; +ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm"); +ChromeUtils.defineModuleGetter(lazy, "CalDuration", "resource:///modules/CalDuration.jsm"); + +function CalPeriod(innerObject) { + this.innerObject = innerObject || new ICAL.Period({}); + this.wrappedJSObject = this; +} + +CalPeriod.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIPeriod"]), + classID: Components.ID("{394a281f-7299-45f7-8b1f-cce21258972f}"), + + isMutable: true, + innerObject: null, + + get icalPeriod() { + return this.innerObject; + }, + set icalPeriod(val) { + this.innerObject = val; + }, + + makeImmutable() { + this.isMutable = false; + }, + clone() { + return new CalPeriod(this.innerObject.clone()); + }, + + get start() { + return wrapGetter(lazy.CalDateTime, this.innerObject.start); + }, + set start(rawval) { + unwrapSetter( + ICAL.Time, + rawval, + function (val) { + this.innerObject.start = val; + }, + this + ); + }, + + get end() { + return wrapGetter(lazy.CalDateTime, this.innerObject.getEnd()); + }, + set end(rawval) { + unwrapSetter( + ICAL.Time, + rawval, + function (val) { + if (this.innerObject.duration) { + this.innerObject.duration = null; + } + this.innerObject.end = val; + }, + this + ); + }, + + get duration() { + return wrapGetter(lazy.CalDuration, this.innerObject.getDuration()); + }, + + get icalString() { + return this.innerObject.toICALString(); + }, + set icalString(val) { + let dates = ICAL.parse._parseValue(val, "period", ICAL.design.icalendar); + this.innerObject = ICAL.Period.fromString(dates.join("/")); + }, + + toString() { + return this.innerObject.toString(); + }, +}; diff --git a/comm/calendar/base/src/CalProtocolHandler.jsm b/comm/calendar/base/src/CalProtocolHandler.jsm new file mode 100644 index 0000000000..09632355a0 --- /dev/null +++ b/comm/calendar/base/src/CalProtocolHandler.jsm @@ -0,0 +1,63 @@ +/* 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 = ["CalProtocolHandlerWebcal", "CalProtocolHandlerWebcals"]; + +/** + * CalProtocolHandler. + * + * @param {string} scheme - The scheme to init for (webcal, webcals). + * @implements {nsIProtocolHandler} + */ +class CalProtocolHandlerWebcal { + QueryInterface = ChromeUtils.generateQI(["nsIProtocolHandler"]); + + scheme = "webcal"; + httpScheme = "http"; + httpPort = 80; + + newURI(aSpec, anOriginalCharset, aBaseURI) { + return Cc["@mozilla.org/network/standard-url-mutator;1"] + .createInstance(Ci.nsIStandardURLMutator) + .init(Ci.nsIStandardURL.URLTYPE_STANDARD, this.httpPort, aSpec, anOriginalCharset, aBaseURI) + .finalize() + .QueryInterface(Ci.nsIStandardURL); + } + + newChannel(aUri, aLoadInfo) { + let uri = aUri.mutate().setScheme(this.httpScheme).finalize(); + + let channel; + if (aLoadInfo) { + channel = Services.io.newChannelFromURIWithLoadInfo(uri, aLoadInfo); + } else { + channel = Services.io.newChannelFromURI( + uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + } + channel.originalURI = aUri; + return channel; + } + + allowPort(aPort, aScheme) { + return false; // We are not overriding any special ports. + } +} +CalProtocolHandlerWebcal.prototype.classID = Components.ID( + "{1153c73a-39be-46aa-9ba9-656d188865ca}" +); + +class CalProtocolHandlerWebcals extends CalProtocolHandlerWebcal { + scheme = "webcals"; + httpScheme = "http"; + httpPort = 443; +} +CalProtocolHandlerWebcals.prototype.classID = Components.ID( + "{bdf71224-365d-4493-856a-a7e74026f766}" +); diff --git a/comm/calendar/base/src/CalReadableStreamFactory.jsm b/comm/calendar/base/src/CalReadableStreamFactory.jsm new file mode 100644 index 0000000000..c41d20dc8f --- /dev/null +++ b/comm/calendar/base/src/CalReadableStreamFactory.jsm @@ -0,0 +1,314 @@ +/* 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/. */ + +/* global ReadableStream */ + +const EXPORTED_SYMBOLS = ["CalReadableStreamFactory"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/** + * Function used to transform each value received from a stream. + * + * @callback MapStreamFunction + * @param {any} value + * @returns {Promise<any>|any} + */ + +/** + * A version of UnderlyingSource that accepts a CalBoundedReadableStreamController + * as the controller argument. + * + * @typedef {object} CalBoundedReadableStreamUnderlyingSource + */ + +/** + * Wrapper class for a ReadableStreamDefaultController that keeps track of how + * many items have been added to the queue before closing. This controller also + * buffers items to reduce the amount of times items are added to the queue. + */ +class CalBoundedReadableStreamController { + /** + * @type {ReadableStreamDefaultController} + */ + _controller = null; + + /** + * @type {CalBoundedReadableStreamUnderlyingSource} + */ + _src = null; + + /** + * @type {number} + */ + _maxTotalItems; + + /** + * @type {number} + */ + _maxQueuedItems; + + /** + * @type {calIItemBase[]} + */ + _buffer = []; + + /** + * @type {boolean} + */ + _closed = false; + + /** + * The count of items enqueued so far. + * + * @type {number} + */ + count = 0; + + /** + * @param {number} maxTotalItems + * @param {number} maxQueuedItems + * @param {CalBoundedReadableStreamUnderlyingSource} src + */ + constructor(maxTotalItems, maxQueuedItems, src) { + this._maxTotalItems = maxTotalItems; + this._maxQueuedItems = maxQueuedItems; + this._src = src; + } + + /** + * Indicates whether the maximum number of items have been added to the queue + * after which no more will be allowed. + * + * @type {number} + */ + get maxTotalItemsReached() { + return this._maxTotalItems && this.count >= this._maxTotalItems; + } + + /** + * Indicates whether the queue is full or not. + * + * @type {boolean} + */ + get queueFull() { + return this._buffer.length >= this._maxQueuedItems; + } + + /** + * Indicates how many more items can be enqueued based on the internal count + * kept. + * + * @type {number} + */ + get remainingItemCount() { + return this._maxTotalItems ? this._maxTotalItems - this.count : Infinity; + } + + /** + * Provides the value of the same property from the controller. + * + * @type {number} + */ + get desiredSize() { + return this._controller.desiredSize; + } + + /** + * Called by the ReadableStream to begin queueing items. This delegates to + * the provided underlying source. + * + * @param {ReadableStreamDefaultController} controller + */ + async start(controller) { + this._controller = controller; + if (this._src.start) { + await this._src.start(this); + } + } + + /** + * Called by the ReadableStream to receive more items when the queue has not + * been filled. + */ + async pull() { + if (this._src.pull) { + await this._src.pull(this); + } + } + + /** + * Called by the ReadableStream when reading has been cancelled. + * + * @param {string} reason + */ + async cancel(reason) { + this._closed = true; + if (this._src.cancel) { + await this._src.cancel(reason); + } + } + + /** + * Called by start() of the underlying source to add items to the queue. Items + * will only be added if maxTotalItemsReached returns false at which point + * the stream is automatically closed. + * + * @param {calIItemBase[]} items + */ + enqueue(items) { + for (let item of items) { + if (this.queueFull) { + this.flush(); + } + if (this.maxTotalItemsReached) { + return; + } + this._buffer.push(item); + } + this.flush(); + } + + /** + * Flushes the internal buffer if the number of buffered items have reached + * the threshold. + * + * @param {boolean} [force] - If true, will flush all items regardless of the + * threshold. + */ + flush(force) { + if (force || this.queueFull) { + if (this.maxTotalItemsReached) { + return; + } + let buffer = this._buffer.slice(0, this.remainingItemCount); + this._controller.enqueue(buffer); + this.count += buffer.length; + this._buffer = []; + if (this.maxTotalItemsReached) { + this._controller.close(); + } + } + } + + /** + * Puts the stream in the error state. + * + * @param {Error} err + */ + error(err) { + this._closed = true; + this._controller.error(err); + } + + /** + * Closes the stream preventing any further items from being added to the queue. + */ + close() { + if (!this._closed) { + if (this._buffer.length) { + this.flush(true); + } + this._closed = true; + this._controller.close(); + } + } +} + +/** + * Factory object for creating ReadableStreams of calIItemBase instances. This + * is used by the providers to satisfy getItems() calls from their respective + * backing stores. + */ +class CalReadableStreamFactory { + /** + * The default amount of items to queue before providing via the reader. + */ + static defaultQueueSize = 10; + + /** + * Creates a generic ReadableStream using the passed object as the + * UnderlyingSource. Use this method instead of creating streams directly + * until the API is more stable. + * + * @param {UnderlyingSource} src + * + * @returns {ReadableStream} + */ + static createReadableStream(src) { + return new ReadableStream(src); + } + + /** + * Creates a ReadableStream of calIItemBase items that tracks how many + * have been added to the queue. If maxTotalItems or more are enqueued, the + * stream will close ignoring further additions. + * + * @param {number} maxTotalItems + * @param {number} maxQueuedItems + * @param {UnderlyingSource} src + * + * @returns {ReadableStream<calIItemBase>} + */ + static createBoundedReadableStream(maxTotalItems, maxQueuedItems, src) { + return new ReadableStream( + new CalBoundedReadableStreamController(maxTotalItems, maxQueuedItems, src) + ); + } + + /** + * Creates a ReadableStream that will provide no actual items. + * + * @returns {ReadableStream<calIItemBase>} + */ + static createEmptyReadableStream() { + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + } + + /** + * Creates a ReadableStream that uses the one or more provided ReadableStreams + * for the source of its data. Each stream is read to completion one at a time + * and an error occurring while reading any will cause the main stream to end + * with in an error state. + * + * @param {ReadableStream[]} streams + * @returns {ReadableStream} + */ + static createCombinedReadableStream(streams) { + return new ReadableStream({ + async start(controller) { + for (let stream of streams) { + for await (let chunk of cal.iterate.streamValues(stream)) { + controller.enqueue(chunk); + } + } + controller.close(); + }, + }); + } + + /** + * Creates a ReadableStream from another stream where each chunk of the source + * stream is passed to a MapStreamFunction before enqueuing in the final stream. + * + * @param {ReadableStream} + * @param {MapStreamFunction} + * + * @returns {ReadableStream} + */ + static createMappedReadableStream(stream, func) { + return new ReadableStream({ + async start(controller) { + for await (let chunk of cal.iterate.streamValues(stream)) { + controller.enqueue(await func(chunk)); + } + controller.close(); + }, + }); + } +} diff --git a/comm/calendar/base/src/CalRecurrenceDate.jsm b/comm/calendar/base/src/CalRecurrenceDate.jsm new file mode 100644 index 0000000000..cd43979a38 --- /dev/null +++ b/comm/calendar/base/src/CalRecurrenceDate.jsm @@ -0,0 +1,122 @@ +/* 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 = ["CalRecurrenceDate"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const lazy = {}; +ChromeUtils.defineModuleGetter(lazy, "CalPeriod", "resource:///modules/CalPeriod.jsm"); + +function CalRecurrenceDate() { + this.wrappedJSObject = this; +} + +var calRecurrenceDateClassID = Components.ID("{806b6423-3aaa-4b26-afa3-de60563e9cec}"); +var calRecurrenceDateInterfaces = [Ci.calIRecurrenceItem, Ci.calIRecurrenceDate]; +CalRecurrenceDate.prototype = { + isMutable: true, + + mIsNegative: false, + mDate: null, + + classID: calRecurrenceDateClassID, + QueryInterface: cal.generateQI(["calIRecurrenceItem", "calIRecurrenceDate"]), + classInfo: cal.generateCI({ + classID: calRecurrenceDateClassID, + contractID: "@mozilla.org/calendar/recurrence-date;1", + classDescription: "The date of an occurrence of a recurring item", + interfaces: calRecurrenceDateInterfaces, + }), + + makeImmutable() { + this.isMutable = false; + }, + + ensureMutable() { + if (!this.isMutable) { + throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE); + } + }, + + clone() { + let other = new CalRecurrenceDate(); + other.mDate = this.mDate ? this.mDate.clone() : null; + other.mIsNegative = this.mIsNegative; + return other; + }, + + get isNegative() { + return this.mIsNegative; + }, + set isNegative(val) { + this.ensureMutable(); + this.mIsNegative = val; + }, + + get isFinite() { + return true; + }, + + get date() { + return this.mDate; + }, + set date(val) { + this.ensureMutable(); + this.mDate = val; + }, + + getNextOccurrence(aStartTime, aOccurrenceTime) { + if (this.mDate && this.mDate.compare(aStartTime) > 0) { + return this.mDate; + } + return null; + }, + + getOccurrences(aStartTime, aRangeStart, aRangeEnd, aMaxCount) { + if ( + this.mDate && + this.mDate.compare(aRangeStart) >= 0 && + (!aRangeEnd || this.mDate.compare(aRangeEnd) < 0) + ) { + return [this.mDate]; + } + return []; + }, + + get icalString() { + let comp = this.icalProperty; + return comp ? comp.icalString : ""; + }, + set icalString(val) { + let prop = cal.icsService.createIcalPropertyFromString(val); + let propName = prop.propertyName; + if (propName != "RDATE" && propName != "EXDATE") { + throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE); + } + + this.icalProperty = prop; + }, + + get icalProperty() { + let prop = cal.icsService.createIcalProperty(this.mIsNegative ? "EXDATE" : "RDATE"); + prop.valueAsDatetime = this.mDate; + return prop; + }, + set icalProperty(prop) { + if (prop.propertyName == "RDATE") { + this.mIsNegative = false; + if (prop.getParameter("VALUE") == "PERIOD") { + let period = new lazy.CalPeriod(); + period.icalString = prop.valueAsIcalString; + this.mDate = period.start; + } else { + this.mDate = prop.valueAsDatetime; + } + } else if (prop.propertyName == "EXDATE") { + this.mIsNegative = true; + this.mDate = prop.valueAsDatetime; + } + }, +}; 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; + } + }, +}; diff --git a/comm/calendar/base/src/CalRecurrenceRule.jsm b/comm/calendar/base/src/CalRecurrenceRule.jsm new file mode 100644 index 0000000000..7d713f2ecf --- /dev/null +++ b/comm/calendar/base/src/CalRecurrenceRule.jsm @@ -0,0 +1,268 @@ +/* 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 = ["CalRecurrenceRule"]; + +const { ICAL, unwrapSetter, unwrapSingle, wrapGetter } = ChromeUtils.import( + "resource:///modules/calendar/Ical.jsm" +); +const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const lazy = {}; +ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm"); +ChromeUtils.defineModuleGetter(lazy, "CalIcalProperty", "resource:///modules/CalICSService.jsm"); + +function CalRecurrenceRule(innerObject) { + this.innerObject = innerObject || new ICAL.Recur(); + this.wrappedJSObject = this; +} + +var calRecurrenceRuleInterfaces = [Ci.calIRecurrenceRule, Ci.calIRecurrenceItem]; +var calRecurrenceRuleClassID = Components.ID("{df19281a-5389-4146-b941-798cb93a7f0d}"); +CalRecurrenceRule.prototype = { + QueryInterface: cal.generateQI(["calIRecurrenceRule", "calIRecurrenceItem"]), + classID: calRecurrenceRuleClassID, + classInfo: cal.generateCI({ + contractID: "@mozilla.org/calendar/recurrence-rule;1", + classDescription: "Calendar Recurrence Rule", + classID: calRecurrenceRuleClassID, + interfaces: calRecurrenceRuleInterfaces, + }), + + innerObject: null, + + isMutable: true, + makeImmutable() { + this.isMutable = false; + }, + ensureMutable() { + if (!this.isMutable) { + throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE); + } + }, + clone() { + return new CalRecurrenceRule(new ICAL.Recur(this.innerObject)); + }, + + isNegative: false, // We don't support EXRULE anymore + get isFinite() { + return this.innerObject.isFinite(); + }, + + /** + * Tests whether the "FREQ" value for this rule is supported or not. A warning + * is logged if an unsupported value ("SECONDLY"|"MINUTELY") is encountered. + * + * @returns {boolean} + */ + freqSupported() { + let { freq } = this.innerObject; + if (freq == "SECONDLY" || freq == "MINUTELY") { + cal.WARN( + `The frequency value "${freq}" is currently not supported. No occurrences will be generated.` + ); + return false; + } + return true; + }, + + getNextOccurrence(aStartTime, aRecId) { + if (!this.freqSupported()) { + return null; + } + aStartTime = unwrapSingle(ICAL.Time, aStartTime); + aRecId = unwrapSingle(ICAL.Time, aRecId); + return wrapGetter(lazy.CalDateTime, this.innerObject.getNextOccurrence(aStartTime, aRecId)); + }, + + getOccurrences(aStartTime, aRangeStart, aRangeEnd, aMaxCount) { + if (!this.freqSupported()) { + return []; + } + aStartTime = unwrapSingle(ICAL.Time, aStartTime); + aRangeStart = unwrapSingle(ICAL.Time, aRangeStart); + aRangeEnd = unwrapSingle(ICAL.Time, aRangeEnd); + + if (!aMaxCount && !aRangeEnd && this.count == 0 && this.until == null) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + let occurrences = []; + let rangeStart = aRangeStart.clone(); + rangeStart.isDate = false; + + let dtend = null; + + if (aRangeEnd) { + dtend = aRangeEnd.clone(); + dtend.isDate = false; + + // If the start of the recurrence is past the end, we have no dates + if (aStartTime.compare(dtend) >= 0) { + return []; + } + } + + let iter = this.innerObject.iterator(aStartTime); + + for (let next = iter.next(); next; next = iter.next()) { + let dtNext = next.clone(); + dtNext.isDate = false; + + if (dtNext.compare(rangeStart) < 0) { + continue; + } + + if (dtend && dtNext.compare(dtend) >= 0) { + break; + } + + next = next.clone(); + + if (aStartTime.zone) { + next.zone = aStartTime.zone; + } + + occurrences.push(new lazy.CalDateTime(next)); + + if (aMaxCount && occurrences.length >= aMaxCount) { + break; + } + } + + return occurrences; + }, + + get icalString() { + return "RRULE:" + this.innerObject.toString() + ICAL.newLineChar; + }, + set icalString(val) { + this.ensureMutable(); + this.innerObject = ICAL.Recur.fromString(val.replace(/^RRULE:/i, "")); + }, + + get icalProperty() { + let prop = new ICAL.Property("rrule"); + prop.setValue(this.innerObject); + return new lazy.CalIcalProperty(prop); + }, + set icalProperty(rawval) { + this.ensureMutable(); + unwrapSetter( + ICAL.Property, + rawval, + function (val) { + this.innerObject = val.getFirstValue(); + }, + this + ); + }, + + get type() { + return this.innerObject.freq; + }, + set type(val) { + this.ensureMutable(); + this.innerObject.freq = val; + }, + + get interval() { + return this.innerObject.interval; + }, + set interval(val) { + this.ensureMutable(); + this.innerObject.interval = val; + }, + + get count() { + if (!this.isByCount) { + throw Components.Exception("", Cr.NS_ERROR_FAILURE); + } + return this.innerObject.count || -1; + }, + set count(val) { + this.ensureMutable(); + this.innerObject.count = val && val > 0 ? val : null; + }, + + get untilDate() { + if (this.innerObject.until) { + return new lazy.CalDateTime(this.innerObject.until); + } + return null; + }, + set untilDate(rawval) { + this.ensureMutable(); + unwrapSetter( + ICAL.Time, + rawval, + function (val) { + if ( + val.timezone != ICAL.Timezone.utcTimezone && + val.timezone != ICAL.Timezone.localTimezone + ) { + val = val.convertToZone(ICAL.Timezone.utcTimezone); + } + + this.innerObject.until = val; + }, + this + ); + }, + + get isByCount() { + return this.innerObject.isByCount(); + }, + + get weekStart() { + return this.innerObject.wkst - 1; + }, + set weekStart(val) { + this.ensureMutable(); + this.innerObject.wkst = val + 1; + }, + + getComponent(aType) { + let values = this.innerObject.getComponent(aType); + if (aType == "BYDAY") { + // BYDAY values are alphanumeric: SU, MO, TU, etc.. + for (let i = 0; i < values.length; i++) { + let match = /^([+-])?(5[0-3]|[1-4][0-9]|[1-9])?(SU|MO|TU|WE|TH|FR|SA)$/.exec(values[i]); + if (!match) { + cal.ERROR("Malformed BYDAY rule\n" + cal.STACK(10)); + return []; + } + values[i] = ICAL.Recur.icalDayToNumericDay(match[3]); + if (match[2]) { + // match[2] is the week number for this value. + values[i] += 8 * match[2]; + } + if (match[1] == "-") { + // Week numbers are counted back from the end of the period. + values[i] *= -1; + } + } + } + + return values; + }, + + setComponent(aType, aValues) { + let values = aValues; + if (aType == "BYDAY") { + // BYDAY values are alphanumeric: SU, MO, TU, etc.. + for (let i = 0; i < values.length; i++) { + let absValue = Math.abs(values[i]); + if (absValue > 7) { + let ordinal = Math.trunc(values[i] / 8); + let day = ICAL.Recur.numericDayToIcalDay(absValue % 8); + values[i] = ordinal + day; + } else { + values[i] = ICAL.Recur.numericDayToIcalDay(values[i]); + } + } + } + this.innerObject.setComponent(aType, values); + }, +}; diff --git a/comm/calendar/base/src/CalRelation.jsm b/comm/calendar/base/src/CalRelation.jsm new file mode 100644 index 0000000000..78327d962c --- /dev/null +++ b/comm/calendar/base/src/CalRelation.jsm @@ -0,0 +1,125 @@ +/* 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 = ["CalRelation"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +/** + * Constructor for `calIRelation` objects. + * + * @class + * @implements {calIRelation} + * @param {string} [icalString] - Optional iCal string for initializing existing relations. + */ +function CalRelation(icalString) { + this.wrappedJSObject = this; + this.mProperties = new Map(); + if (icalString) { + this.icalString = icalString; + } +} +CalRelation.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIRelation"]), + classID: Components.ID("{76810fae-abad-4019-917a-08e95d5bbd68}"), + + mType: null, + mId: null, + + /** + * @see calIRelation + */ + + get relType() { + return this.mType; + }, + set relType(aType) { + this.mType = aType; + }, + + get relId() { + return this.mId; + }, + set relId(aRelId) { + this.mId = aRelId; + }, + + get icalProperty() { + let icalatt = cal.icsService.createIcalProperty("RELATED-TO"); + if (this.mId) { + icalatt.value = this.mId; + } + + if (this.mType) { + icalatt.setParameter("RELTYPE", this.mType); + } + + for (let [key, value] of this.mProperties.entries()) { + try { + icalatt.setParameter(key, value); + } catch (e) { + if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid relation property value " + key + "=" + value); + } else { + throw e; + } + } + } + return icalatt; + }, + + set icalProperty(attProp) { + // Reset the property bag for the parameters, it will be re-initialized + // from the ical property. + this.mProperties = new Map(); + + if (attProp.value) { + this.mId = attProp.value; + } + for (let [name, value] of cal.iterate.icalParameter(attProp)) { + if (name == "RELTYPE") { + this.mType = value; + continue; + } + + this.setParameter(name, value); + } + }, + + get icalString() { + let comp = this.icalProperty; + return comp ? comp.icalString : ""; + }, + set icalString(val) { + let prop = cal.icsService.createIcalPropertyFromString(val); + if (prop.propertyName != "RELATED-TO") { + throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE); + } + this.icalProperty = prop; + }, + + getParameter(aName) { + return this.mProperties.get(aName); + }, + + setParameter(aName, aValue) { + return this.mProperties.set(aName, aValue); + }, + + deleteParameter(aName) { + return this.mProperties.delete(aName); + }, + + clone() { + let newRelation = new CalRelation(); + newRelation.mId = this.mId; + newRelation.mType = this.mType; + for (let [name, value] of this.mProperties.entries()) { + newRelation.mProperties.set(name, value); + } + return newRelation; + }, +}; diff --git a/comm/calendar/base/src/CalStartupService.jsm b/comm/calendar/base/src/CalStartupService.jsm new file mode 100644 index 0000000000..04b2a53032 --- /dev/null +++ b/comm/calendar/base/src/CalStartupService.jsm @@ -0,0 +1,124 @@ +/* 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 = ["CalStartupService"]; + +/** + * Helper function to asynchronously call a certain method on the objects passed + * in 'services' in order (i.e wait until the first completes before calling the + * second + * + * @param method The method name to call. Usually startup/shutdown. + * @param services The array of service objects to call on. + */ +function callOrderedServices(method, services) { + let service = services.shift(); + if (service) { + service[method]({ + onResult() { + callOrderedServices(method, services); + }, + }); + } +} + +function CalStartupService() { + this.wrappedJSObject = this; + this.setupObservers(); +} + +CalStartupService.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + classID: Components.ID("{2547331f-34c0-4a4b-b93c-b503538ba6d6}"), + + // Startup Service Methods + + /** + * Sets up the needed observers for noticing startup/shutdown + */ + setupObservers() { + Services.obs.addObserver(this, "profile-after-change"); + Services.obs.addObserver(this, "profile-before-change"); + Services.obs.addObserver(this, "xpcom-shutdown"); + }, + + started: false, + + /** + * Gets the startup order of services. This is an array of service objects + * that should be called in order at startup. + * + * @returns The startup order as an array. + */ + getStartupOrder() { + let self = this; + + let tzService = Cc["@mozilla.org/calendar/timezone-service;1"] + .getService(Ci.calITimezoneService) + .QueryInterface(Ci.calIStartupService); + + let calMgr = Cc["@mozilla.org/calendar/manager;1"] + .getService(Ci.calICalendarManager) + .QueryInterface(Ci.calIStartupService); + + // Localization service + let locales = { + startup(aCompleteListener) { + let packaged = Services.locale.packagedLocales; + let fileSrc = new L10nFileSource( + "calendar", + "app", + packaged, + "resource:///chrome/{locale}/locale/{locale}/calendar/" + ); + L10nRegistry.getInstance().registerSources([fileSrc]); + aCompleteListener.onResult(null, Cr.NS_OK); + }, + shutdown(aCompleteListener) { + aCompleteListener.onResult(null, Cr.NS_OK); + }, + }; + + // Notification object + let notify = { + startup(aCompleteListener) { + self.started = true; + Services.obs.notifyObservers(null, "calendar-startup-done"); + aCompleteListener.onResult(null, Cr.NS_OK); + }, + shutdown(aCompleteListener) { + // Argh, it would have all been so pretty! Since we just reverse + // the array, the shutdown notification would happen before the + // other shutdown calls. For lack of pretty code, I'm + // leaving this out! Users can still listen to xpcom-shutdown. + self.started = false; + aCompleteListener.onResult(null, Cr.NS_OK); + }, + }; + + // We need to spin up the timezone service before the calendar manager + // to ensure we have the timezones initialized. Make sure "notify" is + // last in this array! + return [locales, tzService, calMgr, notify]; + }, + + /** + * Observer notification callback + */ + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "profile-after-change": + callOrderedServices("startup", this.getStartupOrder()); + break; + case "profile-before-change": + callOrderedServices("shutdown", this.getStartupOrder().reverse()); + break; + case "xpcom-shutdown": + Services.obs.removeObserver(this, "profile-after-change"); + Services.obs.removeObserver(this, "profile-before-change"); + Services.obs.removeObserver(this, "xpcom-shutdown"); + break; + } + }, +}; diff --git a/comm/calendar/base/src/CalTimezone.jsm b/comm/calendar/base/src/CalTimezone.jsm new file mode 100644 index 0000000000..094321fdb3 --- /dev/null +++ b/comm/calendar/base/src/CalTimezone.jsm @@ -0,0 +1,77 @@ +/* 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 = ["CalTimezone"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "l10nBundle", () => { + // Prepare localized timezone display values + const bundleURL = "chrome://calendar/locale/timezones.properties"; + return Services.strings.createBundle(bundleURL); +}); + +function CalTimezone(innerObject) { + this.innerObject = innerObject || new ICAL.Timezone(); + this.wrappedJSObject = this; +} + +CalTimezone.prototype = { + QueryInterface: ChromeUtils.generateQI(["calITimezone"]), + classID: Components.ID("{6702eb17-a968-4b43-b562-0d0c5f8e9eb5}"), + + innerObject: null, + + get provider() { + return cal.timezoneService; + }, + + get icalComponent() { + let innerComp = this.innerObject.component; + let comp = null; + if (innerComp) { + comp = cal.icsService.createIcalComponent("VTIMEZONE"); + comp.icalComponent = innerComp; + } + return comp; + }, + + get tzid() { + return this.innerObject.tzid; + }, + + get isFloating() { + return this.innerObject == ICAL.Timezone.localTimezone; + }, + + get isUTC() { + return this.innerObject == ICAL.Timezone.utcTimezone; + }, + + get displayName() { + // Localization is currently only used for floating/UTC until we have a + // better story around timezone localization and display + let stringName = "pref.timezone." + this.tzid.replace(/\//g, "."); + let displayName = this.tzid; + + try { + displayName = lazy.l10nBundle.GetStringFromName(stringName); + } catch (e) { + // Just use the TZID if the string is missing. + } + + this.__defineGetter__("displayName", () => { + return displayName; + }); + return displayName; + }, + + tostring() { + return this.innerObject.toString(); + }, +}; diff --git a/comm/calendar/base/src/CalTimezoneService.jsm b/comm/calendar/base/src/CalTimezoneService.jsm new file mode 100644 index 0000000000..7973435d7c --- /dev/null +++ b/comm/calendar/base/src/CalTimezoneService.jsm @@ -0,0 +1,228 @@ +/* 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 = ["CalTimezoneService"]; + +var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs"); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { ICAL, unwrapSingle } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm"); + +const { CalTimezone } = ChromeUtils.import("resource:///modules/CalTimezone.jsm"); + +const TIMEZONE_CHANGED_TOPIC = "default-timezone-changed"; + +// CalTimezoneService acts as an implementation of both ICAL.TimezoneService and +// the XPCOM calITimezoneService used for providing timezone objects to calendar +// code. +function CalTimezoneService() { + this.wrappedJSObject = this; + + this._timezoneDatabase = Cc["@mozilla.org/calendar/timezone-database;1"].getService( + Ci.calITimezoneDatabase + ); + + this.mZones = new Map(); + this.mZoneIds = []; + + ICAL.TimezoneService = this.wrappedJSObject; +} + +var calTimezoneServiceClassID = Components.ID("{e736f2bd-7640-4715-ab35-887dc866c587}"); +var calTimezoneServiceInterfaces = [Ci.calITimezoneService, Ci.calIStartupService]; +CalTimezoneService.prototype = { + mDefaultTimezone: null, + mVersion: null, + mZones: null, + mZoneIds: null, + + classID: calTimezoneServiceClassID, + QueryInterface: cal.generateQI(["calITimezoneService", "calIStartupService"]), + classInfo: cal.generateCI({ + classID: calTimezoneServiceClassID, + contractID: "@mozilla.org/calendar/timezone-service;1", + classDescription: "Calendar Timezone Service", + interfaces: calTimezoneServiceInterfaces, + flags: Ci.nsIClassInfo.SINGLETON, + }), + + // ical.js TimezoneService methods + has(id) { + return this.getTimezone(id) != null; + }, + get(id) { + return id ? unwrapSingle(ICAL.Timezone, this.getTimezone(id)) : null; + }, + remove() {}, + register() {}, + + // calIStartupService methods + startup(aCompleteListener) { + // Fetch list of supported canonical timezone IDs from the backing database + this.mZoneIds = this._timezoneDatabase.getCanonicalTimezoneIds(); + + // Fetch the version of the backing database + this.mVersion = this._timezoneDatabase.version; + cal.LOG("[CalTimezoneService] Timezones version " + this.version + " loaded"); + + // Set up zones for special values + const utc = new CalTimezone(ICAL.Timezone.utcTimezone); + this.mZones.set("UTC", utc); + + const floating = new CalTimezone(ICAL.Timezone.localTimezone); + this.mZones.set("floating", floating); + + // Initialize default timezone and, if unset, user timezone prefs + this._initDefaultTimezone(); + + // Watch for changes in system timezone or related user preferences + Services.prefs.addObserver("calendar.timezone.useSystemTimezone", this); + Services.prefs.addObserver("calendar.timezone.local", this); + Services.obs.addObserver(this, TIMEZONE_CHANGED_TOPIC); + + // Notify the startup service that startup is complete + if (aCompleteListener) { + aCompleteListener.onResult(null, Cr.NS_OK); + } + }, + + shutdown(aCompleteListener) { + Services.obs.removeObserver(this, TIMEZONE_CHANGED_TOPIC); + Services.prefs.removeObserver("calendar.timezone.local", this); + Services.prefs.removeObserver("calendar.timezone.useSystemTimezone", this); + aCompleteListener.onResult(null, Cr.NS_OK); + }, + + // calITimezoneService methods + get UTC() { + return this.mZones.get("UTC"); + }, + + get floating() { + return this.mZones.get("floating"); + }, + + getTimezone(tzid) { + if (!tzid) { + cal.ERROR("Unknown timezone requested\n" + cal.STACK(10)); + return null; + } + + if (tzid.startsWith("/mozilla.org/")) { + // We know that our former tzids look like "/mozilla.org/<dtstamp>/continent/..." + // The ending of the mozilla prefix is the index of that slash before the + // continent. Therefore, we start looking for the prefix-ending slash + // after position 13. + tzid = tzid.substring(tzid.indexOf("/", 13) + 1); + } + + // Per the IANA timezone database, "Z" is _not_ an alias for UTC, but our + // previous list of zones included it and Ical.js at a minimum is expecting + // it to be valid + if (tzid === "Z") { + return this.mZones.get("UTC"); + } + + // First check our cache of timezones + let timezone = this.mZones.get(tzid); + if (!timezone) { + // The requested timezone is not in the cache; ask the backing database + // for the timezone definition + const tzdef = this._timezoneDatabase.getTimezoneDefinition(tzid); + + if (!tzdef) { + cal.ERROR(`Could not find definition for ${tzid}`); + return null; + } + + timezone = new CalTimezone( + ICAL.Timezone.fromData({ + tzid, + component: tzdef, + }) + ); + + // Cache the resulting timezone + this.mZones.set(tzid, timezone); + } + + return timezone; + }, + + get timezoneIds() { + return this.mZoneIds; + }, + + get version() { + return this.mVersion; + }, + + _initDefaultTimezone() { + // If the "use system timezone" preference is unset, we default to enabling + // it if the user's system supports it + let isSetSystemTimezonePref = Services.prefs.prefHasUserValue( + "calendar.timezone.useSystemTimezone" + ); + + if (!isSetSystemTimezonePref) { + let canUseSystemTimezone = AppConstants.MOZ_CAN_FOLLOW_SYSTEM_TIME; + + Services.prefs.setBoolPref("calendar.timezone.useSystemTimezone", canUseSystemTimezone); + } + + this._updateDefaultTimezone(); + }, + + _updateDefaultTimezone() { + let prefUseSystemTimezone = Services.prefs.getBoolPref( + "calendar.timezone.useSystemTimezone", + true + ); + let prefTzid = Services.prefs.getStringPref("calendar.timezone.local", null); + + let tzid; + if (prefUseSystemTimezone || prefTzid === null || prefTzid === "floating") { + // If we do not have a timezone preference set, we default to using the + // system time; we may also do this if the user has set their preferences + // accordingly + tzid = Intl.DateTimeFormat().resolvedOptions().timeZone; + } else { + tzid = prefTzid; + } + + // Update default timezone and preference if necessary + if (!this.mDefaultTimezone || this.mDefaultTimezone.tzid != tzid) { + this.mDefaultTimezone = this.getTimezone(tzid); + cal.ASSERT(this.mDefaultTimezone, `Timezone not found: ${tzid}`); + Services.obs.notifyObservers(null, "defaultTimezoneChanged"); + + if (this.mDefaultTimezone.tzid != prefTzid) { + Services.prefs.setStringPref("calendar.timezone.local", this.mDefaultTimezone.tzid); + } + } + }, + + get defaultTimezone() { + // We expect this to be initialized when the service comes up and updated if + // the underlying default changes + return this.mDefaultTimezone; + }, + + observe(aSubject, aTopic, aData) { + // Update the default timezone if the system timezone has changed; we + // expect the update function to decide if actually making the change is + // appropriate based on user prefs + if (aTopic == TIMEZONE_CHANGED_TOPIC) { + this._updateDefaultTimezone(); + } else if ( + aTopic == "nsPref:changed" && + (aData == "calendar.timezone.useSystemTimezone" || aData == "calendar.timezone.local") + ) { + // We may get a bogus second update from the timezone pref if its change + // is a result of the system timezone changing, but it should settle, and + // trying to guard against it is full of corner cases + this._updateDefaultTimezone(); + } + }, +}; diff --git a/comm/calendar/base/src/CalTodo.jsm b/comm/calendar/base/src/CalTodo.jsm new file mode 100644 index 0000000000..50d2f42fd1 --- /dev/null +++ b/comm/calendar/base/src/CalTodo.jsm @@ -0,0 +1,264 @@ +/* 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/. */ + +/* import-globals-from calItemBase.js */ + +var EXPORTED_SYMBOLS = ["CalTodo"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +Services.scriptloader.loadSubScript("resource:///components/calItemBase.js"); + +/** + * Constructor for `calITodo` objects. + * + * @class + * @implements {calITodo} + * @param {string} [icalString] - Optional iCal string for initializing existing todos. + */ +function CalTodo(icalString) { + this.initItemBase(); + + this.todoPromotedProps = { + DTSTART: true, + DTEND: true, + DUE: true, + COMPLETED: true, + __proto__: this.itemBasePromotedProps, + }; + + if (icalString) { + this.icalString = icalString; + } + + // Set a default percentComplete if the icalString didn't already set it. + if (!this.percentComplete) { + this.percentComplete = 0; + } +} + +var calTodoClassID = Components.ID("{7af51168-6abe-4a31-984d-6f8a3989212d}"); +var calTodoInterfaces = [Ci.calIItemBase, Ci.calITodo, Ci.calIInternalShallowCopy]; +CalTodo.prototype = { + __proto__: calItemBase.prototype, + + classID: calTodoClassID, + QueryInterface: cal.generateQI(["calIItemBase", "calITodo", "calIInternalShallowCopy"]), + classInfo: cal.generateCI({ + classID: calTodoClassID, + contractID: "@mozilla.org/calendar/todo;1", + classDescription: "Calendar Todo", + interfaces: calTodoInterfaces, + }), + + cloneShallow(aNewParent) { + let cloned = new CalTodo(); + this.cloneItemBaseInto(cloned, aNewParent); + return cloned; + }, + + createProxy(aRecurrenceId) { + cal.ASSERT(!this.mIsProxy, "Tried to create a proxy for an existing proxy!", true); + + let proxy = new CalTodo(); + + // override proxy's DTSTART/DUE/RECURRENCE-ID + // before master is set (and item might get immutable): + let duration = this.duration; + if (duration) { + let dueDate = aRecurrenceId.clone(); + dueDate.addDuration(duration); + proxy.dueDate = dueDate; + } + proxy.entryDate = aRecurrenceId; + + proxy.initializeProxy(this, aRecurrenceId); + proxy.mDirty = false; + + return proxy; + }, + + makeImmutable() { + this.makeItemBaseImmutable(); + }, + + isTodo() { + return true; + }, + + get isCompleted() { + return this.completedDate != null || this.percentComplete == 100 || this.status == "COMPLETED"; + }, + + set isCompleted(completed) { + if (completed) { + if (!this.completedDate) { + this.completedDate = cal.dtz.jsDateToDateTime(new Date()); + } + this.status = "COMPLETED"; + this.percentComplete = 100; + } else { + this.deleteProperty("COMPLETED"); + this.deleteProperty("STATUS"); + this.deleteProperty("PERCENT-COMPLETE"); + } + }, + + get duration() { + let dur = this.getProperty("DURATION"); + // pick up duration if available, otherwise calculate difference + // between start and enddate + if (dur) { + return cal.createDuration(dur); + } + if (!this.entryDate || !this.dueDate) { + return null; + } + return this.dueDate.subtractDate(this.entryDate); + }, + + set duration(value) { + this.setProperty("DURATION", value); + }, + + get recurrenceStartDate() { + // DTSTART is optional for VTODOs, so it's unclear if RRULE is allowed then, + // so fallback to DUE if no DTSTART is present: + return this.entryDate || this.dueDate; + }, + + icsEventPropMap: [ + { cal: "DTSTART", ics: "startTime" }, + { cal: "DUE", ics: "dueTime" }, + { cal: "COMPLETED", ics: "completedTime" }, + ], + + set icalString(value) { + this.icalComponent = cal.icsService.parseICS(value); + }, + + get icalString() { + let calcomp = cal.icsService.createIcalComponent("VCALENDAR"); + cal.item.setStaticProps(calcomp); + calcomp.addSubcomponent(this.icalComponent); + return calcomp.serializeToICS(); + }, + + get icalComponent() { + let icalcomp = cal.icsService.createIcalComponent("VTODO"); + this.fillIcalComponentFromBase(icalcomp); + this.mapPropsToICS(icalcomp, this.icsEventPropMap); + + for (let [name, value] of this.properties) { + try { + // When deleting a property of an occurrence, the property is not actually deleted + // but instead set to null, so we need to prevent adding those properties. + let wasReset = this.mIsProxy && value === null; + if (!this.todoPromotedProps[name] && !wasReset) { + let icalprop = cal.icsService.createIcalProperty(name); + icalprop.value = value; + let propBucket = this.mPropertyParams[name]; + if (propBucket) { + for (let paramName in propBucket) { + try { + icalprop.setParameter(paramName, propBucket[paramName]); + } catch (e) { + if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG( + "Warning: Invalid todo parameter value " + + paramName + + "=" + + propBucket[paramName] + ); + } else { + throw e; + } + } + } + } + icalcomp.addProperty(icalprop); + } + } catch (e) { + cal.ERROR("failed to set " + name + " to " + value + ": " + e + "\n"); + } + } + return icalcomp; + }, + + todoPromotedProps: null, + + set icalComponent(todo) { + this.modify(); + if (todo.componentType != "VTODO") { + todo = todo.getFirstSubcomponent("VTODO"); + if (!todo) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + } + + this.mDueDate = undefined; + this.setItemBaseFromICS(todo); + this.mapPropsFromICS(todo, this.icsEventPropMap); + + this.importUnpromotedProperties(todo, this.todoPromotedProps); + // Importing didn't really change anything + this.mDirty = false; + }, + + isPropertyPromoted(name) { + // avoid strict undefined property warning + return this.todoPromotedProps[name] || false; + }, + + set entryDate(value) { + this.modify(); + + // We're about to change the start date of an item which probably + // could break the associated calIRecurrenceInfo. We're calling + // the appropriate method here to adjust the internal structure in + // order to free clients from worrying about such details. + if (this.parentItem == this) { + let rec = this.recurrenceInfo; + if (rec) { + rec.onStartDateChange(value, this.entryDate); + } + } + + this.setProperty("DTSTART", value); + }, + + get entryDate() { + return this.getProperty("DTSTART"); + }, + + mDueDate: undefined, + get dueDate() { + let dueDate = this.mDueDate; + if (dueDate === undefined) { + dueDate = this.getProperty("DUE"); + if (!dueDate) { + let entryDate = this.entryDate; + let dur = this.getProperty("DURATION"); + if (entryDate && dur) { + // If there is a duration set on the todo, calculate the right end time. + dueDate = entryDate.clone(); + dueDate.addDuration(cal.createDuration(dur)); + } + } + this.mDueDate = dueDate; + } + return dueDate; + }, + + set dueDate(value) { + this.deleteProperty("DURATION"); // setting dueDate once removes DURATION + this.setProperty("DUE", value); + this.mDueDate = value; + }, +}; + +makeMemberAttrProperty(CalTodo, "COMPLETED", "completedDate"); +makeMemberAttrProperty(CalTodo, "PERCENT-COMPLETE", "percentComplete"); diff --git a/comm/calendar/base/src/CalTransactionManager.jsm b/comm/calendar/base/src/CalTransactionManager.jsm new file mode 100644 index 0000000000..67f01733ec --- /dev/null +++ b/comm/calendar/base/src/CalTransactionManager.jsm @@ -0,0 +1,372 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = [ + "CalTransactionManager", + "CalTransaction", + "CalBatchTransaction", + "CalAddTransaction", + "CalModifyTransaction", + "CalDeleteTransaction", +]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +const OP_ADD = Ci.calIOperationListener.ADD; +const OP_MODIFY = Ci.calIOperationListener.MODIFY; +const OP_DELETE = Ci.calIOperationListener.DELETE; + +let transactionManager = null; + +/** + * CalTransactionManager is used to track user initiated operations on calendar + * items. These transactions can be undone or repeated when appropriate. + * + * This implementation is used instead of nsITransactionManager because better + * support for async transactions and access to batch transactions is needed + * which nsITransactionManager does not provide. + */ +class CalTransactionManager { + /** + * Contains transactions executed by the transaction manager than can be + * undone. + * + * @type {CalTransaction} + */ + undoStack = []; + + /** + * Contains transactions that have been undone by the transaction manager and + * can be redone again later if desired. + * + * @type {CalTransaction} + */ + redoStack = []; + + /** + * Provides a singleton instance of the CalTransactionManager. + * + * @returns {CalTransactionManager} + */ + static getInstance() { + if (!transactionManager) { + transactionManager = new CalTransactionManager(); + } + return transactionManager; + } + + /** + * @typedef {object} ExtResponse + * @property {number} responseMode One of the calIItipItem.autoResponse values. + */ + + /** + * @typedef {"add" | "modify" | "delete"} Action + */ + + /** + * Adds a CalTransaction to the internal stack. The transaction will be + * executed and its resulting Promise returned. + * + * @param {CalTransaction} trn - The CalTransaction to add to the stack and + * execute. + */ + async commit(trn) { + this.undoStack.push(trn); + return trn.doTransaction(); + } + + /** + * Creates and pushes a new CalBatchTransaction onto the internal stack. + * The created transaction is returned and can be used to combine multiple + * transactions into one. + * + * @returns {CalBatchTrasaction} + */ + beginBatch() { + let trn = new CalBatchTransaction(); + this.undoStack.push(trn); + return trn; + } + + /** + * peekUndoStack provides the top transaction on the undo stack (if any) + * without modifying the stack. + * + * @returns {CalTransaction?} + */ + peekUndoStack() { + return this.undoStack.at(-1); + } + + /** + * Undo the transaction at the top of the undo stack. + * + * @throws - NS_ERROR_FAILURE if the undo stack is empty. + */ + async undo() { + if (!this.undoStack.length) { + throw new Components.Exception( + "CalTransactionManager: undo stack is empty!", + Cr.NS_ERROR_FAILURE + ); + } + let trn = this.undoStack.pop(); + this.redoStack.push(trn); + return trn.undoTransaction(); + } + + /** + * Returns true if it is possible to undo the transaction at the top of the + * undo stack. + * + * @returns {boolean} + */ + canUndo() { + let trn = this.peekUndoStack(); + return Boolean(trn?.canWrite()); + } + + /** + * peekRedoStack provides the top transaction on the redo stack (if any) + * without modifying the stack. + * + * @returns {CalTransaction?} + */ + peekRedoStack() { + return this.redoStack.at(-1); + } + + /** + * Redo the transaction at the top of the redo stack. + * + * @throws - NS_ERROR_FAILURE if the redo stack is empty. + */ + async redo() { + if (!this.redoStack.length) { + throw new Components.Exception( + "CalTransactionManager: redo stack is empty!", + Cr.NS_ERROR_FAILURE + ); + } + let trn = this.redoStack.pop(); + this.undoStack.push(trn); + return trn.doTransaction(); + } + + /** + * Returns true if it is possible to redo the transaction at the top of the + * redo stack. + * + * @returns {boolean} + */ + canRedo() { + let trn = this.peekRedoStack(); + return Boolean(trn?.canWrite()); + } +} + +/** + * CalTransaction represents a single, atomic user operation on one or more + * calendar items. + */ +class CalTransaction { + /** + * Indicates whether the calendar of the transaction's target item(s) can be + * written to. + * + * @returns {boolean} + */ + canWrite() { + return false; + } + + /** + * Executes the transaction. + */ + async doTransaction() {} + + /** + * Executes the "undo" action of the transaction. + */ + async undoTransaction() {} +} + +/** + * CalBatchTransaction is used for batch transactions where multiple transactions + * treated as one is desired. For example; where the user selects and deletes + * more than one event. + */ +class CalBatchTransaction extends CalTransaction { + /** + * Stores the transactions that belong to the batch. + * + * @type {CalTransaction[]} + */ + transactions = []; + + /** + * Similar to the CalTransactionManager method except the transaction will be + * added to the batch. + */ + async commit(trn) { + this.transactions.push(trn); + return trn.doTransaction(); + } + + canWrite() { + return Boolean(this.transactions.length && this.transactions.every(trn => trn.canWrite())); + } + + async doTransaction() { + for (let trn of this.transactions) { + await trn.doTransaction(); + } + } + + async undoTransaction() { + for (let trn of this.transactions.slice().reverse()) { + await trn.undoTransaction(); + } + } +} + +/** + * CalBaseTransaction serves as the base for add/modify/delete operations. + */ +class CalBaseTransaction extends CalTransaction { + /** + * @type {calICalendar} + */ + calendar = null; + + /** + * @type {calIItemBase} + */ + item = null; + + /** + * @type {calIItemBase} + */ + oldItem = null; + + /** + * @type {calICalendar} + */ + oldCalendar = null; + + /** + * @type {ExtResponse} + */ + extResponse = null; + + /** + * @private + * @param {calIItemBase} item + * @param {calICalendar} calendar + * @param {calIItemBase?} oldItem + * @param {object?} extResponse + */ + constructor(item, calendar, oldItem, extResponse) { + super(); + this.item = item; + this.calendar = calendar; + this.oldItem = oldItem; + this.extResponse = extResponse; + } + + _dispatch(opType, item, oldItem) { + cal.itip.checkAndSend(opType, item, oldItem, this.extResponse); + } + + canWrite() { + if (itemWritable(this.item)) { + return this instanceof CalModifyTransaction ? itemWritable(this.oldItem) : true; + } + return false; + } +} + +/** + * CalAddTransaction handles additions. + */ +class CalAddTransaction extends CalBaseTransaction { + async doTransaction() { + let item = await this.calendar.addItem(this.item); + this._dispatch(OP_ADD, item, this.oldItem); + this.item = item; + } + + async undoTransaction() { + await this.calendar.deleteItem(this.item); + this._dispatch(OP_DELETE, this.item, this.item); + this.oldItem = this.item; + } +} + +/** + * CalModifyTransaction handles modifications. + */ +class CalModifyTransaction extends CalBaseTransaction { + async doTransaction() { + let item; + if (this.item.calendar.id == this.oldItem.calendar.id) { + item = await this.calendar.modifyItem( + cal.itip.prepareSequence(this.item, this.oldItem), + this.oldItem + ); + this._dispatch(OP_MODIFY, item, this.oldItem); + } else { + this.oldCalendar = this.oldItem.calendar; + item = await this.calendar.addItem(this.item); + this._dispatch(OP_ADD, item, this.oldItem); + await this.oldItem.calendar.deleteItem(this.oldItem); + this._dispatch(OP_DELETE, this.oldItem, this.oldItem); + } + this.item = item; + } + + async undoTransaction() { + if (this.oldItem.calendar.id == this.item.calendar.id) { + await this.calendar.modifyItem(cal.itip.prepareSequence(this.oldItem, this.item), this.item); + this._dispatch(OP_MODIFY, this.oldItem, this.oldItem); + } else { + await this.calendar.deleteItem(this.item); + this._dispatch(OP_DELETE, this.item, this.item); + await this.oldCalendar.addItem(this.oldItem); + this._dispatch(OP_ADD, this.oldItem, this.item); + } + } +} + +/** + * CalDeleteTransaction handles deletions. + */ +class CalDeleteTransaction extends CalBaseTransaction { + async doTransaction() { + await this.calendar.deleteItem(this.item); + this._dispatch(OP_DELETE, this.item, this.oldItem); + } + + async undoTransaction() { + await this.calendar.addItem(this.item); + this._dispatch(OP_ADD, this.item, this.item); + } +} + +/** + * Checks whether an item's calendar can be written to. + * + * @param {calIItemBase} item + */ +function itemWritable(item) { + return ( + item && + item.calendar && + cal.acl.isCalendarWritable(item.calendar) && + cal.acl.userCanAddItemsToCalendar(item.calendar) + ); +} diff --git a/comm/calendar/base/src/CalWeekInfoService.jsm b/comm/calendar/base/src/CalWeekInfoService.jsm new file mode 100644 index 0000000000..a94145f44e --- /dev/null +++ b/comm/calendar/base/src/CalWeekInfoService.jsm @@ -0,0 +1,113 @@ +/* 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 = ["CalWeekInfoService"]; + +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +const SUNDAY = 0; +const THURSDAY = 4; + +const lazy = {}; + +XPCOMUtils.defineLazyPreferenceGetter(lazy, "startWeekday", "calendar.week.start", SUNDAY); + +function CalWeekInfoService() { + this.wrappedJSObject = this; +} +CalWeekInfoService.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIWeekInfoService"]), + classID: Components.ID("{6877bbdd-f336-46f5-98ce-fe86d0285cc1}"), + + // calIWeekInfoService: + getWeekTitle(aDateTime) { + /** + * This implementation is based on the ISO 8601 standard. + * ISO 8601 defines week one as the first week with at least 4 + * days, and defines Monday as the first day of the week. + * Equivalently, the week one is the week with the first Thursday. + * + * This implementation uses the second definition, because it + * enables the user to set a different start-day of the week + * (Sunday instead of Monday is a common setting). If the first + * definition was used, all week-numbers could be off by one + * depending on the week start day. (For example, if weeks start + * on Sunday, a year that starts on Thursday has only 3 days + * [Thu-Sat] in that week, so it would be part of the last week of + * the previous year, but if weeks start on Monday, the year would + * have four days [Thu-Sun] in that week, so it would be counted + * as week 1.) + */ + + // The week number is the number of days since the start of week 1, + // divided by 7 and rounded up. Week 1 is the week containing the first + // Thursday of the year. + // Thus, the week number of any day is the same as the number of days + // between the Thursday of that week and the Thursday of week 1, divided + // by 7 and rounded up. (This takes care of days at end/start of a year + // which may be part of first/last week in the other year.) + // The Thursday of a week is the Thursday that follows the first day + // of the week. + // The week number of a day is the same as the week number of the first + // day of the week. (This takes care of days near the start of the year, + // which may be part of the week counted in the previous year.) So we + // need the startWeekday. + + // The number of days since the start of the week. + // Notice that the result of the subtraction might be negative. + // We correct for that by adding 7, and then using the remainder operator. + let sinceStartOfWeek = (aDateTime.weekday - lazy.startWeekday + 7) % 7; + + // The number of days to Thursday is the difference between Thursday + // and the start-day of the week (again corrected for negative values). + let startToThursday = (THURSDAY - lazy.startWeekday + 7) % 7; + + // The yearday number of the Thursday this week. + let thisWeeksThursday = aDateTime.yearday - sinceStartOfWeek + startToThursday; + + if (thisWeeksThursday < 1) { + // For the first few days of the year, we still are in week 52 or 53. + let lastYearDate = aDateTime.clone(); + lastYearDate.year -= 1; + thisWeeksThursday += lastYearDate.endOfYear.yearday; + } else if (thisWeeksThursday > aDateTime.endOfYear.yearday) { + // For the last few days of the year, we already are in week 1. + thisWeeksThursday -= aDateTime.endOfYear.yearday; + } + + let weekNumber = Math.ceil(thisWeeksThursday / 7); + return weekNumber; + }, + + /** + * gets the first day of a week of a passed day under consideration + * of the preference setting "calendar.week.start" + * + * @param aDate a date time object + * @returns a dateTime-object denoting the first day of the week + */ + getStartOfWeek(aDate) { + let date = aDate.clone(); + date.isDate = true; + let offset = lazy.startWeekday - aDate.weekday; + date.day += offset; + if (offset > 0) { + date.day -= 7; + } + return date; + }, + + /** + * gets the last day of a week of a passed day under consideration + * of the preference setting "calendar.week.start" + * + * @param aDate a date time object + * @returns a dateTime-object denoting the last day of the week + */ + getEndOfWeek(aDate) { + let date = this.getStartOfWeek(aDate); + date.day += 6; + return date; + }, +}; diff --git a/comm/calendar/base/src/TimezoneDatabase.cpp b/comm/calendar/base/src/TimezoneDatabase.cpp new file mode 100644 index 0000000000..d93f78e1f4 --- /dev/null +++ b/comm/calendar/base/src/TimezoneDatabase.cpp @@ -0,0 +1,114 @@ +/* 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/. */ + +#include "nsPrintfCString.h" +#include "nsString.h" +#include "unicode/strenum.h" +#include "unicode/timezone.h" +#include "unicode/ucal.h" +#include "unicode/utypes.h" +#include "unicode/vtzone.h" + +#include "TimezoneDatabase.h" + +NS_IMPL_ISUPPORTS(TimezoneDatabase, calITimezoneDatabase) + +NS_IMETHODIMP +TimezoneDatabase::GetVersion(nsACString& aVersion) { + UErrorCode err = U_ZERO_ERROR; + const char* version = icu::VTimeZone::getTZDataVersion(err); + if (U_FAILURE(err)) { + NS_WARNING(nsPrintfCString("ICU error: %s", u_errorName(err)).get()); + return NS_ERROR_FAILURE; + } + + aVersion.Assign(version); + + return NS_OK; +} + +NS_IMETHODIMP +TimezoneDatabase::GetCanonicalTimezoneIds(nsTArray<nsCString>& aTimezoneIds) { + aTimezoneIds.Clear(); + + UErrorCode err = U_ZERO_ERROR; + + // Because this list of IDs is not intended to be restrictive, we only request + // the canonical IDs to avoid providing lots of redundant options to users + icu::StringEnumeration* icuEnum = icu::VTimeZone::createTimeZoneIDEnumeration( + UCAL_ZONE_TYPE_CANONICAL, nullptr, nullptr, err); + if (U_FAILURE(err)) { + NS_WARNING(nsPrintfCString("ICU error: %s", u_errorName(err)).get()); + return NS_ERROR_FAILURE; + } + + const char* value; + err = U_ZERO_ERROR; + while ((value = icuEnum->next(nullptr, err)) != nullptr && U_SUCCESS(err)) { + nsCString tzid(value); + aTimezoneIds.AppendElement(tzid); + } + + if (U_FAILURE(err)) { + // If we encountered any error during enumeration of the timezones, we want + // to return an empty list + aTimezoneIds.Clear(); + + NS_WARNING(nsPrintfCString("ICU error: %s", u_errorName(err)).get()); + return NS_ERROR_FAILURE; + } + + return NS_OK; +} + +NS_IMETHODIMP +TimezoneDatabase::GetTimezoneDefinition(const nsACString& tzid, + nsACString& _retval) { + _retval.Truncate(); + + NS_ConvertUTF8toUTF16 convertedTzid(tzid); + + // It seems Windows can potentially build `convertedTzid` with wchar_t + // underlying, which makes the UnicodeString ctor ambiguous; be explicit here + const char16_t* convertedTzidPtr = convertedTzid.get(); + + icu::UnicodeString icuTzid(convertedTzidPtr, + static_cast<int>(convertedTzid.Length())); + + auto* icuTimezone = icu::VTimeZone::createVTimeZoneByID(icuTzid); + if (icuTimezone == nullptr) { + return NS_OK; + } + + // Work around https://unicode-org.atlassian.net/browse/ICU-22175 + // This workaround is overly complex because there's no simple, reliable way + // to determine if a VTimeZone is Etc/Unknown; getID() doesn't work because + // the ctor doesn't set the ID field, and hasSameRules() against Etc/Unknown + // will return true if icuTimezone is GMT + if (icuTimezone->hasSameRules(icu::TimeZone::getUnknown()) && + !tzid.Equals("Etc/Unknown")) { + icu::UnicodeString actualTzid; + icu::TimeZone::createTimeZone(icuTzid)->getID(actualTzid); + + if (actualTzid == UNICODE_STRING("Etc/Unknown", 11)) { + return NS_OK; + } + } + + // Extract the VTIMEZONE definition from the timezone object + icu::UnicodeString vtimezoneDef; + UErrorCode err = U_ZERO_ERROR; + icuTimezone->write(vtimezoneDef, err); + if (U_FAILURE(err)) { + NS_WARNING(nsPrintfCString("ICU error: %s", u_errorName(err)).get()); + + return NS_ERROR_FAILURE; + } + + NS_ConvertUTF16toUTF8 convertedDef(vtimezoneDef.getTerminatedBuffer()); + + _retval.Assign(convertedDef); + + return NS_OK; +} diff --git a/comm/calendar/base/src/TimezoneDatabase.h b/comm/calendar/base/src/TimezoneDatabase.h new file mode 100644 index 0000000000..df3f782309 --- /dev/null +++ b/comm/calendar/base/src/TimezoneDatabase.h @@ -0,0 +1,20 @@ +/* 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/. */ +#ifndef mozilla_calTimezoneDatabase_h__ +#define mozilla_calTimezoneDatabase_h__ + +#include "calITimezoneDatabase.h" + +class TimezoneDatabase final : public calITimezoneDatabase { + NS_DECL_ISUPPORTS + NS_DECL_CALITIMEZONEDATABASE + + public: + TimezoneDatabase() = default; + + private: + ~TimezoneDatabase() = default; +}; + +#endif diff --git a/comm/calendar/base/src/calApplicationUtils.js b/comm/calendar/base/src/calApplicationUtils.js new file mode 100644 index 0000000000..47668a099f --- /dev/null +++ b/comm/calendar/base/src/calApplicationUtils.js @@ -0,0 +1,47 @@ +/* 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/. */ + +/* exported launchBrowser */ + +/** + * Launch the given url (string) in the external browser. If an event is passed, + * then this is only done on left click and the event propagation is stopped. + * + * @param url The URL to open, as a string + * @param event (optional) The event that caused the URL to open + */ +function launchBrowser(url, event) { + // Bail out if there is no url set, or an event was passed without left-click + if (!url || (event && event.button != 0)) { + return; + } + + // 0. Prevent people from trying to launch URLs such as javascript:foo(); + // by only allowing URLs starting with http or https or mid. + // XXX: We likely will want to do this using nsIURLs in the future to + // prevent sneaky nasty escaping issues, but this is fine for now. + if (!/^https?:/i.test(url) && !/^mid:/i.test(url)) { + console.error( + "launchBrowser: Invalid URL provided: " + url + " Only http(s):// and mid:// URLs are valid." + ); + return; + } + + if (/^mid:/i.test(url)) { + let { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm"); + MailUtils.openMessageByMessageId(url.slice(4)); + return; + } + + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(Services.io.newURI(url)); + + // Make sure that any default click handlers don't do anything, we have taken + // care of all processing + if (event) { + event.stopPropagation(); + event.preventDefault(); + } +} diff --git a/comm/calendar/base/src/calCachedCalendar.js b/comm/calendar/base/src/calCachedCalendar.js new file mode 100644 index 0000000000..3dd2d872a4 --- /dev/null +++ b/comm/calendar/base/src/calCachedCalendar.js @@ -0,0 +1,957 @@ +/* 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 = ["calCachedCalendar"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var calICalendar = Ci.calICalendar; +var cICL = Ci.calIChangeLog; +var cIOL = Ci.calIOperationListener; + +var gNoOpListener = { + QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]), + onGetResult(calendar, status, itemType, detail, items) {}, + + onOperationComplete(calendar, status, opType, id, detail) {}, +}; + +/** + * Returns true if the exception passed is one that should cause the cache + * layer to retry the operation. This is usually a network error or other + * temporary error. + * + * @param result The result code to check. + * @returns True, if the result code means server unavailability. + */ +function isUnavailableCode(result) { + // Stolen from nserror.h + const NS_ERROR_MODULE_NETWORK = 6; + function NS_ERROR_GET_MODULE(code) { + return ((code >> 16) - 0x45) & 0x1fff; + } + + if (NS_ERROR_GET_MODULE(result) == NS_ERROR_MODULE_NETWORK && !Components.isSuccessCode(result)) { + // This is a network error, which most likely means we should + // retry it some time. + return true; + } + + // Other potential errors we want to retry with + switch (result) { + case Cr.NS_ERROR_NOT_AVAILABLE: + return true; + default: + return false; + } +} + +function calCachedCalendarObserverHelper(home, isCachedObserver) { + this.home = home; + this.isCachedObserver = isCachedObserver; +} +calCachedCalendarObserverHelper.prototype = { + QueryInterface: ChromeUtils.generateQI(["calIObserver"]), + isCachedObserver: false, + + onStartBatch() { + this.home.mObservers.notify("onStartBatch", [this.home]); + }, + + onEndBatch() { + this.home.mObservers.notify("onEndBatch", [this.home]); + }, + + async onLoad(calendar) { + if (this.isCachedObserver) { + this.home.mObservers.notify("onLoad", [this.home]); + } else { + // start sync action after uncached calendar has been loaded. + // xxx todo, think about: + // although onAddItem et al have been called, we need to fire + // an additional onLoad completing the refresh call (->composite) + let home = this.home; + await home.synchronize(); + home.mObservers.notify("onLoad", [home]); + } + }, + + onAddItem(aItem) { + if (this.isCachedObserver) { + this.home.mObservers.notify("onAddItem", arguments); + } + }, + + onModifyItem(aNewItem, aOldItem) { + if (this.isCachedObserver) { + this.home.mObservers.notify("onModifyItem", arguments); + } + }, + + onDeleteItem(aItem) { + if (this.isCachedObserver) { + this.home.mObservers.notify("onDeleteItem", arguments); + } + }, + + onError(aCalendar, aErrNo, aMessage) { + this.home.mObservers.notify("onError", arguments); + }, + + onPropertyChanged(aCalendar, aName, aValue, aOldValue) { + if (!this.isCachedObserver) { + this.home.mObservers.notify("onPropertyChanged", [this.home, aName, aValue, aOldValue]); + } + }, + + onPropertyDeleting(aCalendar, aName) { + if (!this.isCachedObserver) { + this.home.mObservers.notify("onPropertyDeleting", [this.home, aName]); + } + }, +}; + +function calCachedCalendar(uncachedCalendar) { + this.wrappedJSObject = this; + this.mSyncQueue = []; + this.mObservers = new cal.data.ObserverSet(Ci.calIObserver); + uncachedCalendar.superCalendar = this; + uncachedCalendar.addObserver(new calCachedCalendarObserverHelper(this, false)); + this.mUncachedCalendar = uncachedCalendar; + this.setupCachedCalendar(); + if (this.supportsChangeLog) { + uncachedCalendar.offlineStorage = this.mCachedCalendar; + } + this.offlineCachedItems = {}; + this.offlineCachedItemFlags = {}; +} +calCachedCalendar.prototype = { + /* eslint-disable mozilla/use-chromeutils-generateqi */ + QueryInterface(aIID) { + if (aIID.equals(Ci.calISchedulingSupport) && this.mUncachedCalendar.QueryInterface(aIID)) { + // check whether uncached calendar supports it: + return this; + } else if (aIID.equals(Ci.calICalendar) || aIID.equals(Ci.nsISupports)) { + return this; + } + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + }, + /* eslint-enable mozilla/use-chromeutils-generateqi */ + + mCachedCalendar: null, + mCachedObserver: null, + mUncachedCalendar: null, + mObservers: null, + mSuperCalendar: null, + offlineCachedItems: null, + offlineCachedItemFlags: null, + + onCalendarUnregistering() { + if (this.mCachedCalendar) { + let self = this; + this.mCachedCalendar.removeObserver(this.mCachedObserver); + // TODO put changes into a different calendar and delete + // afterwards. + + let listener = { + onDeleteCalendar(aCalendar, aStatus, aDetail) { + self.mCachedCalendar = null; + }, + }; + + this.mCachedCalendar + .QueryInterface(Ci.calICalendarProvider) + .deleteCalendar(this.mCachedCalendar, listener); + } + }, + + setupCachedCalendar() { + try { + if (this.mCachedCalendar) { + // this is actually a resetupCachedCalendar: + // Although this doesn't really follow the spec, we know the + // storage calendar's deleteCalendar method is synchronous. + // TODO put changes into a different calendar and delete + // afterwards. + this.mCachedCalendar + .QueryInterface(Ci.calICalendarProvider) + .deleteCalendar(this.mCachedCalendar, null); + if (this.supportsChangeLog) { + // start with full sync: + this.mUncachedCalendar.resetLog(); + } + } else { + let calType = Services.prefs.getStringPref("calendar.cache.type", "storage"); + // While technically, the above deleteCalendar should delete the + // whole calendar, this is nothing more than deleting all events + // todos and properties. Therefore the initialization can be + // skipped. + let cachedCalendar = Cc["@mozilla.org/calendar/calendar;1?type=" + calType].createInstance( + Ci.calICalendar + ); + switch (calType) { + case "memory": { + if (this.supportsChangeLog) { + // start with full sync: + this.mUncachedCalendar.resetLog(); + } + break; + } + case "storage": { + let file = cal.provider.getCalendarDirectory(); + file.append("cache.sqlite"); + cachedCalendar.uri = Services.io.newFileURI(file); + cachedCalendar.id = this.id; + break; + } + default: { + throw new Error("unsupported cache calendar type: " + calType); + } + } + cachedCalendar.transientProperties = true; + // Forward the disabled property to the storage calendar so that it + // stops interacting with the file system. Other properties have no + // useful effect on the storage calendar, so don't forward them. + cachedCalendar.setProperty("disabled", this.getProperty("disabled")); + cachedCalendar.setProperty("relaxedMode", true); + cachedCalendar.superCalendar = this; + if (!this.mCachedObserver) { + this.mCachedObserver = new calCachedCalendarObserverHelper(this, true); + } + cachedCalendar.addObserver(this.mCachedObserver); + this.mCachedCalendar = cachedCalendar; + } + } catch (exc) { + console.error(exc); + } + }, + + async getOfflineAddedItems() { + this.offlineCachedItems = {}; + for await (let items of cal.iterate.streamValues( + this.mCachedCalendar.getItems( + calICalendar.ITEM_FILTER_ALL_ITEMS | calICalendar.ITEM_FILTER_OFFLINE_CREATED, + 0, + null, + null + ) + )) { + for (let item of items) { + this.offlineCachedItems[item.hashId] = item; + this.offlineCachedItemFlags[item.hashId] = cICL.OFFLINE_FLAG_CREATED_RECORD; + } + } + }, + + async getOfflineModifiedItems() { + for await (let items of cal.iterate.streamValues( + this.mCachedCalendar.getItems( + calICalendar.ITEM_FILTER_OFFLINE_MODIFIED | calICalendar.ITEM_FILTER_ALL_ITEMS, + 0, + null, + null + ) + )) { + for (let item of items) { + this.offlineCachedItems[item.hashId] = item; + this.offlineCachedItemFlags[item.hashId] = cICL.OFFLINE_FLAG_MODIFIED_RECORD; + } + } + }, + + async getOfflineDeletedItems() { + for await (let items of cal.iterate.streamValues( + this.mCachedCalendar.getItems( + calICalendar.ITEM_FILTER_OFFLINE_DELETED | calICalendar.ITEM_FILTER_ALL_ITEMS, + 0, + null, + null + ) + )) { + for (let item of items) { + this.offlineCachedItems[item.hashId] = item; + this.offlineCachedItemFlags[item.hashId] = cICL.OFFLINE_FLAG_DELETED_RECORD; + } + } + }, + + mPendingSync: null, + async synchronize() { + if (!this.mPendingSync) { + this.mPendingSync = this._doSynchronize().catch(console.error); + } + return this.mPendingSync; + }, + async _doSynchronize() { + let clearPending = () => { + this.mPendingSync = null; + }; + + if (this.getProperty("disabled")) { + clearPending(); + return; + } + + if (this.offline) { + clearPending(); + return; + } + + if (this.supportsChangeLog) { + await new Promise((resolve, reject) => { + let spec = this.uri.spec; + cal.LOG("[calCachedCalendar] Doing changelog based sync for calendar " + spec); + let opListener = { + onResult(operation, result) { + if (!operation || !operation.isPending) { + let status = operation ? operation.status : Cr.NS_OK; + clearPending(); + if (!Components.isSuccessCode(status)) { + reject( + "[calCachedCalendar] replay action failed: " + + (operation && operation.id ? operation.id : "<unknown>") + + ", uri=" + + spec + + ", result=" + + result + + ", operation=" + + operation + ); + return; + } + cal.LOG("[calCachedCalendar] replayChangesOn finished."); + resolve(); + } + }, + }; + this.mUncachedCalendar.replayChangesOn(opListener); + }); + return; + } + + cal.LOG("[calCachedCalendar] Doing full sync for calendar " + this.uri.spec); + + await this.getOfflineAddedItems(); + await this.getOfflineModifiedItems(); + await this.getOfflineDeletedItems(); + + // TODO instead of deleting the calendar and creating a new + // one, maybe we want to do a "real" sync between the + // existing local calendar and the remote calendar. + this.setupCachedCalendar(); + + let modifiedTimes = {}; + try { + for await (let items of cal.iterate.streamValues( + this.mUncachedCalendar.getItems(Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, 0, null, null) + )) { + for (let item of items) { + // Adding items recd from the Memory Calendar + // These may be different than what the cache has + modifiedTimes[item.id] = item.lastModifiedTime; + this.mCachedCalendar.addItem(item); + } + } + } catch (e) { + await this.playbackOfflineItems(); + this.mCachedObserver.onLoad(this.mCachedCalendar); + clearPending(); + throw e; // Do not swallow this error. + } + + await new Promise((resolve, reject) => { + cal.iterate.forEach( + this.offlineCachedItems, + item => { + switch (this.offlineCachedItemFlags[item.hashId]) { + case cICL.OFFLINE_FLAG_CREATED_RECORD: + // Created items are not present on the server, so its safe to adopt them + this.adoptOfflineItem(item.clone()); + break; + case cICL.OFFLINE_FLAG_MODIFIED_RECORD: + // Two Cases Here: + if (item.id in modifiedTimes) { + // The item is still on the server, we just retrieved it in the listener above. + if (item.lastModifiedTime.compare(modifiedTimes[item.id]) < 0) { + // The item on the server has been modified, ask to overwrite + cal.WARN( + "[calCachedCalendar] Item '" + + item.title + + "' at the server seems to be modified recently." + ); + this.promptOverwrite("modify", item, null); + } else { + // Our item is newer, just modify the item + this.modifyOfflineItem(item, null); + } + } else { + // The item has been deleted from the server, ask if it should be added again + cal.WARN( + "[calCachedCalendar] Item '" + item.title + "' has been deleted from the server" + ); + if (cal.provider.promptOverwrite("modify", item, null)) { + this.adoptOfflineItem(item.clone()); + } + } + break; + case cICL.OFFLINE_FLAG_DELETED_RECORD: + if (item.id in modifiedTimes) { + // The item seems to exist on the server... + if (item.lastModifiedTime.compare(modifiedTimes[item.id]) < 0) { + // ...and has been modified on the server. Ask to overwrite + cal.WARN( + "[calCachedCalendar] Item '" + + item.title + + "' at the server seems to be modified recently." + ); + this.promptOverwrite("delete", item, null); + } else { + // ...and has not been modified. Delete it now. + this.deleteOfflineItem(item); + } + } else { + // Item has already been deleted from the server, no need to change anything. + } + break; + } + }, + async () => { + this.offlineCachedItems = {}; + this.offlineCachedItemFlags = {}; + await this.playbackOfflineItems(); + clearPending(); + resolve(); + } + ); + }); + }, + + onOfflineStatusChanged(aNewState) { + if (aNewState) { + // Going offline: (XXX get items before going offline?) => we may ask the user to stay online a bit longer + } else if (!this.getProperty("disabled") && this.getProperty("refreshInterval") != "0") { + // Going online (start replaying changes to the remote calendar). + // Don't do this if the calendar is disabled or set to manual updates only. + this.refresh(); + } + }, + + // aOldItem is already in the cache + async promptOverwrite(aMethod, aItem, aOldItem) { + let overwrite = cal.provider.promptOverwrite(aMethod, aItem); + if (overwrite) { + if (aMethod == "modify") { + await this.modifyOfflineItem(aItem, aOldItem); + } else { + await this.deleteOfflineItem(aItem); + } + } + }, + + /* + * Asynchronously performs playback operations of items added, modified, or deleted offline + * + * @param aPlaybackType (optional) The starting operation type. This function will be + * called recursively through playback operations in the order of + * add, modify, delete. By default playback will start with the add + * operation. Valid values for this parameter are defined as + * OFFLINE_FLAG_XXX constants in the calIChangeLog interface. + */ + async playbackOfflineItems(aPlaybackType) { + let self = this; + let storage = this.mCachedCalendar.QueryInterface(Ci.calIOfflineStorage); + + let itemQueue = []; + let debugOp; + let nextCallback; + let uncachedOp; + let filter; + + aPlaybackType = aPlaybackType || cICL.OFFLINE_FLAG_CREATED_RECORD; + switch (aPlaybackType) { + case cICL.OFFLINE_FLAG_CREATED_RECORD: + debugOp = "add"; + nextCallback = this.playbackOfflineItems.bind(this, cICL.OFFLINE_FLAG_MODIFIED_RECORD); + uncachedOp = item => this.mUncachedCalendar.addItem(item); + filter = calICalendar.ITEM_FILTER_OFFLINE_CREATED; + break; + case cICL.OFFLINE_FLAG_MODIFIED_RECORD: + debugOp = "modify"; + nextCallback = this.playbackOfflineItems.bind(this, cICL.OFFLINE_FLAG_DELETED_RECORD); + uncachedOp = item => this.mUncachedCalendar.modifyItem(item, item); + filter = calICalendar.ITEM_FILTER_OFFLINE_MODIFIED; + break; + case cICL.OFFLINE_FLAG_DELETED_RECORD: + debugOp = "delete"; + uncachedOp = item => this.mUncachedCalendar.deleteItem(item); + filter = calICalendar.ITEM_FILTER_OFFLINE_DELETED; + break; + default: + cal.ERROR("[calCachedCalendar] Invalid playback type: " + aPlaybackType); + return; + } + + async function popItemQueue() { + if (!itemQueue || itemQueue.length == 0) { + // no items left in the queue, move on to the next operation + if (nextCallback) { + await nextCallback(); + } + } else { + // perform operation on the next offline item in the queue + let item = itemQueue.pop(); + let error = null; + try { + await uncachedOp(item); + } catch (e) { + error = e; + cal.ERROR( + "[calCachedCalendar] Could not perform playback operation " + + debugOp + + " for item " + + (item.title || " (none) ") + + ": " + + e + ); + } + if (!error) { + if (aPlaybackType == cICL.OFFLINE_FLAG_DELETED_RECORD) { + self.mCachedCalendar.deleteItem(item); + } else { + storage.resetItemOfflineFlag(item); + } + } else { + // If the playback action could not be performed, then there + // is no need for further action. The item still has the + // offline flag, so it will be taken care of next time. + cal.WARN( + "[calCachedCalendar] Unable to perform playback action " + + debugOp + + " to the server, will try again next time (" + + item.id + + "," + + error + + ")" + ); + } + + // move on to the next item in the queue + await popItemQueue(); + } + } + + itemQueue = itemQueue.concat( + await this.mCachedCalendar.getItemsAsArray( + calICalendar.ITEM_FILTER_ALL_ITEMS | filter, + 0, + null, + null + ) + ); + + if (this.offline) { + cal.LOG("[calCachedCalendar] back to offline mode, reconciliation aborted"); + } else { + cal.LOG( + "[calCachedCalendar] Performing playback operation " + + debugOp + + " on " + + itemQueue.length + + " items to " + + self.name + ); + // start the first operation + await popItemQueue(); + } + }, + + get superCalendar() { + return (this.mSuperCalendar && this.mSuperCalendar.superCalendar) || this; + }, + set superCalendar(val) { + this.mSuperCalendar = val; + }, + + get offline() { + return Services.io.offline; + }, + get supportsChangeLog() { + return cal.wrapInstance(this.mUncachedCalendar, Ci.calIChangeLog) != null; + }, + + get canRefresh() { + // enable triggering sync using the reload button + return true; + }, + + get supportsScheduling() { + return this.mUncachedCalendar.supportsScheduling; + }, + + getSchedulingSupport() { + return this.mUncachedCalendar.getSchedulingSupport(); + }, + + getProperty(aName) { + switch (aName) { + case "cache.enabled": + if (this.mUncachedCalendar.getProperty("cache.always")) { + return true; + } + break; + } + + return this.mUncachedCalendar.getProperty(aName); + }, + setProperty(aName, aValue) { + if (aName == "disabled") { + // Forward the disabled property to the storage calendar so that it + // stops interacting with the file system. Other properties have no + // useful effect on the storage calendar, so don't forward them. + this.mCachedCalendar.setProperty(aName, aValue); + } + this.mUncachedCalendar.setProperty(aName, aValue); + }, + async refresh() { + if (this.offline) { + this.downstreamRefresh(); + } else if (this.supportsChangeLog) { + /* we first ensure that any remaining offline items are reconciled with the calendar server */ + await this.playbackOfflineItems(); + await this.downstreamRefresh(); + } else { + this.downstreamRefresh(); + } + }, + async downstreamRefresh() { + if (this.mUncachedCalendar.canRefresh && !this.offline) { + this.mUncachedCalendar.refresh(); // will trigger synchronize once the calendar is loaded + return; + } + await this.synchronize(); + // fire completing onLoad for this refresh call + this.mCachedObserver.onLoad(this.mCachedCalendar); + }, + + addObserver(aObserver) { + this.mObservers.add(aObserver); + }, + removeObserver(aObserver) { + this.mObservers.delete(aObserver); + }, + + async addItem(item) { + return this.adoptItem(item.clone()); + }, + + async adoptItem(item) { + return new Promise((resolve, reject) => { + this.doAdoptItem(item, (calendar, status, opType, id, detail) => { + if (!Components.isSuccessCode(status)) { + return reject(new Components.Exception(detail, status)); + } + return resolve(detail); + }); + }); + }, + + /** + * The function form of calIOperationListener.onOperationComplete used where + * the whole interface is not needed. + * + * @callback OnOperationCompleteHandler + * + * @param {calICalendar} calendar + * @param {number} status + * @param {number} operationType + * @param {string} id + * @param {calIItem|Error} detail + */ + + /** + * Keeps track of pending callbacks injected into the uncached calendar during + * adopt or modify operations. This is done to ensure we remove the correct + * callback when multiple operations occur at once. + * + * @type {OnOperationComplateHandler[]} + */ + _injectedCallbacks: [], + + /** + * Executes the actual addition of the item using either the cached or uncached + * calendar depending on offline state. A separate method is used here to + * preserve the order of the "onAddItem" event. + * + * @param {calIItem} item + * @param {OnOperationCompleteHandler} handler + */ + doAdoptItem(item, listener) { + // Forwarding add/modify/delete to the cached calendar using the calIObserver + // callbacks would be advantageous, because the uncached provider could implement + // a true push mechanism firing without being triggered from within the program. + // But this would mean the uncached provider fires on the passed + // calIOperationListener, e.g. *before* it fires on calIObservers + // (because that order is undefined). Firing onOperationComplete before onAddItem et al + // would result in this facade firing onOperationComplete even though the modification + // hasn't yet been performed on the cached calendar (which happens in onAddItem et al). + // Result is that we currently stick to firing onOperationComplete if the cached calendar + // has performed the modification, see below: + + let onSuccess = item => listener(item.calendar, Cr.NS_OK, cIOL.ADD, item.id, item); + let onError = e => listener(null, e.result || Cr.NS_ERROR_FAILURE, null, null, e); + + if (this.offline) { + // If we are offline, don't even try to add the item + this.adoptOfflineItem(item).then(onSuccess, onError); + } else { + // Otherwise ask the provider to add the item now. + + // Expected to be called in the context of the uncached calendar's adoptItem() + // so this adoptItem() call returns first. This is a needed hack to keep the + // cached calendar's "onAddItem" event firing before the endBatch() call of + // the uncached calendar. + let adoptItemCallback = async (calendar, status, opType, id, detail) => { + if (isUnavailableCode(status)) { + // The item couldn't be added to the (remote) location, + // this is like being offline. Add the item to the cached + // calendar instead. + cal.LOG( + "[calCachedCalendar] Calendar " + calendar.name + " is unavailable, adding item offline" + ); + await this.adoptOfflineItem(item).then(onSuccess, onError); + } else if (Components.isSuccessCode(status)) { + // On success, add the item to the cache. + await this.mCachedCalendar.addItem(detail).then(onSuccess, onError); + } else { + // Either an error occurred or this is a successful add + // to a cached calendar. Forward the call to the listener + listener(this, status, opType, id, detail); + } + this.mUncachedCalendar.wrappedJSObject._cachedAdoptItemCallback = null; + this._injectedCallbacks = this._injectedCallbacks.filter(cb => cb != adoptItemCallback); + }; + + // Store the callback so we can remove the correct one later. + this._injectedCallbacks.push(adoptItemCallback); + + this.mUncachedCalendar.wrappedJSObject._cachedAdoptItemCallback = adoptItemCallback; + this.mUncachedCalendar.adoptItem(item).catch(e => { + adoptItemCallback(null, e.result || Cr.NS_ERROR_FAILURE, null, null, e); + }); + } + }, + + /** + * Adds an item to the cached (storage) calendar. + * + * @param {calIItem} item + * @returns {calIItem} + */ + async adoptOfflineItem(item) { + let adoptedItem = await this.mCachedCalendar.adoptItem(item); + await this.mCachedCalendar.QueryInterface(Ci.calIOfflineStorage).addOfflineItem(adoptedItem); + return adoptedItem; + }, + + async modifyItem(newItem, oldItem) { + return new Promise((resolve, reject) => { + this.doModifyItem(newItem, oldItem, (calendar, status, opType, id, detail) => { + if (!Components.isSuccessCode(status)) { + return reject(new Components.Exception(detail, status)); + } + return resolve(detail); + }); + }); + }, + + /** + * Executes the actual modification of the item using either the cached or + * uncached calendar depending on offline state. A separate method is used here + * to preserve the order of the "onModifyItem" event. + * + * @param {calIItem} newItem + * @param {calIItem} oldItem + * @param {OnOperationCompleteHandler} handler + */ + doModifyItem(newItem, oldItem, listener) { + let onSuccess = item => listener(item.calendar, Cr.NS_OK, cIOL.MODIFY, item.id, item); + let onError = e => listener(null, e.result || Cr.NS_ERROR_FAILURE, null, null, e); + + // Forwarding add/modify/delete to the cached calendar using the calIObserver + // callbacks would be advantageous, because the uncached provider could implement + // a true push mechanism firing without being triggered from within the program. + // But this would mean the uncached provider fires on the passed + // calIOperationListener, e.g. *before* it fires on calIObservers + // (because that order is undefined). Firing onOperationComplete before onAddItem et al + // would result in this facade firing onOperationComplete even though the modification + // hasn't yet been performed on the cached calendar (which happens in onAddItem et al). + // Result is that we currently stick to firing onOperationComplete if the cached calendar + // has performed the modification, see below: */ + + // Expected to be called in the context of the uncached calendar's modifyItem() + // so this modifyItem() call returns first. This is a needed hack to keep the + // cached calendar's "onModifyItem" event firing before the endBatch() call of + // the uncached calendar. + let modifyItemCallback = async (calendar, status, opType, id, detail) => { + // Returned Promise only available through wrappedJSObject. + if (isUnavailableCode(status)) { + // The item couldn't be modified at the (remote) location, + // this is like being offline. Add the item to the cache + // instead. + cal.LOG( + "[calCachedCalendar] Calendar " + + calendar.name + + " is unavailable, modifying item offline" + ); + await this.modifyOfflineItem(newItem, oldItem).then(onSuccess, onError); + } else if (Components.isSuccessCode(status)) { + // On success, modify the item in the cache + await this.mCachedCalendar.modifyItem(detail, oldItem).then(onSuccess, onError); + } else { + // This happens on error, forward the error through the listener + listener(this, status, opType, id, detail); + } + this._injectedCallbacks = this._injectedCallbacks.filter(cb => cb != modifyItemCallback); + }; + + // First of all, we should find out if the item to modify is + // already an offline item or not. + if (this.offline) { + // If we are offline, don't even try to modify the item + this.modifyOfflineItem(newItem, oldItem).then(onSuccess, onError); + } else { + // Otherwise, get the item flags and further process the item. + this.mCachedCalendar.getItemOfflineFlag(oldItem).then(offline_flag => { + if ( + offline_flag == cICL.OFFLINE_FLAG_CREATED_RECORD || + offline_flag == cICL.OFFLINE_FLAG_MODIFIED_RECORD + ) { + // The item is already offline, just modify it in the cache + this.modifyOfflineItem(newItem, oldItem).then(onSuccess, onError); + } else { + // Not an offline item, attempt to modify using provider + + // This is a needed hack to keep the cached calendar's "onModifyItem" event + // firing before the endBatch() call of the uncached calendar. It is called + // in mUncachedCalendar's modifyItem() method. + this.mUncachedCalendar.wrappedJSObject._cachedModifyItemCallback = modifyItemCallback; + + // Store the callback so we can remove the correct one later. + this._injectedCallbacks.push(modifyItemCallback); + + this.mUncachedCalendar.modifyItem(newItem, oldItem).catch(e => { + modifyItemCallback(null, e.result || Cr.NS_ERROR_FAILURE, null, null, e); + }); + } + }); + } + }, + + /** + * Modifies an item in the cached calendar. + * + * @param {calIItem} newItem + * @param {calIItem} oldItem + * @returns {calIItem} + */ + async modifyOfflineItem(newItem, oldItem) { + let modifiedItem = await this.mCachedCalendar.modifyItem(newItem, oldItem); + await this.mCachedCalendar + .QueryInterface(Ci.calIOfflineStorage) + .modifyOfflineItem(modifiedItem); + return modifiedItem; + }, + + async deleteItem(item) { + // First of all, we should find out if the item to delete is + // already an offline item or not. + if (this.offline) { + // If we are offline, don't even try to delete the item + await this.deleteOfflineItem(item); + } else { + // Otherwise, get the item flags, the listener will further + // process the item. + let offline_flag = await this.mCachedCalendar.getItemOfflineFlag(item); + if ( + offline_flag == cICL.OFFLINE_FLAG_CREATED_RECORD || + offline_flag == cICL.OFFLINE_FLAG_MODIFIED_RECORD + ) { + // The item is already offline, just mark it deleted it in + // the cache + await this.deleteOfflineItem(item); + } else { + try { + // Not an offline item, attempt to delete using provider + await this.mUncachedCalendar.deleteItem(item); + + // On success, delete the item from the cache + await this.mCachedCalendar.deleteItem(item); + + // Also, remove any meta data associated with the item + try { + this.mCachedCalendar.QueryInterface(Ci.calISyncWriteCalendar).deleteMetaData(item.id); + } catch (e) { + cal.LOG("[calCachedCalendar] Offline storage doesn't support metadata"); + } + } catch (e) { + if (isUnavailableCode(e.result)) { + // The item couldn't be deleted at the (remote) location, + // this is like being offline. Mark the item deleted in the + // cache instead. + cal.LOG( + "[calCachedCalendar] Calendar " + + item.calendar.name + + " is unavailable, deleting item offline" + ); + await this.deleteOfflineItem(item); + } + } + } + } + }, + + async deleteOfflineItem(item) { + /* We do not delete the item from the cache, as we will need it when reconciling the cache content and the server content. */ + return this.mCachedCalendar.QueryInterface(Ci.calIOfflineStorage).deleteOfflineItem(item); + }, +}; +(function () { + function defineForwards(proto, targetName, functions, getters, gettersAndSetters) { + function defineForwardGetter(attr) { + proto.__defineGetter__(attr, function () { + return this[targetName][attr]; + }); + } + function defineForwardGetterAndSetter(attr) { + defineForwardGetter(attr); + proto.__defineSetter__(attr, function (value) { + return (this[targetName][attr] = value); + }); + } + function defineForwardFunction(funcName) { + proto[funcName] = function (...args) { + let obj = this[targetName]; + return obj[funcName](...args); + }; + } + functions.forEach(defineForwardFunction); + getters.forEach(defineForwardGetter); + gettersAndSetters.forEach(defineForwardGetterAndSetter); + } + + defineForwards( + calCachedCalendar.prototype, + "mUncachedCalendar", + ["deleteProperty", "isInvitation", "getInvitedAttendee", "canNotify"], + ["providerID", "type", "aclManager", "aclEntry"], + ["id", "name", "uri", "readOnly"] + ); + defineForwards( + calCachedCalendar.prototype, + "mCachedCalendar", + ["getItem", "getItems", "getItemsAsArray", "startBatch", "endBatch"], + [], + [] + ); +})(); diff --git a/comm/calendar/base/src/calICSService-worker.js b/comm/calendar/base/src/calICSService-worker.js new file mode 100644 index 0000000000..83d8c2c088 --- /dev/null +++ b/comm/calendar/base/src/calICSService-worker.js @@ -0,0 +1,21 @@ +/* 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/. */ + +/** + * ChromeWorker for parseICSAsync method in CalICSService.jsm + */ + +/* eslint-env worker */ +/* import-globals-from ../modules/Ical.jsm */ + +// eslint-disable-next-line no-unused-vars +importScripts("resource:///modules/calendar/Ical.jsm"); + +ICAL.design.strict = false; + +self.onmessage = function (event) { + let comp = ICAL.parse(event.data); + postMessage(comp); + self.close(); +}; diff --git a/comm/calendar/base/src/calInternalInterfaces.idl b/comm/calendar/base/src/calInternalInterfaces.idl new file mode 100644 index 0000000000..339b935448 --- /dev/null +++ b/comm/calendar/base/src/calInternalInterfaces.idl @@ -0,0 +1,29 @@ +/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +/** Don't use these if you're not the calendar glue code! **/ + +#include "nsISupports.idl" + +interface calIItemBase; +interface calIDateTime; + +[scriptable, uuid(1903648f-a0ee-4ae1-84b0-d8e8d0b10506)] +interface calIInternalShallowCopy : nsISupports +{ + /** + * create a proxy for this item; the returned item + * proxy will have parentItem set to this instance. + * + * @param aRecurrenceId RECURRENCE-ID of the proxy to be created + */ + calIItemBase createProxy(in calIDateTime aRecurrenceId); + + // used by recurrenceInfo when cloning proxy objects to + // avoid an infinite loop. aNewParent is optional, and is + // used to set the parent of the new item; it should be null + // if no new parent is passed in. + calIItemBase cloneShallow(in calIItemBase aNewParent); +}; diff --git a/comm/calendar/base/src/calItemBase.js b/comm/calendar/base/src/calItemBase.js new file mode 100644 index 0000000000..d1cd1599e5 --- /dev/null +++ b/comm/calendar/base/src/calItemBase.js @@ -0,0 +1,1198 @@ +/* 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/. */ + +/* exported makeMemberAttr, makeMemberAttrProperty */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { CalAttendee } = ChromeUtils.import("resource:///modules/CalAttendee.jsm"); +var { CalRelation } = ChromeUtils.import("resource:///modules/CalRelation.jsm"); +var { CalAttachment } = ChromeUtils.import("resource:///modules/CalAttachment.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAlarm: "resource:///modules/CalAlarm.jsm", + CalDateTime: "resource:///modules/CalDateTime.jsm", + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gParserUtils", + "@mozilla.org/parserutils;1", + "nsIParserUtils" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gTextToHtmlConverter", + "@mozilla.org/txttohtmlconv;1", + "mozITXTToHTMLConv" +); + +/** + * calItemBase prototype definition + * + * @implements calIItemBase + * @class + */ +function calItemBase() { + cal.ASSERT(false, "Inheriting objects call initItemBase()!"); +} + +calItemBase.prototype = { + mProperties: null, + mPropertyParams: null, + + mIsProxy: false, + mHashId: null, + mImmutable: false, + mDirty: false, + mCalendar: null, + mParentItem: null, + mRecurrenceInfo: null, + mOrganizer: null, + + mAlarms: null, + mAlarmLastAck: null, + + mAttendees: null, + mAttachments: null, + mRelations: null, + mCategories: null, + + mACLEntry: null, + + /** + * Initialize the base item's attributes. Can be called from inheriting + * objects in their constructor. + */ + initItemBase() { + this.wrappedJSObject = this; + this.mProperties = new Map(); + this.mPropertyParams = {}; + this.setProperty("CREATED", cal.dtz.jsDateToDateTime(new Date())); + }, + + /** + * @see nsISupports + */ + QueryInterface: ChromeUtils.generateQI(["calIItemBase"]), + + /** + * @see calIItemBase + */ + get aclEntry() { + let aclEntry = this.mACLEntry; + let aclManager = this.calendar && this.calendar.superCalendar.aclManager; + + if (!aclEntry && aclManager) { + this.mACLEntry = aclManager.getItemEntry(this); + aclEntry = this.mACLEntry; + } + + if (!aclEntry && this.parentItem != this) { + // No ACL entry on this item, check the parent + aclEntry = this.parentItem.aclEntry; + } + + return aclEntry; + }, + + // readonly attribute AUTF8String hashId; + get hashId() { + if (this.mHashId === null) { + let rid = this.recurrenceId; + let calendar = this.calendar; + // some unused delim character: + this.mHashId = [ + encodeURIComponent(this.id), + rid ? rid.getInTimezone(cal.dtz.UTC).icalString : "", + calendar ? encodeURIComponent(calendar.id) : "", + ].join("#"); + } + return this.mHashId; + }, + + // attribute AUTF8String id; + get id() { + return this.getProperty("UID"); + }, + set id(uid) { + this.mHashId = null; // recompute hashId + this.setProperty("UID", uid); + if (this.mRecurrenceInfo) { + this.mRecurrenceInfo.onIdChange(uid); + } + }, + + // attribute calIDateTime recurrenceId; + get recurrenceId() { + return this.getProperty("RECURRENCE-ID"); + }, + set recurrenceId(rid) { + this.mHashId = null; // recompute hashId + this.setProperty("RECURRENCE-ID", rid); + }, + + // attribute calIRecurrenceInfo recurrenceInfo; + get recurrenceInfo() { + return this.mRecurrenceInfo; + }, + set recurrenceInfo(value) { + this.modify(); + this.mRecurrenceInfo = cal.unwrapInstance(value); + }, + + // attribute calIItemBase parentItem; + get parentItem() { + return this.mParentItem || this; + }, + set parentItem(value) { + if (this.mImmutable) { + throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE); + } + this.mParentItem = cal.unwrapInstance(value); + }, + + /** + * Initializes the base item to be an item proxy. Used by inheriting + * objects createProxy() method. + * + * XXXdbo Explain proxy a bit better, either here or in + * calIInternalShallowCopy. + * + * @see calIInternalShallowCopy + * @param aParentItem The parent item to initialize the proxy on. + * @param aRecurrenceId The recurrence id to initialize the proxy for. + */ + initializeProxy(aParentItem, aRecurrenceId) { + this.mIsProxy = true; + + aParentItem = cal.unwrapInstance(aParentItem); + this.mParentItem = aParentItem; + this.mCalendar = aParentItem.mCalendar; + this.recurrenceId = aRecurrenceId; + + // Make sure organizer is unset, as the getter checks for this. + this.mOrganizer = undefined; + + this.mImmutable = aParentItem.mImmutable; + }, + + // readonly attribute boolean isMutable; + get isMutable() { + return !this.mImmutable; + }, + + /** + * This function should be called by all members that modify the item. It + * checks if the item is immutable and throws accordingly, and sets the + * mDirty property. + */ + modify() { + if (this.mImmutable) { + throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE); + } + this.mDirty = true; + }, + + /** + * Makes sure the item is not dirty. If the item is dirty, properties like + * LAST-MODIFIED and DTSTAMP are set to now. + */ + ensureNotDirty() { + if (this.mDirty) { + let now = cal.dtz.jsDateToDateTime(new Date()); + this.setProperty("LAST-MODIFIED", now); + this.setProperty("DTSTAMP", now); + this.mDirty = false; + } + }, + + /** + * Makes all properties of the base item immutable. Can be called by + * inheriting objects' makeImmutable method. + */ + makeItemBaseImmutable() { + if (this.mImmutable) { + return; + } + + // make all our components immutable + if (this.mRecurrenceInfo) { + this.mRecurrenceInfo.makeImmutable(); + } + + if (this.mOrganizer) { + this.mOrganizer.makeImmutable(); + } + if (this.mAttendees) { + for (let att of this.mAttendees) { + att.makeImmutable(); + } + } + + for (let propValue of this.mProperties.values()) { + if (propValue?.isMutable) { + propValue.makeImmutable(); + } + } + + if (this.mAlarms) { + for (let alarm of this.mAlarms) { + alarm.makeImmutable(); + } + } + + if (this.mAlarmLastAck) { + this.mAlarmLastAck.makeImmutable(); + } + + this.ensureNotDirty(); + this.mImmutable = true; + }, + + // boolean hasSameIds(in calIItemBase aItem); + hasSameIds(that) { + return ( + that && + this.id == that.id && + (this.recurrenceId == that.recurrenceId || // both null + (this.recurrenceId && + that.recurrenceId && + this.recurrenceId.compare(that.recurrenceId) == 0)) + ); + }, + + /** + * Overridden by CalEvent to indicate the item is an event. + */ + isEvent() { + return false; + }, + + /** + * Overridden by CalTodo to indicate the item is a todo. + */ + isTodo() { + return false; + }, + + // calIItemBase clone(); + clone() { + return this.cloneShallow(this.mParentItem); + }, + + /** + * Clones the base item's properties into the passed object, potentially + * setting a new parent item. + * + * @param m The item to clone this item into + * @param aNewParent (optional) The new parent item to set on m. + */ + cloneItemBaseInto(cloned, aNewParent) { + cloned.mImmutable = false; + cloned.mACLEntry = this.mACLEntry; + cloned.mIsProxy = this.mIsProxy; + cloned.mParentItem = cal.unwrapInstance(aNewParent) || this.mParentItem; + cloned.mHashId = this.mHashId; + cloned.mCalendar = this.mCalendar; + if (this.mRecurrenceInfo) { + cloned.mRecurrenceInfo = cal.unwrapInstance(this.mRecurrenceInfo.clone()); + cloned.mRecurrenceInfo.item = cloned; + } + + let org = this.organizer; + if (org) { + org = org.clone(); + } + cloned.mOrganizer = org; + + cloned.mAttendees = []; + for (let att of this.getAttendees()) { + cloned.mAttendees.push(att.clone()); + } + + cloned.mProperties = new Map(); + for (let [name, value] of this.mProperties.entries()) { + if (value instanceof CalDateTime || value instanceof Ci.calIDateTime) { + value = value.clone(); + } + + cloned.mProperties.set(name, value); + + let propBucket = this.mPropertyParams[name]; + if (propBucket) { + let newBucket = {}; + for (let param in propBucket) { + newBucket[param] = propBucket[param]; + } + cloned.mPropertyParams[name] = newBucket; + } + } + + cloned.mAttachments = []; + for (let att of this.getAttachments()) { + cloned.mAttachments.push(att.clone()); + } + + cloned.mRelations = []; + for (let rel of this.getRelations()) { + cloned.mRelations.push(rel.clone()); + } + + cloned.mCategories = this.getCategories(); + + cloned.mAlarms = []; + for (let alarm of this.getAlarms()) { + // Clone alarms into new item, assume the alarms from the old item + // are valid and don't need validation. + cloned.mAlarms.push(alarm.clone()); + } + + let alarmLastAck = this.alarmLastAck; + if (alarmLastAck) { + alarmLastAck = alarmLastAck.clone(); + } + cloned.mAlarmLastAck = alarmLastAck; + + cloned.mDirty = this.mDirty; + + return cloned; + }, + + // attribute calIDateTime alarmLastAck; + get alarmLastAck() { + return this.mAlarmLastAck; + }, + set alarmLastAck(aValue) { + this.modify(); + if (aValue && !aValue.timezone.isUTC) { + aValue = aValue.getInTimezone(cal.dtz.UTC); + } + this.mAlarmLastAck = aValue; + }, + + // readonly attribute calIDateTime lastModifiedTime; + get lastModifiedTime() { + this.ensureNotDirty(); + return this.getProperty("LAST-MODIFIED"); + }, + + // readonly attribute calIDateTime stampTime; + get stampTime() { + this.ensureNotDirty(); + return this.getProperty("DTSTAMP"); + }, + + // attribute AUTF8string descriptionText; + get descriptionText() { + return this.getProperty("DESCRIPTION"); + }, + + set descriptionText(text) { + this.setProperty("DESCRIPTION", text); + if (text) { + this.setPropertyParameter("DESCRIPTION", "ALTREP", null); + } // else: property parameter deleted by setProperty(..., null) + }, + + // attribute AUTF8string descriptionHTML; + get descriptionHTML() { + let altrep = this.getPropertyParameter("DESCRIPTION", "ALTREP"); + if (altrep?.startsWith("data:text/html,")) { + try { + return decodeURIComponent(altrep.slice("data:text/html,".length)); + } catch (ex) { + console.error(ex); + } + } + // Fallback: Upconvert the plaintext + let description = this.getProperty("DESCRIPTION"); + if (!description) { + return null; + } + let mode = Ci.mozITXTToHTMLConv.kStructPhrase | Ci.mozITXTToHTMLConv.kURLs; + description = gTextToHtmlConverter.scanTXT(description, mode); + return description.replace(/\r?\n/g, "<br>"); + }, + + set descriptionHTML(html) { + if (html) { + // We need to output a plaintext version of the description, even if we're + // using the ALTREP parameter. We use the "preformatted" option in case + // the HTML contains a <pre/> tag with newlines. + let mode = + Ci.nsIDocumentEncoder.OutputDropInvisibleBreak | + Ci.nsIDocumentEncoder.OutputLFLineBreak | + Ci.nsIDocumentEncoder.OutputPreformatted; + let text = gParserUtils.convertToPlainText(html, mode, 0); + + this.setProperty("DESCRIPTION", text); + + // If the text is non-empty, create a standard ALTREP representation of + // the description as HTML. + // N.B. There's logic in nsMsgCompose for determining if HTML is + // convertible to plaintext without losing formatting. We could test if we + // could leave this part off if we generalized that logic. + if (text) { + this.setPropertyParameter( + "DESCRIPTION", + "ALTREP", + "data:text/html," + encodeURIComponent(html) + ); + } + } else { + this.deleteProperty("DESCRIPTION"); + } + }, + + // Each inner array has two elements: a string and a nsIVariant. + // readonly attribute Array<Array<jsval> > properties; + get properties() { + let properties = this.mProperties; + if (this.mIsProxy) { + let parentProperties = this.mParentItem.wrappedJSObject.mProperties; + let thisProperties = this.mProperties; + properties = new Map( + (function* () { + yield* parentProperties; + yield* thisProperties; + })() + ); + } + + return [...properties.entries()]; + }, + + // nsIVariant getProperty(in AString name); + getProperty(aName) { + let name = aName.toUpperCase(); + if (this.mProperties.has(name)) { + return this.mProperties.get(name); + } + return this.mIsProxy ? this.mParentItem.getProperty(name) : null; + }, + + // boolean hasProperty(in AString name); + hasProperty(aName) { + return this.getProperty(aName) != null; + }, + + // void setProperty(in AString name, in nsIVariant value); + setProperty(aName, aValue) { + this.modify(); + aName = aName.toUpperCase(); + if (aValue || !isNaN(parseInt(aValue, 10))) { + this.mProperties.set(aName, aValue); + if (!(aName in this.mPropertyParams)) { + this.mPropertyParams[aName] = {}; + } + } else { + this.deleteProperty(aName); + } + if (aName == "LAST-MODIFIED") { + // setting LAST-MODIFIED cleans/undirties the item, we use this for preserving DTSTAMP + this.mDirty = false; + } + }, + + // void deleteProperty(in AString name); + deleteProperty(aName) { + this.modify(); + aName = aName.toUpperCase(); + if (this.mIsProxy) { + // deleting a proxy's property will mark the bag's item as null, so we could + // distinguish it when enumerating/getting properties from the undefined ones. + this.mProperties.set(aName, null); + } else { + this.mProperties.delete(aName); + } + delete this.mPropertyParams[aName]; + }, + + // AString getPropertyParameter(in AString aPropertyName, + // in AString aParameterName); + getPropertyParameter(aPropName, aParamName) { + let propName = aPropName.toUpperCase(); + let paramName = aParamName.toUpperCase(); + if (propName in this.mPropertyParams) { + if (paramName in this.mPropertyParams[propName]) { + // If the property is not in mPropertyParams, then this just means + // there are no properties set. + return this.mPropertyParams[propName][paramName]; + } + return null; + } + return this.mIsProxy ? this.mParentItem.getPropertyParameter(propName, paramName) : null; + }, + + // boolean hasPropertyParameter(in AString aPropertyName, + // in AString aParameterName); + hasPropertyParameter(aPropName, aParamName) { + return this.getPropertyParameter(aPropName, aParamName) != null; + }, + + // void setPropertyParameter(in AString aPropertyName, + // in AString aParameterName, + // in AUTF8String aParameterValue); + setPropertyParameter(aPropName, aParamName, aParamValue) { + let propName = aPropName.toUpperCase(); + let paramName = aParamName.toUpperCase(); + this.modify(); + if (!(propName in this.mPropertyParams)) { + if (this.hasProperty(propName)) { + this.mPropertyParams[propName] = {}; + } else { + throw new Error("Property " + aPropName + " not set"); + } + } + if (aParamValue || !isNaN(parseInt(aParamValue, 10))) { + this.mPropertyParams[propName][paramName] = aParamValue; + } else { + delete this.mPropertyParams[propName][paramName]; + } + return aParamValue; + }, + + // Array<AString> getParameterNames(in AString aPropertyName); + getParameterNames(aPropName) { + let propName = aPropName.toUpperCase(); + if (!(propName in this.mPropertyParams)) { + if (this.mIsProxy) { + return this.mParentItem.getParameterNames(aPropName); + } + throw new Error("Property " + aPropName + " not set"); + } + return Object.keys(this.mPropertyParams[propName]); + }, + + // Array<calIAttendee> getAttendees(); + getAttendees() { + if (!this.mAttendees && this.mIsProxy) { + this.mAttendees = this.mParentItem.getAttendees(); + } + if (this.mAttendees) { + return Array.from(this.mAttendees); // clone + } + return []; + }, + + // calIAttendee getAttendeeById(in AUTF8String id); + getAttendeeById(id) { + let attendees = this.getAttendees(); + let lowerCaseId = id.toLowerCase(); + for (let attendee of attendees) { + // This match must be case insensitive to deal with differing + // cases of things like MAILTO: + if (attendee.id.toLowerCase() == lowerCaseId) { + return attendee; + } + } + return null; + }, + + // void removeAttendee(in calIAttendee attendee); + removeAttendee(attendee) { + this.modify(); + let found = false, + newAttendees = []; + let attendees = this.getAttendees(); + let attIdLowerCase = attendee.id.toLowerCase(); + + for (let i = 0; i < attendees.length; i++) { + if (attendees[i].id.toLowerCase() == attIdLowerCase) { + found = true; + } else { + newAttendees.push(attendees[i]); + } + } + if (found) { + this.mAttendees = newAttendees; + } + }, + + // void removeAllAttendees(); + removeAllAttendees() { + this.modify(); + this.mAttendees = []; + }, + + // void addAttendee(in calIAttendee attendee); + addAttendee(attendee) { + if (!attendee.id) { + cal.LOG("Tried to add invalid attended"); + return; + } + // the duplicate check is migration code for bug 1204255 + let exists = this.getAttendeeById(attendee.id); + if (exists) { + cal.LOG( + "Ignoring attendee duplicate for item " + this.id + " (" + this.title + "): " + exists.id + ); + if ( + exists.participationStatus == "NEEDS-ACTION" || + attendee.participationStatus == "DECLINED" + ) { + this.removeAttendee(exists); + } else { + attendee = null; + } + } + if (attendee) { + if (attendee.commonName) { + // migration code for bug 1209399 to remove leading/training double quotes in + let commonName = attendee.commonName.replace(/^["]*([^"]*)["]*$/, "$1"); + if (commonName.length == 0) { + commonName = null; + } + if (commonName != attendee.commonName) { + if (attendee.isMutable) { + attendee.commonName = commonName; + } else { + cal.LOG( + "Failed to cleanup malformed commonName for immutable attendee " + + attendee.toString() + + "\n" + + cal.STACK(20) + ); + } + } + } + this.modify(); + this.mAttendees = this.getAttendees(); + this.mAttendees.push(attendee); + } + }, + + // Array<calIAttachment> getAttachments(); + getAttachments() { + if (!this.mAttachments && this.mIsProxy) { + this.mAttachments = this.mParentItem.getAttachments(); + } + if (this.mAttachments) { + return this.mAttachments.concat([]); // clone + } + return []; + }, + + // void removeAttachment(in calIAttachment attachment); + removeAttachment(aAttachment) { + this.modify(); + for (let attIndex in this.mAttachments) { + if (cal.data.compareObjects(this.mAttachments[attIndex], aAttachment, Ci.calIAttachment)) { + this.modify(); + this.mAttachments.splice(attIndex, 1); + break; + } + } + }, + + // void addAttachment(in calIAttachment attachment); + addAttachment(attachment) { + this.modify(); + this.mAttachments = this.getAttachments(); + if (!this.mAttachments.some(x => x.hashId == attachment.hashId)) { + this.mAttachments.push(attachment); + } + }, + + // void removeAllAttachments(); + removeAllAttachments() { + this.modify(); + this.mAttachments = []; + }, + + // Array<calIRelation> getRelations(); + getRelations() { + if (!this.mRelations && this.mIsProxy) { + this.mRelations = this.mParentItem.getRelations(); + } + if (this.mRelations) { + return this.mRelations.concat([]); + } + return []; + }, + + // void removeRelation(in calIRelation relation); + removeRelation(aRelation) { + this.modify(); + for (let attIndex in this.mRelations) { + // Could we have the same item as parent and as child ? + if ( + this.mRelations[attIndex].relId == aRelation.relId && + this.mRelations[attIndex].relType == aRelation.relType + ) { + this.modify(); + this.mRelations.splice(attIndex, 1); + break; + } + } + }, + + // void addRelation(in calIRelation relation); + addRelation(aRelation) { + this.modify(); + this.mRelations = this.getRelations(); + this.mRelations.push(aRelation); + // XXX ensure that the relation isn't already there? + }, + + // void removeAllRelations(); + removeAllRelations() { + this.modify(); + this.mRelations = []; + }, + + // attribute calICalendar calendar; + get calendar() { + if (!this.mCalendar && this.parentItem != this) { + return this.parentItem.calendar; + } + return this.mCalendar; + }, + set calendar(calendar) { + if (this.mImmutable) { + throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE); + } + this.mHashId = null; // recompute hashId + this.mCalendar = calendar; + }, + + // attribute calIAttendee organizer; + get organizer() { + if (this.mIsProxy && this.mOrganizer === undefined) { + return this.mParentItem.organizer; + } + return this.mOrganizer; + }, + set organizer(organizer) { + this.modify(); + this.mOrganizer = organizer; + }, + + // Array<AString> getCategories(); + getCategories() { + if (!this.mCategories && this.mIsProxy) { + this.mCategories = this.mParentItem.getCategories(); + } + if (this.mCategories) { + return this.mCategories.concat([]); // clone + } + return []; + }, + + // void setCategories(in Array<AString> aCategories); + setCategories(aCategories) { + this.modify(); + this.mCategories = aCategories.concat([]); + }, + + // attribute AUTF8String icalString; + get icalString() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + set icalString(str) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + /** + * The map of promoted properties is a list of those properties that are + * represented directly by getters/setters. + * All of these property names must be in upper case isPropertyPromoted to + * function correctly. The has/get/set/deleteProperty interfaces + * are case-insensitive, but these are not. + */ + itemBasePromotedProps: { + CREATED: true, + UID: true, + "LAST-MODIFIED": true, + SUMMARY: true, + PRIORITY: true, + STATUS: true, + DTSTAMP: true, + RRULE: true, + EXDATE: true, + RDATE: true, + ATTENDEE: true, + ATTACH: true, + CATEGORIES: true, + ORGANIZER: true, + "RECURRENCE-ID": true, + "X-MOZ-LASTACK": true, + "RELATED-TO": true, + }, + + /** + * A map of properties that need translation between the ical component + * property and their ICS counterpart. + */ + icsBasePropMap: [ + { cal: "CREATED", ics: "createdTime" }, + { cal: "LAST-MODIFIED", ics: "lastModified" }, + { cal: "DTSTAMP", ics: "stampTime" }, + { cal: "UID", ics: "uid" }, + { cal: "SUMMARY", ics: "summary" }, + { cal: "PRIORITY", ics: "priority" }, + { cal: "STATUS", ics: "status" }, + { cal: "RECURRENCE-ID", ics: "recurrenceId" }, + ], + + /** + * Walks through the propmap and sets all properties on this item from the + * given icalcomp. + * + * @param icalcomp The calIIcalComponent to read from. + * @param propmap The property map to walk through. + */ + mapPropsFromICS(icalcomp, propmap) { + for (let i = 0; i < propmap.length; i++) { + let prop = propmap[i]; + let val = icalcomp[prop.ics]; + if (val != null && val != Ci.calIIcalComponent.INVALID_VALUE) { + this.setProperty(prop.cal, val); + } + } + }, + + /** + * Walks through the propmap and sets all properties on the given icalcomp + * from the properties set on this item. + * given icalcomp. + * + * @param icalcomp The calIIcalComponent to write to. + * @param propmap The property map to walk through. + */ + mapPropsToICS(icalcomp, propmap) { + for (let i = 0; i < propmap.length; i++) { + let prop = propmap[i]; + let val = this.getProperty(prop.cal); + if (val != null && val != Ci.calIIcalComponent.INVALID_VALUE) { + icalcomp[prop.ics] = val; + } + } + }, + + /** + * Reads an ical component and sets up the base item's properties to match + * it. + * + * @param icalcomp The ical component to read. + */ + setItemBaseFromICS(icalcomp) { + this.modify(); + + // re-initializing from scratch -- no light proxy anymore: + this.mIsProxy = false; + this.mProperties = new Map(); + this.mPropertyParams = {}; + + this.mapPropsFromICS(icalcomp, this.icsBasePropMap); + + this.mAttendees = []; // don't inherit anything from parent + for (let attprop of cal.iterate.icalProperty(icalcomp, "ATTENDEE")) { + let att = new CalAttendee(); + att.icalProperty = attprop; + this.addAttendee(att); + } + + this.mAttachments = []; // don't inherit anything from parent + for (let attprop of cal.iterate.icalProperty(icalcomp, "ATTACH")) { + let att = new CalAttachment(); + att.icalProperty = attprop; + this.addAttachment(att); + } + + this.mRelations = []; // don't inherit anything from parent + for (let relprop of cal.iterate.icalProperty(icalcomp, "RELATED-TO")) { + let rel = new CalRelation(); + rel.icalProperty = relprop; + this.addRelation(rel); + } + + let org = null; + let orgprop = icalcomp.getFirstProperty("ORGANIZER"); + if (orgprop) { + org = new CalAttendee(); + org.icalProperty = orgprop; + org.isOrganizer = true; + } + this.mOrganizer = org; + + this.mCategories = []; + for (let catprop of cal.iterate.icalProperty(icalcomp, "CATEGORIES")) { + this.mCategories.push(catprop.value); + } + + // find recurrence properties + let rec = null; + if (!this.recurrenceId) { + for (let recprop of cal.iterate.icalProperty(icalcomp)) { + let ritem = null; + switch (recprop.propertyName) { + case "RRULE": + case "EXRULE": + ritem = cal.createRecurrenceRule(); + break; + case "RDATE": + case "EXDATE": + ritem = cal.createRecurrenceDate(); + break; + default: + continue; + } + ritem.icalProperty = recprop; + + if (!rec) { + rec = new CalRecurrenceInfo(this); + } + rec.appendRecurrenceItem(ritem); + } + } + this.mRecurrenceInfo = rec; + + this.mAlarms = []; // don't inherit anything from parent + for (let alarmComp of cal.iterate.icalSubcomponent(icalcomp, "VALARM")) { + let alarm = new CalAlarm(); + try { + alarm.icalComponent = alarmComp; + this.addAlarm(alarm, true); + } catch (e) { + cal.ERROR( + "Invalid alarm for item: " + + this.id + + " (" + + alarmComp.serializeToICS() + + ")" + + " exception: " + + e + ); + } + } + + let lastAck = icalcomp.getFirstProperty("X-MOZ-LASTACK"); + this.mAlarmLastAck = null; + if (lastAck) { + this.mAlarmLastAck = cal.createDateTime(lastAck.value); + } + + this.mDirty = false; + }, + + /** + * Import all properties not in the promoted map into this item's extended + * properties bag. + * + * @param icalcomp The ical component to read. + * @param promoted The map of promoted properties. + */ + importUnpromotedProperties(icalcomp, promoted) { + for (let prop of cal.iterate.icalProperty(icalcomp)) { + let propName = prop.propertyName; + if (!promoted[propName]) { + this.setProperty(propName, prop.value); + for (let [paramName, paramValue] of cal.iterate.icalParameter(prop)) { + if (!(propName in this.mPropertyParams)) { + this.mPropertyParams[propName] = {}; + } + this.mPropertyParams[propName][paramName] = paramValue; + } + } + } + }, + + // boolean isPropertyPromoted(in AString name); + isPropertyPromoted(name) { + return this.itemBasePromotedProps[name.toUpperCase()]; + }, + + // attribute calIIcalComponent icalComponent; + get icalComponent() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + set icalComponent(val) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + // attribute PRUint32 generation; + get generation() { + let gen = this.getProperty("X-MOZ-GENERATION"); + return gen ? parseInt(gen, 10) : 0; + }, + set generation(aValue) { + this.setProperty("X-MOZ-GENERATION", String(aValue)); + }, + + /** + * Fills the passed ical component with the base item's properties. + * + * @param icalcomp The ical component to write to. + */ + fillIcalComponentFromBase(icalcomp) { + this.ensureNotDirty(); + + this.mapPropsToICS(icalcomp, this.icsBasePropMap); + + let org = this.organizer; + if (org) { + icalcomp.addProperty(org.icalProperty); + } + + for (let attendee of this.getAttendees()) { + icalcomp.addProperty(attendee.icalProperty); + } + + for (let attachment of this.getAttachments()) { + icalcomp.addProperty(attachment.icalProperty); + } + + for (let relation of this.getRelations()) { + icalcomp.addProperty(relation.icalProperty); + } + + if (this.mRecurrenceInfo) { + for (let ritem of this.mRecurrenceInfo.getRecurrenceItems()) { + icalcomp.addProperty(ritem.icalProperty); + } + } + + for (let cat of this.getCategories()) { + let catprop = cal.icsService.createIcalProperty("CATEGORIES"); + catprop.value = cat; + icalcomp.addProperty(catprop); + } + + if (this.mAlarms) { + for (let alarm of this.mAlarms) { + icalcomp.addSubcomponent(alarm.icalComponent); + } + } + + let alarmLastAck = this.alarmLastAck; + if (alarmLastAck) { + let lastAck = cal.icsService.createIcalProperty("X-MOZ-LASTACK"); + // - should we further ensure that those are UTC or rely on calAlarmService doing so? + lastAck.value = alarmLastAck.icalString; + icalcomp.addProperty(lastAck); + } + }, + + // Array<calIAlarm> getAlarms(); + getAlarms() { + if (!this.mAlarms && this.mIsProxy) { + this.mAlarms = this.mParentItem.getAlarms(); + } + if (this.mAlarms) { + return this.mAlarms.concat([]); // clone + } + return []; + }, + + /** + * Adds an alarm. The second parameter is for internal use only, i.e not + * provided on the interface. + * + * @see calIItemBase + * @param aDoNotValidate Don't serialize the component to check for + * errors. + */ + addAlarm(aAlarm, aDoNotValidate) { + if (!aDoNotValidate) { + try { + // Trigger the icalComponent getter to make sure the alarm is valid. + aAlarm.icalComponent; // eslint-disable-line no-unused-expressions + } catch (e) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + } + + this.modify(); + this.mAlarms = this.getAlarms(); + this.mAlarms.push(aAlarm); + }, + + // void deleteAlarm(in calIAlarm aAlarm); + deleteAlarm(aAlarm) { + this.modify(); + this.mAlarms = this.getAlarms(); + for (let i = 0; i < this.mAlarms.length; i++) { + if (cal.data.compareObjects(this.mAlarms[i], aAlarm, Ci.calIAlarm)) { + this.mAlarms.splice(i, 1); + break; + } + } + }, + + // void clearAlarms(); + clearAlarms() { + this.modify(); + this.mAlarms = []; + }, + + // Array<calIItemBase> getOccurrencesBetween(in calIDateTime aStartDate, in calIDateTime aEndDate); + getOccurrencesBetween(aStartDate, aEndDate) { + if (this.recurrenceInfo) { + return this.recurrenceInfo.getOccurrences(aStartDate, aEndDate, 0); + } + + if (cal.item.checkIfInRange(this, aStartDate, aEndDate)) { + return [this]; + } + + return []; + }, +}; + +makeMemberAttrProperty(calItemBase, "CREATED", "creationDate"); +makeMemberAttrProperty(calItemBase, "SUMMARY", "title"); +makeMemberAttrProperty(calItemBase, "PRIORITY", "priority"); +makeMemberAttrProperty(calItemBase, "CLASS", "privacy"); +makeMemberAttrProperty(calItemBase, "STATUS", "status"); +makeMemberAttrProperty(calItemBase, "ALARMTIME", "alarmTime"); + +/** + * Adds a member attribute to the given prototype. + * + * @param {Function} ctor - The constructor function of the prototype. + * @param {string} varname - The variable name to get/set. + * @param {string} attr - The attribute name to be used. + * @param {*} dflt - The default value in case none is set. + */ +function makeMemberAttr(ctor, varname, attr, dflt) { + let getter = function () { + return varname in this ? this[varname] : dflt; + }; + let setter = function (value) { + this.modify(); + this[varname] = value; + return value; + }; + + ctor.prototype.__defineGetter__(attr, getter); + ctor.prototype.__defineSetter__(attr, setter); +} + +/** + * Adds a member attribute to the given prototype, using `getProperty` and + * `setProperty` for access. + * + * Default values are not handled here, but instead are set in constructors, + * which makes it possible to e.g. iterate through `mProperties` when cloning + * an object. + * + * @param {Function} ctor - The constructor function of the prototype. + * @param {string} name - The property name to get/set. + * @param {string} attr - The attribute name to be used. + */ +function makeMemberAttrProperty(ctor, name, attr) { + let getter = function () { + return this.getProperty(name); + }; + let setter = function (value) { + this.modify(); + return this.setProperty(name, value); + }; + ctor.prototype.__defineGetter__(attr, getter); + ctor.prototype.__defineSetter__(attr, setter); +} diff --git a/comm/calendar/base/src/components.conf b/comm/calendar/base/src/components.conf new file mode 100644 index 0000000000..b28918ca2d --- /dev/null +++ b/comm/calendar/base/src/components.conf @@ -0,0 +1,208 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/ + +Classes = [ + { + "cid": "{7463258c-6ef3-40a2-89a9-bb349596e927}", + "contract_ids": ["@mozilla.org/calendar/acl-manager;1?type=default"], + "jsm": "resource:///modules/CalDefaultACLManager.jsm", + "constructor": "CalDefaultACLManager", + }, + { + "cid": "{fcbadec9-aab7-49e2-a20a-8e93f41d68d1}", + "interfaces": ["calITimezoneDatabase"], + "contract_ids": ["@mozilla.org/calendar/timezone-database;1"], + "type": "TimezoneDatabase", + "headers": ["/comm/calendar/base/src/TimezoneDatabase.h"], + }, + { + "cid": "{e736f2bd-7640-4715-ab35-887dc866c587}", + "contract_ids": ["@mozilla.org/calendar/timezone-service;1"], + "jsm": "resource:///modules/CalTimezoneService.jsm", + "constructor": "CalTimezoneService", + }, + { + "cid": "{b8db7c7f-c168-4e11-becb-f26c1c4f5f8f}", + "contract_ids": ["@mozilla.org/calendar/alarm;1"], + "jsm": "resource:///modules/CalAlarm.jsm", + "constructor": "CalAlarm", + }, + { + "cid": "{4b7ae030-ed79-11d9-8cd6-0800200c9a66}", + "contract_ids": ["@mozilla.org/calendar/alarm-monitor;1"], + "jsm": "resource:///modules/CalAlarmMonitor.jsm", + "constructor": "CalAlarmMonitor", + }, + { + "cid": "{7a9200dd-6a64-4fff-a798-c5802186e2cc}", + "contract_ids": ["@mozilla.org/calendar/alarm-service;1"], + "jsm": "resource:///modules/CalAlarmService.jsm", + "constructor": "CalAlarmService", + }, + { + "cid": "{5f76b352-ab75-4c2b-82c9-9206dbbf8571}", + "contract_ids": ["@mozilla.org/calendar/attachment;1"], + "jsm": "resource:///modules/CalAttachment.jsm", + "constructor": "CalAttachment", + }, + { + "cid": "{5c8dcaa3-170c-4a73-8142-d531156f664d}", + "contract_ids": ["@mozilla.org/calendar/attendee;1"], + "jsm": "resource:///modules/CalAttendee.jsm", + "constructor": "CalAttendee", + }, + { + "cid": "{f42585e7-e736-4600-985d-9624c1c51992}", + "contract_ids": ["@mozilla.org/calendar/manager;1"], + "jsm": "resource:///modules/CalCalendarManager.jsm", + "constructor": "CalCalendarManager", + }, + { + "cid": "{36783242-ec94-4d8a-9248-d2679edd55b9}", + "contract_ids": ["@mozilla.org/calendar/datetime;1"], + "jsm": "resource:///modules/CalDateTime.jsm", + "constructor": "CalDateTime", + }, + { + "cid": "{8e6799af-e7e9-4e6c-9a82-a2413e86d8c3}", + "contract_ids": ["@mozilla.org/calendar/deleted-items-manager;1"], + "jsm": "resource:///modules/CalDeletedItems.jsm", + "constructor": "CalDeletedItems", + "categories": {"profile-after-change": "deleted-items-manager"}, + }, + { + "cid": "{7436f480-c6fc-4085-9655-330b1ee22288}", + "contract_ids": ["@mozilla.org/calendar/duration;1"], + "jsm": "resource:///modules/CalDuration.jsm", + "constructor": "CalDuration", + }, + { + "cid": "{974339d5-ab86-4491-aaaf-2b2ca177c12b}", + "contract_ids": ["@mozilla.org/calendar/event;1"], + "jsm": "resource:///modules/CalEvent.jsm", + "constructor": "CalEvent", + }, + { + "cid": "{29c56cd5-d36e-453a-acde-0083bd4fe6d3}", + "contract_ids": ["@mozilla.org/calendar/freebusy-service;1"], + "jsm": "resource:///modules/CalFreeBusyService.jsm", + "constructor": "CalFreeBusyService", + }, + { + "cid": "{6fe88047-75b6-4874-80e8-5f5800f14984}", + "contract_ids": ["@mozilla.org/calendar/ics-parser;1"], + "jsm": "resource:///modules/CalIcsParser.jsm", + "constructor": "CalIcsParser", + }, + { + "cid": "{207a6682-8ff1-4203-9160-729ec28c8766}", + "contract_ids": ["@mozilla.org/calendar/ics-serializer;1"], + "jsm": "resource:///modules/CalIcsSerializer.jsm", + "constructor": "CalIcsSerializer", + }, + { + "cid": "{c61cb903-4408-41b3-bc22-da0b27efdfe1}", + "contract_ids": ["@mozilla.org/calendar/ics-service;1"], + "jsm": "resource:///modules/CalICSService.jsm", + "constructor": "CalICSService", + }, + { + "cid": "{f41392ab-dcad-4bad-818f-b3d1631c4d93}", + "contract_ids": ["@mozilla.org/calendar/itip-item;1"], + "jsm": "resource:///modules/CalItipItem.jsm", + "constructor": "CalItipItem", + }, + { + "cid": "{394a281f-7299-45f7-8b1f-cce21258972f}", + "contract_ids": ["@mozilla.org/calendar/period;1"], + "jsm": "resource:///modules/CalPeriod.jsm", + "constructor": "CalPeriod", + }, + { + "cid": "{1153c73a-39be-46aa-9ba9-656d188865ca}", + "contract_ids": ["@mozilla.org/network/protocol;1?name=webcal"], + "jsm": "resource:///modules/CalProtocolHandler.jsm", + "constructor": "CalProtocolHandlerWebcal", + "protocol_config": { + "scheme": "webcal", + "flags": [ + "URI_STD", + "ALLOWS_PROXY", + "ALLOWS_PROXY_HTTP", + "URI_LOADABLE_BY_ANYONE", + "URI_IS_POTENTIALLY_TRUSTWORTHY", + ], + "default_port": 80, + }, + }, + { + "cid": "{bdf71224-365d-4493-856a-a7e74026f766}", + "contract_ids": ["@mozilla.org/network/protocol;1?name=webcals"], + "jsm": "resource:///modules/CalProtocolHandler.jsm", + "constructor": "CalProtocolHandlerWebcals", + "protocol_config": { + "scheme": "webcals", + "flags": [ + "URI_STD", + "ALLOWS_PROXY", + "ALLOWS_PROXY_HTTP", + "URI_LOADABLE_BY_ANYONE", + "URI_IS_POTENTIALLY_TRUSTWORTHY", + ], + "default_port": 443, + }, + }, + { + "cid": "{806b6423-3aaa-4b26-afa3-de60563e9cec}", + "contract_ids": ["@mozilla.org/calendar/recurrence-date;1"], + "jsm": "resource:///modules/CalRecurrenceDate.jsm", + "constructor": "CalRecurrenceDate", + }, + { + "cid": "{04027036-5884-4a30-b4af-f2cad79f6edf}", + "contract_ids": ["@mozilla.org/calendar/recurrence-info;1"], + "jsm": "resource:///modules/CalRecurrenceInfo.jsm", + "constructor": "CalRecurrenceInfo", + }, + { + "cid": "{df19281a-5389-4146-b941-798cb93a7f0d}", + "contract_ids": ["@mozilla.org/calendar/recurrence-rule;1"], + "jsm": "resource:///modules/CalRecurrenceRule.jsm", + "constructor": "CalRecurrenceRule", + }, + { + "cid": "{76810fae-abad-4019-917a-08e95d5bbd68}", + "contract_ids": ["@mozilla.org/calendar/relation;1"], + "jsm": "resource:///modules/CalRelation.jsm", + "constructor": "CalRelation", + }, + { + "cid": "{7af51168-6abe-4a31-984d-6f8a3989212d}", + "contract_ids": ["@mozilla.org/calendar/todo;1"], + "jsm": "resource:///modules/CalTodo.jsm", + "constructor": "CalTodo", + }, + { + "cid": "{6877bbdd-f336-46f5-98ce-fe86d0285cc1}", + "contract_ids": ["@mozilla.org/calendar/weekinfo-service;1"], + "jsm": "resource:///modules/CalWeekInfoService.jsm", + "constructor": "CalWeekInfoService", + }, + { + "cid": "{2547331f-34c0-4a4b-b93c-b503538ba6d6}", + "contract_ids": ["@mozilla.org/calendar/startup-service;1"], + "jsm": "resource:///modules/CalStartupService.jsm", + "constructor": "CalStartupService", + "categories": {"profile-after-change": "calendar-startup-service"}, + }, + { + "cid": "{c70acb08-464e-4e55-899d-b2c84c5409fa}", + "contract_ids": ["@mozilla.org/calendar/mime-converter;1"], + "jsm": "resource:///modules/CalMimeConverter.jsm", + "constructor": "CalMimeConverter", + "categories": {"simple-mime-converters": "text/calendar"}, + }, +] diff --git a/comm/calendar/base/src/moz.build b/comm/calendar/base/src/moz.build new file mode 100644 index 0000000000..350262f182 --- /dev/null +++ b/comm/calendar/base/src/moz.build @@ -0,0 +1,71 @@ +# vim: set filetype=python: +# 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/. + +XPIDL_SOURCES += [ + "calInternalInterfaces.idl", +] + +XPIDL_MODULE = "calbaseinternal" + +EXTRA_JS_MODULES += [ + "CalAlarm.jsm", + "CalAlarmMonitor.jsm", + "CalAlarmService.jsm", + "CalAttachment.jsm", + "CalAttendee.jsm", + "CalCalendarManager.jsm", + "CalDateTime.jsm", + "CalDefaultACLManager.jsm", + "CalDeletedItems.jsm", + "CalDuration.jsm", + "CalEvent.jsm", + "CalFreeBusyService.jsm", + "CalIcsParser.jsm", + "CalIcsSerializer.jsm", + "CalICSService.jsm", + "CalItipItem.jsm", + "CalMetronome.jsm", + "CalMimeConverter.jsm", + "CalPeriod.jsm", + "CalProtocolHandler.jsm", + "CalReadableStreamFactory.jsm", + "CalRecurrenceDate.jsm", + "CalRecurrenceInfo.jsm", + "CalRecurrenceRule.jsm", + "CalRelation.jsm", + "CalStartupService.jsm", + "CalTimezone.jsm", + "CalTimezoneService.jsm", + "CalTodo.jsm", + "CalTransactionManager.jsm", + "CalWeekInfoService.jsm", +] + +EXPORTS += [ + "TimezoneDatabase.h", +] + +UNIFIED_SOURCES += [ + "TimezoneDatabase.cpp", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +# These files go in components so they can be packaged correctly. +FINAL_TARGET_FILES.components += [ + "calCachedCalendar.js", + "calICSService-worker.js", + "calItemBase.js", +] + +with Files("**"): + BUG_COMPONENT = ("Calendar", "Internal Components") + +with Files("calAlarm*"): + BUG_COMPONENT = ("Calendar", "Alarms") + +FINAL_LIBRARY = "xul" |