VulpesOS/gaia/apps/calendar/js/service/caldav.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

1063 lines
27 KiB
JavaScript
Executable file

define(function(require, exports, module) {
'use strict';
var Caldav = require('ext/caldav');
var ICAL = require('ext/ical');
var IcalRecurExpansion = require('./ical_recur_expansion');
var Presets = require('common/presets');
var Responder = require('common/responder');
var debug = require('common/debug')('service/caldav');
var extend = require('common/extend');
var uuid = require('ext/uuid');
/* TODO: ugly hack to enable system XHR fix upstream in Caldav lib */
var xhrOpts = {
/** system is required for cross domain XHR */
mozSystem: true,
/** mozAnon is required to avoid system level popups on 401 status */
mozAnon: true,
/** enables use of mozilla only streaming api's when available */
useMozChunkedText: true
};
Caldav.Xhr.prototype.globalXhrOptions = xhrOpts;
function Service(service) {
Responder.call(this);
this.service = service;
this._initEvents();
}
module.exports = Service;
Service.prototype = {
__proto__: Responder.prototype,
/**
* See: http://tools.ietf.org/html/rfc5545#section-3.7.3
*/
icalProductId: '-//Mozilla//FirefoxOS',
/**
* See: http://tools.ietf.org/html/rfc5545#section-3.7.4
*/
icalVersion: '2.0',
_initEvents: function() {
var events = [
'noop',
'getAccount',
'findCalendars',
'getCalendar',
'streamEvents',
'expandComponents',
'expandRecurringEvent',
'deleteEvent',
'updateEvent',
'createEvent'
];
debug('Will listen for worker events...');
events.forEach(function(e) {
this.service.on(e, this);
}, this);
},
handleEvent: function(e) {
debug('Worker will fulfill', e.type, 'request...');
this[e.type].apply(this, e.data);
},
/**
* Builds an Caldav connection from an account model object.
*/
_createConnection: function(account) {
var params = extend({}, account);
var preset = Presets[account.preset];
if (
preset &&
preset.authenticationType &&
preset.apiCredentials
) {
switch (preset.authenticationType) {
case 'oauth2':
params.httpHandler = 'oauth2';
// shallow copy the apiCredentials on the preset
params.apiCredentials = extend({}, preset.apiCredentials);
// the url in this case will always be tokenUrl
params.apiCredentials.url = preset.apiCredentials.tokenUrl;
break;
}
}
var connection = new Caldav.Connection(params);
return connection;
},
_requestHome: function(connection, url) {
return new Caldav.Request.CalendarHome(
connection,
{ url: url }
);
},
_requestCalendars: function(connection, url) {
var Finder = Caldav.Request.Resources;
var Resource = Caldav.Resources.Calendar;
var req = new Finder(connection, { url: url });
req.addResource('calendar', Resource);
req.prop(['ical', 'calendar-color']);
req.prop(['caldav', 'calendar-description']);
req.prop('current-user-privilege-set');
req.prop('displayname');
req.prop('resourcetype');
req.prop(['calserver', 'getctag']);
return req;
},
_requestEvents: function(connection, cal, options) {
var Resource = Caldav.Resources.Calendar;
var remoteCal = new Resource(connection, cal);
var query = remoteCal.createQuery();
query.prop('getetag');
// only return VEVENT & VTIMEZONE
var filterQuery = query.filter.setComp('VCALENDAR');
var filterEvent = filterQuery.comp('VEVENT');
if (options && options.startDate) {
// convert startDate to unix ical time.
var icalDate = new ICAL.Time();
// ical uses seconds not milliseconds
icalDate.fromUnixTime(options.startDate.valueOf() / 1000);
filterEvent.setTimeRange({ start: icalDate.toICALString() });
}
// include only the VEVENT/VTIMEZONE in the data
query.data.setComp('VCALENDAR');
return query;
},
noop: function(callback) {
callback({ ready: true });
},
getAccount: function(account, callback) {
var url = account.entrypoint;
debug('Fetching account from:', url);
var connection = this._createConnection(account);
var request = this._requestHome(connection, url);
debug('Will issue calendar home request.');
return request.send(function(err, data) {
if (err) {
debug('Error sending home request:', err);
return callback(err);
}
debug('Received data:', data);
var result = {};
if (data.url) {
result.calendarHome = data.url;
}
if (connection.oauth) {
result.oauth = connection.oauth;
}
if (connection.user) {
result.user = connection.user;
}
callback(null, result);
});
},
_formatCalendar: function(cal) {
var result = Object.create(null);
result.id = cal.url;
result.url = cal.url;
result.name = cal.name;
result.color = cal.color;
result.description = cal.description;
result.syncToken = cal.ctag;
result.privilegeSet = cal.privilegeSet;
return result;
},
findCalendars: function(account, callback) {
var self = this;
var url = account.calendarHome;
var connection = this._createConnection(account);
var request = this._requestCalendars(
connection,
url
);
request.send(function(err, data) {
if (err) {
callback(err);
return;
}
var calendars = data.calendar;
var results = {};
var key;
var item;
for (key in calendars) {
if (calendars.hasOwnProperty(key)) {
item = calendars[key];
var formattedCal = self._formatCalendar(
item
);
// If privilegeSet is not present we will assume full permissions.
// Its highly unlikey that it is missing however.
if (('privilegeSet' in formattedCal) &&
(formattedCal.privilegeSet.indexOf('read') === -1)) {
// skip calendars without read permissions
continue;
}
results[key] = formattedCal;
}
}
callback(null, results);
});
},
/**
* Formats an alarm trigger
* Returns the relative time for that trigger
*
* @param {ICAL.Property} trigger property.
* @param {ICAL.Date} start date.
*/
_formatTrigger: function(trigger, startDate) {
var alarmTrigger;
if (trigger.type == 'duration') {
alarmTrigger = trigger.getFirstValue().toSeconds();
} else {
// Type is date-time
alarmTrigger = trigger
.getFirstValue()
.subtractDate(startDate)
.toSeconds();
}
return alarmTrigger;
},
/**
* Formats an already parsed ICAL.Event instance.
* Expects event to already contain exceptions, etc..
*
* @param {String} etag etag.
* @param {String} url caldav url.
* @param {String} ical raw ical string.
* @param {ICAL.Event} event ical event.
*/
_formatEvent: function(etag, url, ical, event) {
var self = this;
var exceptions = null;
var key;
if (event.exceptions) {
exceptions = [];
for (key in event.exceptions) {
exceptions.push(this._formatEvent(
etag,
url,
ical,
event.exceptions[key]
));
}
if (!exceptions.length) {
exceptions = null;
}
}
var rid = event.recurrenceId;
if (rid) {
rid = this.formatICALTime(rid);
}
var resultAlarms = [];
var alarms = event.component.getAllSubcomponents('valarm');
alarms.forEach(function(instance) {
var action = instance.getFirstPropertyValue('action');
if (action && action === 'DISPLAY') {
var triggers = instance.getAllProperties('trigger');
var i = 0;
var len = triggers.length;
for (; i < len; i++) {
var trigger = triggers[i];
resultAlarms.push({
action: action,
trigger: self._formatTrigger(trigger, event.startDate)
});
}
}
});
var result = {
alarms: resultAlarms,
syncToken: etag,
url: url,
id: event.uid,
title: event.summary,
recurrenceId: rid,
isRecurring: event.isRecurring(),
description: event.description,
location: event.location,
start: this.formatICALTime(event.startDate),
end: this.formatICALTime(event.endDate),
exceptions: exceptions
};
return result;
},
/**
* Find and parse the display alarms for an event.
*
* @param {Object} details details for specific instance.
*/
_displayAlarms: function(details) {
var event = details.item;
var comp = event.component;
var alarms = comp.getAllSubcomponents('valarm');
var result = [];
var self = this;
alarms.forEach(function(instance) {
var action = instance.getFirstPropertyValue('action');
if (action && action === 'DISPLAY') {
// lets just assume we might have multiple triggers
var triggers = instance.getAllProperties('trigger');
var i = 0;
var len = triggers.length;
for (; i < len; i++) {
result.push({
action: action,
trigger: self._formatTrigger(triggers[i], event.startDate)
});
}
}
});
return result;
},
/**
* Takes an ICAL.Time object and converts it
* into the storage format familiar to the calendar app.
*
* var time = new ICAL.Time({
* year: 2012,
* month: 1,
* day: 1,
* zone: 'PST'
* });
*
* // time is converted to a MS
* // then its UTC offset is added
* // so the time is at UTC (offset 0) then the
* // offset is associated with that time.
*
* var output = {
* utc: ms,
* offset: (+|-)ms,
* // zone can mostly be ignored except
* // in the case where the event is "floating"
* // in time and we need to convert the utc value
* // to the current local time.
* tzid: ''
* };
*/
formatICALTime: function(time) {
var zone = time.zone;
var offset = time.utcOffset() * 1000;
var utc = time.toUnixTime() * 1000;
utc += offset;
var result = {
tzid: zone.tzid,
// from seconds to ms
offset: offset,
// from seconds to ms
utc: utc
};
if (time.isDate) {
result.isDate = true;
}
return result;
},
/**
* Formats a given time/date into a ICAL.Time instance.
* Suitable for converting the output of formatICALTime back
* into a similar representation of the original.
*
* Once a time instance goes through this method it should _not_
* be modified as the DST information is lost (offset is preserved).
*
* @param {ICAL.Time|Object} time formatted ical time
* or output of formatICALTime.
*/
formatInputTime: function(time) {
if (time instanceof ICAL.Time) {
return time;
}
var utc = time.utc;
var tzid = time.tzid;
var offset = time.offset;
var result;
if (tzid === ICAL.Timezone.localTimezone.tzid) {
result = new ICAL.Time();
result.fromUnixTime(utc / 1000);
result.zone = ICAL.Timezone.localTimezone;
} else {
result = new ICAL.Time();
result.fromUnixTime((utc - offset) / 1000);
result.zone = ICAL.Timezone.utcTimezone;
}
if (time.isDate) {
result.isDate = true;
}
return result;
},
/**
* Parse an ical data/string into primary
* event and exceptions.
*
* It is assumed there is only one primary event
* (does not have a RECURRENCE-ID) in the ical content.
*
* @param {Object|String|ICAL.Event} ical vcalendar chunk (and exceptions).
* @param {Function} callback node style callback [err, primary event].
*/
parseEvent: function(ical, callback) {
if (ical instanceof ICAL.Event) {
callback(null, ical);
return;
}
var parser = new ICAL.ComponentParser();
var primaryEvent;
var exceptions = [];
parser.ontimezone = function(zone) {
var id = zone.tzid;
if (!ICAL.TimezoneService.has(id)) {
ICAL.TimezoneService.register(id, zone);
}
};
parser.onevent = function(item) {
if (item.isRecurrenceException()) {
exceptions.push(item);
} else {
primaryEvent = item;
}
};
parser.oncomplete = function() {
if (!primaryEvent) {
//TODO: in the error handling pass we need to define
// path to log this information so we can determine
// the cause of failures.
callback(new Error('ical parse error'));
return;
}
exceptions.forEach(primaryEvent.relateException, primaryEvent);
callback(null, primaryEvent);
};
//XXX: Right now ICAL.js is all sync so we
// can catch the errors this way in the future
// onerror will replace this.
try {
parser.process(ical);
} catch (e) {
callback(e);
}
},
_defaultMaxDate: function() {
var now = new Date();
return new ICAL.Time({
year: now.getFullYear(),
// three months in advance
// +1 because js months are zero based
month: now.getMonth() + 6,
day: now.getDate()
});
},
/**
* Expands a list recurring events by their component.
*
* It is expected for this function to receive an array
* of items each structured as a icalComponent.
*
* [
* { ical: '...', lastRecurrenceId: '..', iterator: '...' },
* ...
* ]
*
* @param {Array.<icalComponent>} components list of icalComponents.
* @param {Calendar.Responder} stream to emit events.
* @param {Object} options list of options.
* @param {Object} options.maxDate maximum date to expand to.
* @param {Function} callback only sends an error if fatal.
*/
expandComponents: function(components, options, stream, callback) {
var pending = components.length;
function next() {
if (!(--pending)) {
callback();
}
}
components.forEach(function(component) {
var ical = component.ical;
var localOpts = {
maxDate: options.maxDate,
iterator: component.iterator
};
if (component.lastRecurrenceId) {
localOpts.minDate = component.lastRecurrenceId;
}
// expand each component
this.expandRecurringEvent(ical, localOpts, stream,
function(err, iter, lastRecurId, uid) {
if (err) {
stream.emit('error', err);
next();
return;
}
stream.emit('component', {
eventId: uid,
lastRecurrenceId: lastRecurId,
ical: ical,
iterator: iter
});
next();
});
}, this);
},
/**
* Options:
*
* - iterator: (ICAL.RecurExpander) optional recurrence expander
* used to resume the iterator state for existing events.
*
* - maxDate: if instance ends after this date stop expansion.
*
*
* Returns:
*
* [
* {
* start: { offset: inMS, utc: ms },
* endDate: // same format as start,
* recurrenceId: // id of specific recurrence.
* uid: // uid of event
* isException: // true when is exception to usual rule.
* },
* //...
* ]
*/
expandRecurringEvent: function(component, options, stream, callback) {
var self = this;
var maxDate;
var minDate = null;
var now;
if (options.minDate) {
minDate = this.formatInputTime(options.minDate);
}
if (options.maxDate) {
maxDate = this.formatInputTime(options.maxDate);
}
if (!('now' in options)) {
options.now = ICAL.Time.now();
}
now = options.now;
// convert to rich ical event
this.parseEvent(component, function(err, event) {
if (err) {
callback(err);
return;
}
var iter = IcalRecurExpansion.forEach(
event,
options.iterator,
occuranceHandler,
minDate,
maxDate
);
function occuranceHandler(next) {
var details = event.getOccurrenceDetails(next);
var inFuture = details.endDate.compare(now);
debug('alarm time',
event.summary,
'will add ' + String(inFuture),
'start:', details.startDate.toJSDate().toString(),
'end:', details.endDate.toJSDate().toString(),
'now:', now.toJSDate().toString());
var occurrence = {
start: self.formatICALTime(details.startDate),
end: self.formatICALTime(details.endDate),
recurrenceId: self.formatICALTime(next),
eventId: details.item.uid,
isException: details.item.isRecurrenceException()
};
// only set alarms for those dates in the future...
if (inFuture >= 0) {
var alarms = self._displayAlarms(details);
if (alarms) {
occurrence.alarms = alarms;
}
}
stream.emit('occurrence', occurrence);
}
var lastRecurrence;
if (iter.complete) {
// when the iterator is complete
// last recurrence is false.
// We use this to signify the end
// of the iteration cycle.
lastRecurrence = false;
} else {
// its very important all times used
// for comparison are based on the recurrence id
// and not the start date as those can change
// with exceptions...
lastRecurrence = self.formatICALTime(
iter.last
);
}
callback(
null,
iter.toJSON(),
lastRecurrence,
event.uid
);
});
},
/**
* Handle a single caldav event response.
*
* @param {String} url location of event.
* @param {Object} response caldav response object.
* @param {Calendar.Responder} responder event emitter.
* @param {Function} callback node style callback fired after event parsing.
*/
_handleCaldavEvent: function(url, response, stream, callback) {
var self = this;
var etag = response.getetag;
var event = response['calendar-data'];
if (event.status != 200) {
callback(new Error('non 200 status code "' + url + '"'));
return;
}
// process event
var ical = event.value;
this.parseEvent(ical, function(err, event) {
if (err) {
callback(err);
return;
}
var result = self._formatEvent(etag.value, url, ical, event);
stream.emit('event', result);
var options = {
maxDate: self._defaultMaxDate(),
now: ICAL.Time.now()
};
self.expandRecurringEvent(event, options, stream,
function(err, iter, lastRecurrenceId) {
if (err) {
callback(err);
return;
}
if (!event.isRecurring()) {
stream.emit('component', {
eventId: result.id,
isRecurring: false,
ical: ical
});
} else {
stream.emit('component', {
eventId: result.id,
lastRecurrenceId: lastRecurrenceId,
ical: ical,
iterator: iter
});
}
callback(null);
});
});
},
streamEvents: function(account, calendar, options, stream, callback) {
var self = this;
var hasCompleted = false;
var connection = this._createConnection(account);
var cache = options.cached;
// we don't need to pass this around anywhere.
//delete options.cache;
var request = this._requestEvents(connection, calendar, options);
var pending = 0;
function next(err) {
if (err) {
try {
stream.emit('error', err);
} catch (e) {
console.error('Failed to transport err:', err.toString(), err.stack);
}
}
if (!(--pending) && hasCompleted) {
callback(null);
}
}
function handleResponse(url, data) {
if (!data || !data['calendar-data']) {
// throw some error;
console.error('Could not sync: ', url);
return;
}
var etag = data.getetag.value;
if (url in cache) {
// don't need to track this for missing events.
if (etag !== cache[url].syncToken) {
pending++;
self._handleCaldavEvent(url, data, stream, next);
}
delete cache[url];
} else {
pending++;
self._handleCaldavEvent(url, data, stream, next);
}
}
request.sax.on('DAV:/response', handleResponse);
request.send(function(err) {
hasCompleted = true;
// when the request is completed stop listening
// for sax events.
request.sax.removeEventListener(
'DAV:/response', handleResponse
);
if (err) {
callback(err);
return;
}
if (!pending) {
var missing = [];
for (var url in cache) {
missing.push(cache[url].id);
}
// send missing events
stream.emit('missingEvents', missing);
// notify the requester that we have completed.
callback();
}
});
},
_assetRequest: function(connection, url) {
return new Caldav.Request.Asset(connection, url);
},
deleteEvent: function(account, calendar, event, callback) {
var connection = this._createConnection(account);
var req = this._assetRequest(connection, event.url);
req.delete({}, function(err) {
callback(err);
});
},
addAlarms: function(component, alarms, account) {
alarms = alarms || [];
for (var i = 0; i < alarms.length; i++) {
var valarm = new ICAL.Component('valarm');
// valarm details
valarm.addPropertyWithValue('action', alarms[i].action);
valarm.addPropertyWithValue('description', 'This is an event reminder');
var trigger = valarm.addPropertyWithValue('trigger',
ICAL.Duration.fromSeconds(
alarms[i].trigger
)
);
trigger.setParameter('relative', 'START');
component.addSubcomponent(valarm);
// Check if we need to mirror the VALARM onto email
if (this.mirrorAlarms(account)) {
valarm = new ICAL.Component('valarm');
valarm.addPropertyWithValue('action', 'EMAIL');
valarm.addPropertyWithValue('description',
'This is an event reminder');
valarm.addPropertyWithValue('ATTENDEE', account.user);
trigger = valarm.addPropertyWithValue('trigger',
ICAL.Duration.fromSeconds(
alarms[i].trigger
)
);
trigger.setParameter('relative', 'START');
component.addSubcomponent(valarm);
}
}
},
/**
* Update absolute alarm times when the startDate changes.
*
* @param {ICAL.Time} originalDate of the event.
* @param {ICAL.Event} event to update.
*/
adjustAbsoluteAlarms: function(originalDate, event) {
var alarms = event.component.getAllSubcomponents('valarm');
alarms.forEach(function(alarm) {
var trigger = alarm.getFirstProperty('trigger');
var value = trigger.getValues()[0].clone();
// absolute time
if (value instanceof ICAL.Time) {
// find absolute time difference
var diff = value.subtractDateTz(originalDate);
trigger.setValue(diff);
}
});
},
/**
* Yahoo needs us to mirror all alarms as EMAIL alarms
*/
mirrorAlarms: function(account) {
return account && account.domain === 'https://caldav.calendar.yahoo.com';
},
createEvent: function(account, calendar, event, callback) {
var connection = this._createConnection(account);
var vcalendar = new ICAL.Component('vcalendar');
var icalEvent = new ICAL.Event();
// vcalendar details
vcalendar.addPropertyWithValue('prodid', this.icalProductId);
vcalendar.addPropertyWithValue('version', this.icalVersion);
// text fields
icalEvent.uid = uuid();
icalEvent.summary = event.title;
icalEvent.description = event.description;
icalEvent.location = event.location;
icalEvent.sequence = 1;
// time fields
icalEvent.startDate = this.formatInputTime(event.start);
icalEvent.endDate = this.formatInputTime(event.end);
// alarms
this.addAlarms(icalEvent.component, event.alarms, account);
vcalendar.addSubcomponent(icalEvent.component);
var url = calendar.url + icalEvent.uid + '.ics';
var req = this._assetRequest(connection, url);
event.id = icalEvent.uid;
event.url = url;
event.icalComponent = vcalendar.toString();
req.put({}, event.icalComponent, function(err, data, xhr) {
if (err) {
callback(err);
return;
}
var token = xhr.getResponseHeader('Etag');
event.syncToken = token;
// TODO: error handling
callback(err, event);
});
},
/**
* Updates a single caldav event.
* Will handle updating both primary events and exceptions.
*
* @param {Object} account full account details.
* @param {Object} calendar full calendar details.
* @param {Object} eventDetails details to update the event.
* unmodified parsed ical component. (VCALENDAR).
*/
updateEvent: function(account, calendar, eventDetails, callback) {
var connection = this._createConnection(account);
var icalComponent = eventDetails.icalComponent;
var event = eventDetails.event;
var self = this;
var req = this._assetRequest(connection, event.url);
// parse event
this.parseEvent(icalComponent, function(err, icalEvent) {
if (err) {
callback(err);
return;
}
var target = icalEvent;
var vcalendar = icalEvent.component.parent;
var originalStartDate = target.startDate;
// find correct event
if (event.recurrenceId) {
var rid = self.formatInputTime(event.recurrenceId);
rid = rid.toString();
if (icalEvent.exceptions[rid]) {
target = icalEvent.exceptions[rid];
}
}
// vcalendar pieces
vcalendar.updatePropertyWithValue('prodid', self.icalProductId);
// text fields
target.summary = event.title;
target.description = event.description;
target.location = event.location;
if (!target.sequence) {
target.sequence = 0;
}
target.sequence = parseInt(target.sequence, 10) + 1;
// time fields
target.startDate = self.formatInputTime(
event.start
);
target.endDate = self.formatInputTime(
event.end
);
// adjust absolute alarm time ( we do this before adding/changing our
// new alarm times )
self.adjustAbsoluteAlarms(originalStartDate, target);
// We generally want to remove all 'DISPLAY' alarms
// UNLESS we are dealing with a YAHOO account
// Then we overwrite all alarms
var alarms = target.component.getAllSubcomponents('valarm');
alarms.forEach(function(alarm) {
var action = alarm.getFirstPropertyValue('action');
if (action === 'DISPLAY' || self.mirrorAlarms(account)) {
target.component.removeSubcomponent(alarm);
}
});
self.addAlarms(target.component, event.alarms, account);
var vcal = target.component.parent.toString();
event.icalComponent = vcal;
req.put({}, vcal, function(err, data, xhr) {
if (err) {
callback(err);
return;
}
var token = xhr.getResponseHeader('Etag');
event.syncToken = token;
// TODO: error handling
callback(err, event);
});
});
}
};
});