You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cryptpad/www/common/outer/calendar.js

831 lines
29 KiB
JavaScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

define([
'/common/common-util.js',
'/common/common-hash.js',
'/common/common-constants.js',
'/common/common-realtime.js',
'/common/outer/cache-store.js',
'/customize/messages.js',
'/bower_components/nthen/index.js',
'/bower_components/chainpad-listmap/chainpad-listmap.js',
'/bower_components/chainpad-crypto/crypto.js',
'/bower_components/chainpad/chainpad.dist.js',
], function (Util, Hash, Constants, Realtime, Cache, Messages, nThen, Listmap, Crypto, ChainPad) {
var Calendar = {};
/* TODO
* Calendar
{
href,
roHref,
channel, (pinning)
title, (when created from the UI, own calendar has no title)
color
}
* Own drive
{
calendars: {
uid: calendar,
uid: calendar
}
}
* Team drive
{
calendars: {
uid: calendar,
uid: calendar
}
}
* Calendars are listmap
{
content: {},
metadata: {
title: "pewpewpew"
}
}
ctx.calendars[channel] = {
lm: lm,
proxy: lm.proxy?
stores: [teamId, teamId, 1]
}
* calendar app can subscribe to this module
* when a listmap changes, push an update for this calendar to subscribed tabs
* Ability to open a calendar not stored in the stores but from its URL directly
* No "userlist" visible in the UI
* No framework
*/
var getStore = function (ctx, id) {
if (!id || id === 1) {
return ctx.store;
}
var m = ctx.store.modules && ctx.store.modules.team;
if (!m) { return; }
return m.getTeam(id);
};
var makeCalendar = function () {
var hash = Hash.createRandomHash('calendar');
var secret = Hash.getSecrets('calendar', hash);
var roHash = Hash.getViewHashFromKeys(secret);
var href = Hash.hashToHref(hash, 'calendar');
var roHref = Hash.hashToHref(roHash, 'calendar');
return {
href: href,
roHref: roHref,
channel: secret.channel,
};
};
var initializeCalendars = function (ctx, cb) {
var proxy = ctx.store.proxy;
proxy.calendars = proxy.calendars || {};
setTimeout(cb);
};
var sendUpdate = function (ctx, c) {
ctx.emit('UPDATE', {
teams: c.stores,
roTeams: c.roStores,
id: c.channel,
loading: !c.ready && !c.cacheready,
readOnly: c.readOnly || (!c.ready && c.cacheready) || c.offline,
offline: c.offline,
deleted: !c.stores.length,
restricted: c.restricted,
owned: ctx.Store.isOwned(c.owners),
content: Util.clone(c.proxy),
hashes: c.hashes
}, ctx.clients);
};
var closeCalendar = function (ctx, id) {
var ctxCal = ctx.calendars[id];
if (!ctxCal) { return; }
// If the calendar doesn't exist in any other team, stop it and delete it from ctx
if (!ctxCal.stores.length) {
ctxCal.lm.stop();
delete ctx.calendars[id];
}
};
var updateLocalCalendars = function (ctx, c, data) {
// Also update local data
c.stores.forEach(function (id) {
var s = getStore(ctx, id);
if (!s || !s.proxy) { return; }
if (!s.rpc) { return; } // team viewer
if (!s.proxy.calendars) { return; }
var cal = s.proxy.calendars[c.channel];
if (!cal) { return; }
if (cal.color !== data.color) { cal.color = data.color; }
if (cal.title !== data.title) { cal.title = data.title; }
});
};
var openChannel = function (ctx, cfg, _cb) {
var cb = Util.once(Util.mkAsync(_cb || function () {}));
var teamId = cfg.storeId;
var data = cfg.data;
var channel = data.channel;
if (!channel) { return; }
var c = ctx.calendars[channel];
var update = function () {
sendUpdate(ctx, c);
};
if (c) {
if (c.readOnly && data.href) {
// Upgrade readOnly calendar to editable
var upgradeParsed = Hash.parsePadUrl(data.href);
var upgradeSecret = Hash.getSecrets('calendar', upgradeParsed.hash, data.password);
var upgradeCrypto = Crypto.createEncryptor(upgradeSecret.keys);
c.hashes.editHash = Hash.getEditHashFromKeys(upgradeSecret);
c.lm.setReadOnly(false, upgradeCrypto);
c.readOnly = false;
} else if (teamId === 0) {
// Existing calendars can't be "temp calendars" (unless they are an upgrade)
return void cb();
}
// Remove from roStores when upgrading this store
if (c.roStores.indexOf(teamId) !== -1 && data.href) {
c.roStores.splice(c.roStores.indexOf(teamId), 1);
// If we've upgraded a stored calendar, remove the temp calendar
if (c.stores.indexOf(0) !== -1) {
c.stores.splice(c.stores.indexOf(0), 1);
}
update();
}
// Don't store duplicates
if (c.stores && c.stores.indexOf(teamId) !== -1) { return void cb(); }
// If we store a temp calendar to our account or team, remove this "temp calendar"
if (c.stores.indexOf(0) !== -1) { c.stores.splice(c.stores.indexOf(0), 1); }
c.stores.push(teamId);
if (!data.href) {
c.roStores.push(teamId);
}
update();
return void cb();
}
// Multiple teams can have the same calendar. Make sure we remember the list of stores
// that know it so that we don't close the calendar when leaving/deleting a team.
c = ctx.calendars[channel] = {
ready: false,
channel: channel,
readOnly: !data.href,
stores: [teamId],
roStores: data.href ? [] : [teamId],
hashes: {}
};
var parsed = Hash.parsePadUrl(data.href || data.roHref);
var secret = Hash.getSecrets('calendar', parsed.hash, data.password);
var crypto = Crypto.createEncryptor(secret.keys);
c.hashes.viewHash = Hash.getViewHashFromKeys(secret);
if (data.href) {
c.hashes.editHash = Hash.getEditHashFromKeys(secret);
}
c.proxy = {
metadata: {
color: data.color,
title: data.title
}
};
update();
var onDeleted = function () {
// Remove this calendar from all our teams
// XXX Maybe not? don't remove automatically so that we can tell the user to do so.
c.stores.forEach(function (storeId) {
var store = getStore(ctx, storeId);
if (!store || !store.rpc || !store.proxy.calendars) { return; }
delete store.proxy.calendars[channel];
// And unpin
var unpin = store.unpin || ctx.unpinPads;
unpin([channel], function (res) {
if (res && res.error) { console.error(res.error); }
});
});
// Close listmap, update the UI and clear the memory
if (c.lm) { c.lm.stop(); }
c.stores = [];
sendUpdate(ctx, c);
delete ctx.calendars[channel];
};
nThen(function (waitFor) {
if (!ctx.store.network || cfg.isNew) { return; }
// This is supposed to be an existing channel. Make sure it exists on the server
// before trying to load it.
// NOTE: if we can't check (error), we can skip this step. On "ready", we have
// another check to make sure we won't make a new calendar
ctx.Store.isNewChannel(null, channel, waitFor(function (obj) {
if (obj && obj.error) {
// If we can't check, skip this part
return;
}
if (obj && typeof(obj.isNew) === "boolean") {
if (obj.isNew) {
onDeleted();
cb({error: 'EDELETED'});
waitFor.abort();
return;
}
}
}));
}).nThen(function () {
// Set the owners as the first store opening it. We don't know yet if it's a new or
// existing calendar. "owners' will be ignored if the calendar already exists.
var edPublic;
if (teamId === 1 || !teamId) {
edPublic = ctx.store.proxy.edPublic;
} else {
var teams = ctx.store.modules.team && ctx.store.modules.team.getTeamsData();
var team = teams && teams[teamId];
edPublic = team ? team.edPublic : undefined;
}
var config = {
data: {},
network: ctx.store.network || ctx.store.networkPromise,
channel: secret.channel,
crypto: crypto,
owners: [edPublic],
ChainPad: ChainPad,
validateKey: secret.keys.validateKey || undefined,
userName: 'calendar',
Cache: Cache,
classic: true,
onRejected: ctx.Store && ctx.Store.onRejected
};
var lm = Listmap.create(config);
c.lm = lm;
var proxy = c.proxy = lm.proxy;
lm.proxy.on('cacheready', function () {
if (!proxy.metadata) { return; }
c.cacheready = true;
setTimeout(update);
if (cb) { cb(null, lm.proxy); }
}).on('ready', function (info) {
var md = info.metadata;
c.owners = md.owners || [];
c.ready = true;
if (!proxy.metadata) {
if (!cfg.isNew) {
// no metadata on an existing calendar: deleted calendar
return void onDeleted();
}
proxy.metadata = {
color: data.color,
title: data.title
};
}
setTimeout(update);
if (cb) { cb(null, lm.proxy); }
}).on('change', [], function () {
if (!c.ready) { return; }
setTimeout(update);
}).on('change', ['metadata'], function () {
// if title or color have changed, update our local values
var md = proxy.metadata;
if (!md || !md.title || !md.color) { return; }
updateLocalCalendars(ctx, c, md);
}).on('disconnect', function () {
c.offline = true;
setTimeout(update);
}).on('reconnect', function () {
c.offline = false;
setTimeout(update);
}).on('error', function (info) {
if (!info || !info.error) { return; }
if (info.error === "EDELETED" ) {
return void onDeleted();
}
if (info.error === "ERESTRICTED" ) {
c.restricted = true;
}
cb(info);
});
});
};
var decryptTeamCalendarHref = function (store, calData) {
if (!calData.href) { return; }
// Already decrypted? nothing to do
if (calData.href.indexOf('#') !== -1) { return; }
// href exists and is encrypted: decrypt if we can or ignore the href
if (store.secondaryKey) {
try {
calData.href = store.userObject.cryptor.decrypt(calData.href);
} catch (e) {
console.error(e);
delete calData.href;
}
} else {
delete calData.href;
}
};
var initializeStore = function (ctx, store) {
var c = store.proxy.calendars;
var storeId = store.id || 1;
// Add listeners
store.proxy.on('change', ['calendars'], function (o, n, p) {
if (p.length < 2) { return; }
// Handle deletions
if (o && !n) {
(function () {
var id = p[1];
var ctxCal = ctx.calendars[id];
if (!ctxCal) { return; }
var idx = ctxCal.stores.indexOf(storeId);
// Check if the team has loaded this calendar in memory
if (idx === -1) { return; }
// Remove the team from memory
ctxCal.stores.splice(idx, 1);
var roIdx = ctxCal.roStores.indexOf(storeId);
if (roIdx !== -1) { ctxCal.roStores.splice(roIdx, 1); }
// Check if we need to close listmap and update the UI
closeCalendar(ctx, id);
sendUpdate(ctx, ctxCal);
})();
}
// Handle additions
// NOTE: this also upgrade from readOnly to edit (add an "href" to the calendar)
if (!o && n) {
(function () {
var id = p[1];
var _cal = store.proxy.calendars[id];
if (!_cal) { return; }
var cal = Util.clone(_cal);
decryptTeamCalendarHref(store, cal);
openChannel(ctx, {
storeId: storeId,
data: cal
});
})();
}
});
// If this store contains existing calendars, open them
Object.keys(c || {}).forEach(function (channel) {
var cal = Util.clone(c[channel]);
decryptTeamCalendarHref(store, cal);
openChannel(ctx, {
storeId: storeId,
data: cal
});
});
};
var openChannels = function (ctx) {
// Personal drive
initializeStore(ctx, ctx.store);
var teams = ctx.store.modules.team && ctx.store.modules.team.getTeamsData();
if (!teams) { return; }
Object.keys(teams).forEach(function (id) {
var store = getStore(ctx, id);
initializeStore(ctx, store);
});
};
var subscribe = function (ctx, data, cId, cb) {
// Subscribe to new notifications
var idx = ctx.clients.indexOf(cId);
if (idx === -1) {
ctx.clients.push(cId);
}
cb({
empty: !Object.keys(ctx.calendars).length
});
Object.keys(ctx.calendars).forEach(function (channel) {
var c = ctx.calendars[channel] || {};
sendUpdate(ctx, c);
});
};
var openCalendar = function (ctx, data, cId, cb) {
var secret = Hash.getSecrets('calendar', data.hash, data.password);
var hash = Hash.getEditHashFromKeys(secret);
var roHash = Hash.getViewHashFromKeys(secret);
var cal = {
href: hash && Hash.hashToHref(hash, 'calendar'),
roHref: roHash && Hash.hashToHref(roHash, 'calendar'),
channel: secret.channel,
color: Util.getRandomColor(),
title: '...'
};
openChannel(ctx, {
storeId: 0,
data: cal
}, cb);
};
var importCalendar = function (ctx, data, cId, cb) {
var id = data.id;
var c = ctx.calendars[id];
if (!c) { return void cb({error: "ENOENT"}); }
if (!Array.isArray(c.stores) || c.stores.indexOf(data.teamId) === -1) {
return void cb({error: 'EINVAL'});
}
// Add to my calendars
var store = ctx.store;
var calendars = store.proxy.calendars = store.proxy.calendars || {};
var hash = c.hashes.editHash;
var roHash = c.hashes.viewHash;
calendars[id] = {
href: hash && Hash.hashToHref(hash, 'calendar'),
roHref: roHash && Hash.hashToHref(roHash, 'calendar'),
channel: id,
color: Util.find(c,['proxy', 'metadata', 'color']) || Util.getRandomColor(),
title: Util.find(c,['proxy', 'metadata', 'title']) || '...'
};
ctx.Store.onSync(null, cb);
// Make the change in memory
openChannel(ctx, {
storeId: 1,
data: {
href: calendars[id].href,
toHref: calendars[id].roHref,
channel: id
}
});
};
var addCalendar = function (ctx, data, cId, cb) {
var store = getStore(ctx, data.teamId);
if (!store) { return void cb({error: "NO_STORE"}); }
// Check team edit rights: viewers in teams don't have rpc
if (!store.rpc) { return void cb({error: "EFORBIDDEN"}); }
var c = store.proxy.calendars = store.proxy.calendars || {};
var parsed = Hash.parsePadUrl(data.href);
var secret = Hash.getSecrets(parsed.type, parsed.hash, data.password);
if (secret.channel !== data.channel) { return void cb({error: 'EINVAL'}); }
var hash = Hash.getEditHashFromKeys(secret);
var roHash = Hash.getViewHashFromKeys(secret);
var href = hash && Hash.hashToHref(hash, 'calendar');
var cal = {
href: href,
roHref: roHash && Hash.hashToHref(roHash, 'calendar'),
color: data.color,
title: data.title,
channel: data.channel
};
// If it already existed and it's not an upgrade, nothing to do
if (c[data.channel] && (c[data.channel].href || !cal.href)) { return void cb(); }
cal.color = data.color;
cal.title = data.title;
openChannel(ctx, {
storeId: store.id || 1,
data: Util.clone(cal)
}, function (err) {
if (err) {
// Can't open this channel, don't store it
console.error(err);
return void cb({error: err.error});
}
if (href && store.id && store.secondaryKey) {
try {
cal.href = store.userObject.cryptor.encrypt(href);
} catch (e) {
console.error(e);
}
}
// Add the calendar and call back
// If it already existed it means this is an upgrade
c[cal.channel] = cal;
var pin = store.pin || ctx.pinPads;
pin([cal.channel], function (res) {
if (res && res.error) { console.error(res.error); }
});
ctx.Store.onSync(store.id, cb);
});
};
var createCalendar = function (ctx, data, cId, cb) {
var store = getStore(ctx, data.teamId);
if (!store) { return void cb({error: "NO_STORE"}); }
// Check team edit rights: viewers in teams don't have rpc
if (!store.rpc) { return void cb({error: "EFORBIDDEN"}); }
var c = store.proxy.calendars = store.proxy.calendars || {};
var cal = makeCalendar();
cal.color = data.color;
cal.title = data.title;
openChannel(ctx, {
storeId: store.id || 1,
data: cal,
isNew: true
}, function (err) {
if (err) {
// Can't open this channel, don't store it
console.error(err);
return void cb({error: err.error});
}
// Add the calendar and call back
// Wait for the metadata to be stored (channel fully ready) before adding it
// to our store
var ctxCal = ctx.calendars[cal.channel];
Realtime.whenRealtimeSyncs(ctxCal.lm.realtime, function () {
c[cal.channel] = cal;
var pin = store.pin || ctx.pinPads;
pin([cal.channel], function (res) {
if (res && res.error) { console.error(res.error); }
});
ctx.Store.onSync(store.id, cb);
});
});
};
var updateCalendar = function (ctx, data, cId, cb) {
var id = data.id;
var c = ctx.calendars[id];
if (!c) { return void cb({error: "ENOENT"}); }
var md = Util.find(c, ['proxy', 'metadata']);
if (!md) { return void cb({error: 'EINVAL'}); }
md.title = data.title;
md.color = data.color;
Realtime.whenRealtimeSyncs(c.lm.realtime, cb);
sendUpdate(ctx, c);
updateLocalCalendars(ctx, c, data);
};
var deleteCalendar = function (ctx, data, cId, cb) {
var store = getStore(ctx, data.teamId);
if (!store) { return void cb({error: "NO_STORE"}); }
if (!store.rpc) { return void cb({error: "EFORBIDDEN"}); }
if (!store.proxy.calendars) { return; }
var id = data.id;
var cal = store.proxy.calendars[id];
if (!cal) { return void cb(); } // Already deleted
// Delete
delete store.proxy.calendars[id];
// Unpin
var unpin = store.unpin || ctx.unpinPads;
unpin([id], function (res) {
if (res && res.error) { console.error(res.error); }
});
// Clear/update ctx data
// Remove this store from the calendar's clients
var ctxCal = ctx.calendars[id];
var idx = ctxCal.stores.indexOf(store.id || 1);
ctxCal.stores.splice(idx, 1);
closeCalendar(ctx, id);
ctx.Store.onSync(store.id, function () {
sendUpdate(ctx, ctxCal);
cb();
});
};
var createEvent = function (ctx, data, cId, cb) {
var id = data.calendarId;
var c = ctx.calendars[id];
if (!c) { return void cb({error: "ENOENT"}); }
var startDate = new Date(data.start);
var endDate = new Date(data.end);
if (data.isAllDay) {
data.startDay = startDate.getFullYear() + '-' + (startDate.getMonth()+1) + '-' + startDate.getDate();
data.endDay = endDate.getFullYear() + '-' + (endDate.getMonth()+1) + '-' + endDate.getDate();
} else {
delete data.startDay;
delete data.endDay;
}
c.proxy.content = c.proxy.content || {};
c.proxy.content[data.id] = data;
Realtime.whenRealtimeSyncs(c.lm.realtime, function () {
sendUpdate(ctx, c);
cb();
});
};
var updateEvent = function (ctx, data, cId, cb) {
if (!data || !data.ev) { return void cb({error: 'EINVAL'}); }
var id = data.ev.calendarId;
var c = ctx.calendars[id];
if (!c || !c.proxy || !c.proxy.content) { return void cb({error: "ENOENT"}); }
// Find the event
var ev = c.proxy.content[data.ev.id];
if (!ev) { return void cb({error: "EINVAL"}); }
// update the event
var changes = data.changes || {};
var newC;
if (changes.calendarId) {
newC = ctx.calendars[changes.calendarId];
if (!newC || !newC.proxy) { return void cb({error: "ENOENT"}); }
newC.proxy.content = newC.proxy.content || {};
}
Object.keys(changes).forEach(function (key) {
ev[key] = changes[key];
});
var startDate = new Date(ev.start);
var endDate = new Date(ev.end);
if (ev.isAllDay) {
ev.startDay = startDate.getFullYear() + '-' + (startDate.getMonth()+1) + '-' + startDate.getDate();
ev.endDay = endDate.getFullYear() + '-' + (endDate.getMonth()+1) + '-' + endDate.getDate();
} else {
delete ev.startDay;
delete ev.endDay;
}
// Move to a different calendar?
if (changes.calendarId && newC) {
newC.proxy.content[data.ev.id] = Util.clone(ev);
delete c.proxy.content[data.ev.id];
}
nThen(function (waitFor) {
Realtime.whenRealtimeSyncs(c.lm.realtime, waitFor());
if (newC) { Realtime.whenRealtimeSyncs(newC.lm.realtime, waitFor()); }
}).nThen(function () {
sendUpdate(ctx, c);
if (newC) { sendUpdate(ctx, newC); }
cb();
});
};
var deleteEvent = function (ctx, data, cId, cb) {
var id = data.calendarId;
var c = ctx.calendars[id];
if (!c) { return void cb({error: "ENOENT"}); }
c.proxy.content = c.proxy.content || {};
delete c.proxy.content[data.id];
Realtime.whenRealtimeSyncs(c.lm.realtime, function () {
sendUpdate(ctx, c);
cb();
});
};
var removeClient = function (ctx, cId) {
var idx = ctx.clients.indexOf(cId);
ctx.clients.splice(idx, 1);
};
Calendar.init = function (cfg, waitFor, emit) {
var calendar = {};
var store = cfg.store;
if (!store.loggedIn || !store.proxy.edPublic) { return; } // XXX logged in only?
var ctx = {
store: store,
Store: cfg.Store,
pinPads: cfg.pinPads,
unpinPads: cfg.unpinPads,
updateMetadata: cfg.updateMetadata,
emit: emit,
onReady: Util.mkEvent(true),
calendars: {},
clients: [],
};
initializeCalendars(ctx, waitFor(function (err) {
if (err) { return; }
openChannels(ctx);
}));
calendar.closeTeam = function (teamId) {
Object.keys(ctx.calendars).forEach(function (id) {
var ctxCal = ctx.calendars[id];
var idx = ctxCal.stores.indexOf(teamId);
if (idx === -1) { return; }
ctxCal.stores.splice(idx, 1);
var roIdx = ctxCal.roStores.indexOf(teamId);
if (roIdx !== -1) { ctxCal.roStores.splice(roIdx, 1); }
closeCalendar(ctx, id);
sendUpdate(ctx, ctxCal);
});
};
calendar.openTeam = function (teamId) {
var store = getStore(ctx, teamId);
if (!store) { return; }
initializeStore(ctx, store);
};
calendar.upgradeTeam = function (teamId) {
if (!teamId) { return; }
var store = getStore(ctx, teamId);
if (!store) { return; }
Object.keys(ctx.calendars).forEach(function (id) {
var ctxCal = ctx.calendars[id];
var idx = ctxCal.stores.indexOf(teamId);
if (idx === -1) { return; }
var _cal = store.proxy.calendars[id];
var cal = Util.clone(_cal);
decryptTeamCalendarHref(store, cal);
openChannel(ctx, {
storeId: teamId,
data: cal
});
sendUpdate(ctx, ctxCal);
});
};
calendar.removeClient = function (clientId) {
removeClient(ctx, clientId);
};
calendar.execCommand = function (clientId, obj, cb) {
var cmd = obj.cmd;
var data = obj.data;
if (cmd === 'SUBSCRIBE') {
return void subscribe(ctx, data, clientId, cb);
}
if (cmd === 'OPEN') {
ctx.Store.onReadyEvt.reg(function () {
openCalendar(ctx, data, clientId, cb);
});
return;
}
if (cmd === 'IMPORT') {
if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); }
return void importCalendar(ctx, data, clientId, cb);
}
if (cmd === 'ADD') {
if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); }
return void addCalendar(ctx, data, clientId, cb);
}
if (cmd === 'CREATE') {
if (data.initialCalendar) {
return void ctx.Store.onReadyEvt.reg(function () {
createCalendar(ctx, data, clientId, cb);
});
}
if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); }
return void createCalendar(ctx, data, clientId, cb);
}
if (cmd === 'UPDATE') {
if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); }
return void updateCalendar(ctx, data, clientId, cb);
}
if (cmd === 'DELETE') {
if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); }
return void deleteCalendar(ctx, data, clientId, cb);
}
if (cmd === 'CREATE_EVENT') {
if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); }
return void createEvent(ctx, data, clientId, cb);
}
if (cmd === 'UPDATE_EVENT') {
if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); }
return void updateEvent(ctx, data, clientId, cb);
}
if (cmd === 'DELETE_EVENT') {
if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); }
return void deleteEvent(ctx, data, clientId, cb);
}
};
return calendar;
};
return Calendar;
});