// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors // // SPDX-License-Identifier: AGPL-3.0-or-later // 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', '/common/common-util.js', '/common/recurrence.js', '/lib/ical.min.js' ], function (Pages, Util, Rec) { 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 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 getDT = function (data) { var start, end; if (data.isAllDay) { var startDate = new Date(data.start); var endDate = new Date(data.end); data.startDay = data.startDay || (startDate.getFullYear() + '-' + (startDate.getMonth()+1) + '-' + startDate.getDate()); data.endDay = data.endDay || (endDate.getFullYear() + '-' + (endDate.getMonth()+1) + '-' + endDate.getDate()); 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); } return { start: start, end: end }; }; var getRRule = function (data) { if (!data.recurrenceRule || !data.recurrenceRule.freq) { return; } var r = data.recurrenceRule; var rrule = "RRULE:"; rrule += "FREQ="+r.freq.toUpperCase(); Object.keys(r).forEach(function (k) { if (k === "freq") { return; } if (k === "by") { Object.keys(r.by).forEach(function (_k) { rrule += ";BY"+_k.toUpperCase()+"="+r.by[_k]; }); return; } if (k === "until") { rrule += ";"+k.toUpperCase()+"="+getICSDate(r[k]); return; } rrule += ";"+k.toUpperCase()+"="+r[k]; }); return rrule; }; var addEvent = function (arr, data, recId) { var uid = data.id; var dt = getDT(data); var start = dt.start; var end = dt.end; var rrule = getRRule(data); var formatDescription = function(str) { var componentName = 'DESCRIPTION:'; var result = componentName + str.replace(/\n/g, ["\\n"]); // In RFC5545: https://www.rfc-editor.org/rfc/rfc5545#section-3.1 result = window.ICAL.helpers.foldline(result); return result; }; Array.prototype.push.apply(arr, [ 'BEGIN:VEVENT', 'DTSTAMP:'+getICSDate(+new Date()), 'UID:'+uid, start, end, recId, rrule, 'SUMMARY:'+ data.title, 'LOCATION:'+ data.location, formatDescription(data.body), ].filter(Boolean)); 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(arr, [ 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:This is an event reminder', 'TRIGGER:'+str, 'END:VALARM' ]); }); } if (Array.isArray(data.cp_hidden)) { Array.prototype.push.apply(arr, data.cp_hidden); } arr.push('END:VEVENT'); }; var applyChanges = function (base, changes) { var applyDiff = function (obj, k) { var diff = obj[k]; // Diff is always compared to origin start/end var d = new Date(base[k]); d.setDate(d.getDate() + diff.d); d.setHours(d.getHours() + diff.h); d.setMinutes(d.getMinutes() + diff.m); base[k] = +d; }; Object.keys(changes || {}).forEach(function (k) { if (k === "start" || k === "end") { return applyDiff(changes, k); } base[k] = changes[k]; }); }; var prev = data; // Check if we have "one-time" or "from date" updates. // "One-time" updates will be added accordingly to the ICS specs // "From date" updates will be added as new events and will add // an "until" value to the initial event's RRULE var toAdd = []; if (data.recurrenceRule && data.recurrenceRule.freq && data.recUpdate) { var ru = data.recUpdate; var _all = {}; var duration = data.end - data.start; var all = Rec.getAllOccurrences(data); // "false" if infinite Object.keys(ru.from || {}).forEach(function (d) { if (!Object.keys(ru.from[d] || {}).length) { return; } _all[d] = _all[d] || {}; _all[d].from = ru.from[d]; }); Object.keys(ru.one || {}).forEach(function (d) { if (!Object.keys(ru.one[d] || {}).length) { return; } _all[d] = _all[d] || {}; _all[d].one = ru.one[d]; }); Object.keys(_all).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (d) { d = Number(d); var r = prev.recurrenceRule; // This rule won't apply if we've reached "until" or "count" var idx = all && all.indexOf(d); if (all && idx === -1) { // Make sure we don't have both count and until if (all.length === r.count) { delete r.until; } else { delete r.count; } return; } var ud = _all[d]; if (ud.from) { // "From" updates are not supported by ICS: make a new event var _new = Util.clone(prev); r.until = d - 1; // Stop previous recursion delete r.count; addEvent(ICS, prev, null); // Add previous event Array.prototype.push.apply(ICS, toAdd); // Add individual updates toAdd = []; prev = _new; if (all) { all = all.slice(idx); } // if we updated the recurrence rule, count is reset, nothing to do // if we didn't update the recurrence, we need to fix the count var _r = _new.recurrenceRule; if (all && !ud.from.recurrenceRule && _r && _r.count) { _r.count -= idx; } prev.start = d; prev.end = d + duration; prev.id = Util.uid(); applyChanges(prev, ud.from); duration = prev.end - prev.start; } if (ud.one) { // Add update var _one = Util.clone(prev); _one.start = d; _one.end = d + duration; applyChanges(_one, ud.one); var recId = "RECURRENCE-ID:"+getICSDate(+d); delete _one.recurrenceRule; addEvent(toAdd, _one, recId); // Add updated event } }); } addEvent(ICS, prev); Array.prototype.push.apply(ICS, toAdd); // Add individual updates }); ICS.push('END:VCALENDAR'); return new Blob([ ICS.join('\r\n') ], { type: 'text/calendar;charset=utf-8' }); }; module.import = function (content, id, cb) { var ICAL = window.ICAL; var res = {}; var vcalendar; try { var jcalData = ICAL.parse(content); vcalendar = new ICAL.Component(jcalData); } catch (e) { console.error(e); return void cb(e); } //var method = vcalendar.getFirstPropertyValue('method'); //if (method !== "PUBLISH") { return void cb('NOT_SUPPORTED'); } // Add all timezones in iCalendar object to TimezoneService // if they are not already registered. var timezones = vcalendar.getAllSubcomponents("vtimezone"); timezones.forEach(function (timezone) { if (!(ICAL.TimezoneService.has(timezone.getFirstPropertyValue("tzid")))) { ICAL.TimezoneService.register(timezone); } }); 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'); var duration = ev.getFirstPropertyValue('duration'); if (!end && !duration) { if (start.isDate) { end = start.clone(); end.adjust(1); // Add one day } else { end = start.clone(); } } else if (!end) { end = start.clone(); end.addDuration(duration); } 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', 'description', 'dtstamp', 'rrule', 'recurrence-id']; 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') { // Email notification: keep it in "hidden" and create a cryptpad notification hidden.push(al.toString()); } var trigger = al.getFirstPropertyValue('trigger'); var minutes = trigger && trigger.toSeconds ? (-trigger.toSeconds() / 60) : 0; if (reminders.indexOf(minutes) === -1) { reminders.push(minutes); } }); // Get recurrence rule var rrule = ev.getFirstPropertyValue('rrule'); var rec; if (rrule && rrule.freq) { rec = {}; rec.freq = rrule.freq.toLowerCase(); if (rrule.interval) { rec.interval = rrule.interval; } if (rrule.count) { rec.count = rrule.count; } if (Object.keys(rrule).includes('wkst')) { rec.wkst = (rrule.wkst + 6) % 7; } if (rrule.until) { rec.until = +new Date(rrule.until); } Object.keys(rrule.parts || {}).forEach(function (k) { rec.by = rec.by || {}; var _k = k.toLowerCase().slice(2); // "BYDAY" ==> "day" rec.by[_k] = rrule.parts[k]; }); } // Create event var obj = { calendarId: id, id: uid, category: 'time', title: ev.getFirstPropertyValue('summary'), location: ev.getFirstPropertyValue('location'), body: ev.getFirstPropertyValue('description'), isAllDay: isAllDay, start: start, end: end, reminders: reminders, cp_hidden: hidden, }; if (rec) { obj.recurrenceRule = rec; } if (!hidden.length) { delete obj.cp_hidden; } if (!reminders.length) { delete obj.reminders; } var recId = ev.getFirstPropertyValue('recurrence-id'); if (recId) { setTimeout(function () { if (!res[uid]) { return; } var old = res[uid]; var time = +new Date(recId); var diff = {}; var from = {}; Object.keys(obj).forEach(function (k) { if (JSON.stringify(old[k]) === JSON.stringify(obj[k])) { return; } if (['start','end'].includes(k)) { diff[k] = Rec.diffDate(old[k], obj[k]); return; } if (k === "recurrenceRule") { from[k] = obj[k]; return; } diff[k] = obj[k]; }); old.recUpdate = old.recUpdate || {one:{},from:{}}; if (Object.keys(from).length) { old.recUpdate.from[time] = from; } if (Object.keys(diff).length) { old.recUpdate.one[time] = diff; } }); return; } res[uid] = obj; }); // setTimeout to make sure we call back after the "recurrence-id" setTimeout // are called setTimeout(function () { cb(null, res); }); }; return module; });