diff --git a/www/calendar/export.js b/www/calendar/export.js new file mode 100644 index 000000000..e470c5d33 --- /dev/null +++ b/www/calendar/export.js @@ -0,0 +1,200 @@ +// This file is used when a user tries to export the entire CryptDrive. +// Calendars will be exported using this format instead of plain text. +define([ + '/customize/pages.js', +], function (Pages) { + var module = {}; + + var getICSDate = function (str) { + var date = new Date(str); + + var m = date.getUTCMonth() + 1; + var d = date.getUTCDate(); + var h = date.getUTCHours(); + var min = date.getUTCMinutes(); + + var year = date.getUTCFullYear().toString(); + var month = m < 10 ? "0" + m : m.toString(); + var day = d < 10 ? "0" + d : d.toString(); + var hours = h < 10 ? "0" + h : h.toString(); + var minutes = min < 10 ? "0" + min : min.toString(); + + return year + month + day + "T" + hours + minutes + "00Z"; + } + + + var getDate = function (str, end) { + var date = new Date(str); + if (end) { + date.setDate(date.getDate() + 1); + } + var m = date.getUTCMonth() + 1; + var d = date.getUTCDate(); + + var year = date.getUTCFullYear().toString(); + var month = m < 10 ? "0" + m : m.toString(); + var day = d < 10 ? "0" + d : d.toString(); + + return year+month+day; + }; + + var MINUTE = 60; + var HOUR = MINUTE * 60; + var DAY = HOUR * 24; + + + module.main = function (userDoc) { + var content = userDoc.content; + var md = userDoc.metadata; + + var ICS = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//CryptPad//CryptPad Calendar '+Pages.versionString+'//EN', + 'METHOD:PUBLISH', + ]; + + Object.keys(content).forEach(function (uid) { + var data = content[uid]; + // DTSTAMP: now... + // UID: uid + var start, end; + if (data.isAllDay && data.startDay && data.endDay) { + start = "DTSTART;VALUE=DATE:" + getDate(data.startDay); + end = "DTEND;VALUE=DATE:" + getDate(data.endDay, true); + } else { + start = "DTSTART:"+getICSDate(data.start); + end = "DTEND:"+getICSDate(data.end); + } + + Array.prototype.push.apply(ICS, [ + 'BEGIN:VEVENT', + 'DTSTAMP:'+getICSDate(+new Date()), + 'UID:'+uid, + start, + end, + 'SUMMARY:'+ data.title, + 'LOCATION:'+ data.location, + ]); + + if (Array.isArray(data.reminders)) { + data.reminders.forEach(function (valueMin) { + var time = valueMin * 60; + var days = Math.floor(time / DAY); + time -= days * DAY; + var hours = Math.floor(time / HOUR); + time -= hours * HOUR; + var minutes = Math.floor(time / MINUTE); + time -= minutes * MINUTE; + var seconds = time; + + var str = "-P" + days + "D"; + if (hours || minutes || seconds) { + str += "T" + hours + "H" + minutes + "M" + seconds + "S"; + } + Array.prototype.push.apply(ICS, [ + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:This is an event reminder', + 'TRIGGER:'+str, + 'END:VALARM' + ]); + }); + } + + if (Array.isArray(data.cp_hidden)) { + Array.prototype.push.apply(ICS, data.cp_hidden); + } + + ICS.push('END:VEVENT'); + }); + + ICS.push('END:VCALENDAR'); + + return new Blob([ ICS.join('\n') ], { type: 'text/calendar;charset=utf-8' }); + }; + + module.import = function (content, id, cb) { + require(['/lib/ical.min.js'], function () { + var ICAL = window.ICAL; + var res = {}; + + try { + var jcalData = ICAL.parse(content); + var vcalendar = new ICAL.Component(jcalData); + } catch (e) { + return void cb(e); + } + + var method = vcalendar.getFirstPropertyValue('method'); + if (method !== "PUBLISH") { return void cb('NOT_SUPPORTED'); } + + var events = vcalendar.getAllSubcomponents('vevent'); + events.forEach(function (ev) { + var uid = ev.getFirstPropertyValue('uid'); + if (!uid) { return; } + + // Get start and end time + var isAllDay = false; + var start = ev.getFirstPropertyValue('dtstart'); + var end = ev.getFirstPropertyValue('dtend'); + if (start.isDate && end.isDate) { + isAllDay = true; + start = String(start); + end.adjust(-1); // Substract one day + end = String(end); + } else { + start = +start.toJSDate(); + end = +end.toJSDate(); + } + + // Store other properties + var used = ['dtstart', 'dtend', 'uid', 'summary', 'location', 'dtstamp']; + var hidden = []; + ev.getAllProperties().forEach(function (p) { + if (used.indexOf(p.name) !== -1) { return; } + // This is an unused property + hidden.push(p.toICALString()); + }); + + // Get reminders + var reminders = []; + ev.getAllSubcomponents('valarm').forEach(function (al) { + var action = al.getFirstPropertyValue('action'); + if (action !== 'DISPLAY') { + // XXX email: maybe keep a notification in CryptPad? + hidden.push(al.toString()); + return; + } + var trigger = al.getFirstPropertyValue('trigger'); + var minutes = -trigger.toSeconds() / 60; + if (reminders.indexOf(minutes) === -1) { reminders.push(minutes); } + }); + + // Create event + res[uid] = { + calendarId: id, + id: uid, + category: 'time', + title: ev.getFirstPropertyValue('summary'), + location: ev.getFirstPropertyValue('location'), + isAllDay: isAllDay, + start: start, + end: end, + reminders: reminders, + cp_hidden: hidden + }; + + if (!hidden.length) { delete res[uid].cp_hidden; } + if (!reminders.length) { delete res[uid].reminders; } + + }); + + cb(null, res); + }); + }; + + return module; +}); + + diff --git a/www/calendar/inner.js b/www/calendar/inner.js index 90b2e0e45..f6a4d4988 100644 --- a/www/calendar/inner.js +++ b/www/calendar/inner.js @@ -16,12 +16,14 @@ define([ '/customize/messages.js', '/customize/application_config.js', '/lib/calendar/tui-calendar.min.js', + '/calendar/export.js', '/common/inner/share.js', '/common/inner/access.js', '/common/inner/properties.js', '/common/jscolor.js', + '/bower_components/file-saver/FileSaver.min.js', 'css!/lib/calendar/tui-calendar.min.css', 'css!/bower_components/components-font-awesome/css/font-awesome.min.css', 'less!/calendar/app-calendar.less', @@ -43,9 +45,11 @@ define([ Messages, AppConfig, Calendar, + Export, Share, Access, Properties ) { + var SaveAs = window.saveAs; var APP = window.APP = { calendars: {} }; @@ -113,6 +117,12 @@ Messages.calendar_noNotification = "None"; cb(null, obj); }); }; + var importICSCalendar = function (data, cb) { + APP.module.execCommand('IMPORT_ICS', data, function (obj) { + if (obj && obj.error) { return void cb(obj.error); } + cb(null, obj); + }); + }; var newEvent = function (data, cb) { APP.module.execCommand('CREATE_EVENT', data, function (obj) { if (obj && obj.error) { return void cb(obj.error); } @@ -469,6 +479,79 @@ Messages.calendar_noNotification = "None"; return true; } }); + + if (!data.readOnly) { + options.push({ + tag: 'a', + attributes: { + 'class': 'fa fa-upload', + }, + content: h('span', Messages.importButton), + action: function (e) { + UIElements.importContent('text/calendar', function (res) { + Export.import(res, id, function (err, json) { + if (err) { return void UI.warn(Messages.importError); } + importICSCalendar({ + id: id, + json: json + }, function (err) { + if (err) { return void UI.warn(Messages.error); } + UI.log(Messages.saved); + }); + + }); + }, { + accept: ['.ics'] + })(); + return true; + } + }); + } + options.push({ + tag: 'a', + attributes: { + 'class': 'fa fa-download', + }, + content: h('span', Messages.exportButton), + action: function (e) { + e.stopPropagation(); + var cal = APP.calendars[id]; + var suggestion = Util.find(cal, ['content', 'metadata', 'title']); + var types = []; + types.push({ + tag: 'a', + attributes: { + 'data-value': '.ics', + 'href': '#' + }, + content: '.ics' + }); + var dropdownConfig = { + text: '.ics', // Button initial text + caretDown: true, + options: types, // Entries displayed in the menu + isSelect: true, + initialValue: '.ics', + common: common + }; + var $select = UIElements.createDropdown(dropdownConfig); + UI.prompt(Messages.exportPrompt, + Util.fixFileName(suggestion), function (filename) + { + if (!(typeof(filename) === 'string' && filename)) { return; } + var ext = $select.getValue(); + filename = filename + ext; + var blob = Export.main(cal.content); + SaveAs(blob, filename); + }, { + typeInput: $select[0] + }); + return true; + } + }); + + + options.push({ tag: 'a', attributes: { diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 6ec1cf420..578e716e3 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -127,7 +127,7 @@ define([ dcAlert = undefined; }; - var importContent = function (type, f, cfg) { + var importContent = UIElements.importContent = function (type, f, cfg) { return function () { var $files = $('', {type:"file"}); if (cfg && cfg.accept) { diff --git a/www/common/outer/calendar.js b/www/common/outer/calendar.js index 3ae006d63..1bdde4724 100644 --- a/www/common/outer/calendar.js +++ b/www/common/outer/calendar.js @@ -516,6 +516,22 @@ define([ }); }; + var importICSCalendar = function (ctx, data, cId, cb) { + var id = data.id; + var c = ctx.calendars[id]; + if (!c || !c.proxy) { return void cb({error: "ENOENT"}); } + var json = data.json; + c.proxy.content = c.proxy.content || {}; + Object.keys(json).forEach(function (uid) { + c.proxy.content[uid] = json[uid]; + }); + + Realtime.whenRealtimeSyncs(c.lm.realtime, function () { + sendUpdate(ctx, c); + cb(); + }); + }; + var openCalendar = function (ctx, data, cId, cb) { var secret = Hash.getSecrets('calendar', data.hash, data.password); var hash = Hash.getEditHashFromKeys(secret); @@ -884,6 +900,10 @@ define([ if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); } return void importCalendar(ctx, data, clientId, cb); } + if (cmd === 'IMPORT_ICS') { + if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); } + return void importICSCalendar(ctx, data, clientId, cb); + } if (cmd === 'ADD') { if (ctx.store.offline) { return void cb({error: 'OFFLINE'}); } return void addCalendar(ctx, data, clientId, cb);