ical-utils
Version:
ICal generator/updater/parser with Timezone/DST, Alams, Organizers, Events, etc. support.
430 lines (367 loc) • 13.2 kB
JavaScript
/**
* ICS Builders
* */
var crypto = require('crypto'),
path = require('path'),
//database = require('../timezones/database'),
fs = require('fs');
function handleAdditionalTags(additionalTags, lines) {
for (var additionalProp in additionalTags) {
if (additionalTags.hasOwnProperty(additionalProp)) {
if (Array.isArray(additionalTags[additionalProp])) {
var property = additionalTags[additionalProp];
for (var value in property) {
lines.push(additionalProp + ':' + property[value]);
}
} else {
lines.push(additionalProp + ':' + additionalTags[additionalProp]);
}
}
}
}
function ICSFileBuilder() {
this.calscale = 'GREGORIAN';
this.version = '2.0';
this.calname = null;
this.method = 'PUBLISH';
this.prodid = 'node-ical-toolkit';
this.timezone = null;
this.tzid = null;
this.events = [];
this.additionalTags = {};
this.spacers = true;
this.NEWLINE_CHAR = '\r\n';
this.throwError = false;
this.ignoreTZIDMismatch = true;
}
ICSFileBuilder.prototype.toString = function () {
var lines = [];
lines.push('BEGIN:VCALENDAR');
if (this.version) lines.push('VERSION:' + this.version.toString().trim());
if (this.calscale) lines.push('CALSCALE:' + this.calscale.toString().trim());
if (this.calname) lines.push('X-WR-CALNAME:' + this.calname.toString().trim());
if (this.method) lines.push('METHOD:' + this.method.toString().trim());
if (this.prodid) lines.push('PRODID:' + this.prodid.toString().trim());
if (this.timezone) lines.push('X-WR-TIMEZONE:' + this.timezone.toString().trim());
if (this.tzid) {
var tzData;
try {
tzData = fs.readFileSync(path.join(__dirname, './timezones/database',
this.tzid.toString().toLowerCase().replace(/\//g, '-'))
.replace(/\/$/, '') + '.json', 'utf8');
if (!tzData || !tzData.TZID) {
throw new Error('Timezone not found! Please check!');
}
} catch (c) {
if (this.ignoreTZIDMismatch) {
tzData = {
"VTIMEZONE": {
"TZID": this.tzid
},
"TZID": this.tzid
};
} else {
c = new Error('Unable to process TZID provided! ' + c.message);
if (this.throwError) throw c;
else return c;
}
}
if (this.spacers)lines.push('');
lines.push('BEGIN:VTIMEZONE');
for (var key in tzData.VTIMEZONE) {
if (tzData.VTIMEZONE.hasOwnProperty(key)) {
if (typeof tzData.VTIMEZONE[key] == 'string' || tzData.VTIMEZONE[key] instanceof String) {
lines.push(key + ':' + tzData.VTIMEZONE[key].toString().trim());
} else {
var ref = tzData.VTIMEZONE[key];
if (ref instanceof Array) ref = ref[0];
if (this.spacers)lines.push('');
lines.push('BEGIN:' + key);
for (var prop in ref) {
if (ref.hasOwnProperty(prop)) {
if (typeof ref[prop] == 'string' || ref[prop] instanceof String) {
lines.push(prop + ':' + ref[prop].toString().trim());
} else {
//todo move this to recursive generic method. bad code, i know.
//No inner tz tags supported. Invalid data. Ignore.
}
}
}
lines.push('END:' + key);
if (this.spacers)lines.push('');
}
}
}
lines.push('END:VTIMEZONE');
if (this.spacers)lines.push('');
}
for (var idx = 0; idx < this.events.length; idx++) {
var _event = this._checkAndBuildEventObject(this.events[idx]);
var tzMode = 'UTC'; // If none of the other modes are specified, fall back on UTC mode
if(_event.floating) {
tzMode = 'floating'; // If the user has requested floating mode, honor that.
}
else if(this.tzid) {
tzMode = this.tzid; // TZID should be used if available.
}
else if(this.timezone) {
tzMode = this.timezone; // Fall back to timezone only if TZID is missing
}
if (_event instanceof Error) {
if (this.throwError) throw _event;
else return _event;
}
if (this.spacers)lines.push('');
lines.push('BEGIN:VEVENT');
lines.push('UID:' + _event.uid);
lines.push('DTSTAMP' + _formatDate(_event.stamp, false, tzMode));
if (_event.transp) lines.push('TRANSP:' + _event.transp);
if (_event.allDay) {
lines.push('DTSTART;VALUE=DATE:' + _formatDate(_event.start, true, 'UTC'));
lines.push('DTEND;VALUE=DATE:' + _formatDate(_event.end, true, 'UTC'));
} else {
lines.push('DTSTART' + _formatDate(_event.start, false, tzMode));
lines.push('DTEND' + _formatDate(_event.end, false, tzMode));
}
lines.push('SUMMARY:' + _escape(_event.summary));
lines.push('SEQUENCE:' + _event.sequence);
if (_event.location) lines.push('LOCATION:' + _escape(_event.location));
if (_event.description) lines.push('DESCRIPTION:' + _escape(_event.description));
if (_event.url) lines.push('URL;VALUE=URI:' + _event.url);
if (_event.status) lines.push('STATUS:' + _event.status.toUpperCase());
if (_event.organizer) lines.push('ORGANIZER;' + (!!_event.organizer.sentBy ? ('SENT-BY="MAILTO:' + _event.organizer.sentBy + '":') : '') + 'CN="' + _event.organizer.name.replace(/"/g, '\\"') + '":mailto:' + _event.organizer.email);
for (var ac = 0; ac < _event.attendees.length; ac++) {
if (_event.attendees[ac] instanceof Error) {
if (this.throwError) throw _event.attendees[ac];
else return _event.attendees[ac];
} else {
lines.push('ATTENDEE;ROLE=' + _event.attendees[ac].role + ';PARTSTAT=' + _event.attendees[ac].status + (_event.attendees[ac].rsvp ? ';RSVP=TRUE' : '') + ';CN=' + _event.attendees[ac].name + ':MAILTO:' + _event.attendees[ac].email);
}
}
for (var i = 0; i < _event.alarms.length; i++) {
if (this.spacers)lines.push('');
lines.push('BEGIN:VALARM');
lines.push('TRIGGER:-PT' + _event.alarms[i] + 'M');
lines.push('ACTION:DISPLAY');
lines.push('DESCRIPTION:' + _event.summary);
lines.push('END:VALARM');
if (this.spacers)lines.push('');
}
if (_event.repeating) {
var rrlue = 'RRULE:FREQ=' + _event.repeating.freq;
if (_event.repeating.count) {
rrlue += ';COUNT=' + _event.repeating.count;
}
if (_event.repeating.until) {
rrlue += ';UNTIL=' + _formatDate(_event.repeating.until, false, tzMode);
}
if (_event.repeating.interval) {
rrlue += ';INTERVAL=' + _event.repeating.interval;
}
if (_event.repeating.bymonth) {
rrlue += ';BYMONTH=' + _event.repeating.bymonth.join(',');
}
if (_event.repeating.byday) {
rrlue += ';BYDAY=' + _event.repeating.byday.join(',');
}
lines.push(rrlue);
}
handleAdditionalTags(_event.additionalTags, lines);
lines.push('END:VEVENT');
if (this.spacers)lines.push('');
}
if (this.additionalTags) {
handleAdditionalTags(_event.additionalTags, lines);
}
lines.push('END:VCALENDAR');
return lines.join(this.NEWLINE_CHAR);
};
ICSFileBuilder.prototype._checkAndBuildEventObject = function (rawEvent) {
var event = {},
allowedMethods = [
'PUBLISH',
'REQUEST',
'REPLY',
'ADD',
'CANCEL',
'REFRESH',
'COUNTER',
'DECLINECOUNTER'
],
allowedRepeatingFreq = [
'SECONDLY',
'MINUTELY',
'HOURLY',
'DAILY',
'WEEKLY',
'MONTHLY',
'YEARLY'
],
allowedStatuses = [
'CONFIRMED',
'TENTATIVE',
'CANCELLED'
];
if (!rawEvent || typeof rawEvent !== 'object') {
return new Error('event is not an object.');
}
// Date Start
if (!rawEvent.start) {
return new Error('event.start is a mandatory item.');
}
if (!(rawEvent.start instanceof Date)) {
return new Error('event.start must be a Date Object.');
}
event.start = rawEvent.start;
// Date Stop
if (!rawEvent.end) {
return new Error('event.end is a mandatory item.');
}
if (!(rawEvent.end instanceof Date)) {
return new Error('event.end must be a Date Object.');
}
event.end = rawEvent.end;
//Alarms
event.alarms = rawEvent.alarms || [];
//Additional tags
event.additionalTags = rawEvent.additionalTags || {};
// UID
event.uid = rawEvent.uid || crypto.randomBytes(4).toString('hex').toString(36);
//Update sequence
event.sequence = rawEvent.sequence || 0;
// Repeating Event
if (rawEvent.repeating) {
event.repeating = {};
if (!rawEvent.repeating.freq || allowedRepeatingFreq.indexOf(rawEvent.repeating.freq.toUpperCase()) === -1) {
return new Error('event.repeating.freq is a mandatory item, and must be one of the following: ' + allowedRepeatingFreq.join(', '));
}
event.repeating.freq = rawEvent.repeating.freq;
if (rawEvent.repeating.count) {
if (!isFinite(rawEvent.repeating.count)) {
return new Error('event.repeating.count must be a Number.');
}
event.repeating.count = rawEvent.repeating.count;
}
if (rawEvent.repeating.interval) {
if (!isFinite(rawEvent.repeating.interval)) {
return new Error('event.repeating.interval must be a Number.');
}
event.repeating.interval = rawEvent.repeating.interval;
}
if (rawEvent.repeating.until) {
if (!(rawEvent.repeating.until instanceof Date)) {
return new Error('event.repeating.until must be a Date Object.');
}
event.repeating.until = rawEvent.repeating.until;
}
if (rawEvent.repeating.byday) {
event.repeating.byday = rawEvent.repeating.byday;
}
if (rawEvent.repeating.bymonth) {
event.repeating.bymonth = rawEvent.repeating.bymonth;
}
}
// allDay flag
event.allDay = !!rawEvent.allDay;
// Date Stamp
if (rawEvent.stamp && !(rawEvent.stamp instanceof Date)) {
return new Error('event.stamp must be a Date Object.');
}
event.stamp = rawEvent.stamp || new Date();
// Floating times
event.floating = rawEvent.floating || false;
// Summary
if (!rawEvent.summary) {
return new Error('event.summary is a mandatory item.');
}
event.summary = rawEvent.summary;
// Location
event.location = rawEvent.location || null;
// Description
event.description = rawEvent.description || null;
//attendees
event.attendees = [];
(rawEvent.attendees || []).forEach(function (att) {
if (att.name && att.email) {
event.attendees.push({
name: att.name,
email: att.email,
role: (att.role || 'REQ-PARTICIPANT').toUpperCase(),
status: (att.status || 'NEEDS-ACTION').toUpperCase(),
rsvp: !!att.rsvp
});
} else {
event.attendees.push(new Error('Invalid attendee data, name and email is required: ' + JSON.stringify({input: att})));
}
});
// Organizer
event.organizer = null;
if (rawEvent.organizer) {
if (!rawEvent.organizer.name) {
return new Error('event.organizer.name is empty.');
}
if (!rawEvent.organizer.email) {
return new Error('event.organizer.email is empty.');
}
event.organizer = {
name: rawEvent.organizer.name,
email: rawEvent.organizer.email
};
if (rawEvent.organizer.sentBy) event.organizer.sentBy = rawEvent.organizer.sentBy;
}
// Method
if (rawEvent.method && allowedMethods.indexOf(rawEvent.method.toUpperCase()) === -1) {
return new Error('event.method must be one of the following: ' + allowedMethods.join(', '));
}
event.method = rawEvent.method;
// Status
if (rawEvent.status && allowedStatuses.indexOf(rawEvent.status.toUpperCase()) === -1) {
return new Error('event.status must be one of the following: ' + allowedStatuses.join(', '));
}
event.status = rawEvent.status;
// URL
event.url = rawEvent.url || null;
//Transp prop
event.transp = rawEvent.transp;
return event;
};
/**
* Export builder
* */
exports.createIcsFileBuilder = function () {
return new ICSFileBuilder();
};
//Utils
function _formatDate(d, dateonly, tZMode) {
var s = ':'; // non-timezone timestamps use a leading colon
tZMode = tZMode || 'UTC';
var isUtc = tZMode === 'UTC'; // Since we'll use this to determine if we should grab UTC or Local components a bunch, cache for performance.
function pad(i) {
return (i < 10 ? '0' : '') + i;
}
if(!isUtc && tZMode !== 'floating') {
// If we aren't in either special mode, we need to assemble the TZID prefix
s = ';TZID=' + tZMode + ':'; // leading semi-colon is REQUIRED!
}
s += isUtc ? d.getUTCFullYear() : d.getFullYear();
s += pad((isUtc ? d.getUTCMonth() : d.getMonth()) + 1);
s += pad(isUtc ? d.getUTCDate() : d.getDate());
if (!dateonly) {
s += 'T';
s += pad(isUtc ? d.getUTCHours() : d.getHours());
s += pad(isUtc ? d.getUTCMinutes() : d.getMinutes());
s += pad(isUtc ? d.getUTCSeconds() : d.getSeconds());
if (isUtc) {
s += 'Z';
}
}
return s;
}
function _escape(str) {
return str.replace(/[\\;,\n]/g, function (match) {
if (match === '\n') {
return '\\n';
}
return '\\' + match;
});
}