From 65b00736bc2ecd1643f00dec247d64fc21886484 Mon Sep 17 00:00:00 2001 From: yflory Date: Thu, 15 Sep 2022 13:15:27 +0200 Subject: [PATCH] Support recurrence rules when importing or exporting ICS calendars --- www/calendar/export.js | 287 ++++++++++++++++++++++++++++------- www/calendar/inner.js | 33 +--- www/calendar/recurrence.js | 82 ++++++++-- www/common/outer/calendar.js | 24 ++- 4 files changed, 329 insertions(+), 97 deletions(-) diff --git a/www/calendar/export.js b/www/calendar/export.js index 307abad33..b62de0664 100644 --- a/www/calendar/export.js +++ b/www/calendar/export.js @@ -2,7 +2,9 @@ // Calendars will be exported using this format instead of plain text. define([ '/customize/pages.js', -], function (Pages) { + '/common/common-util.js', + '/calendar/recurrence.js' +], function (Pages, Util, Rec) { var module = {}; var getICSDate = function (str) { @@ -57,60 +59,197 @@ define([ 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); - } + 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 + }; + }; - 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"; + 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; + } + 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); + + Array.prototype.push.apply(arr, [ + 'BEGIN:VEVENT', + 'DTSTAMP:'+getICSDate(+new Date()), + 'UID:'+uid, + start, + end, + recId, + rrule, + 'SUMMARY:'+ data.title, + 'LOCATION:'+ data.location, + ].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 = getICSDate(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 } - 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'); + addEvent(ICS, prev); + Array.prototype.push.apply(ICS, toAdd); // Add individual updates }); ICS.push('END:VCALENDAR'); - return new Blob([ ICS.join('\n') ], { type: 'text/calendar;charset=utf-8' }); + return new Blob([ ICS.join('\r\n') ], { type: 'text/calendar;charset=utf-8' }); }; module.import = function (content, id, cb) { @@ -171,7 +310,7 @@ define([ } // Store other properties - var used = ['dtstart', 'dtend', 'uid', 'summary', 'location', 'dtstamp']; + var used = ['dtstart', 'dtend', 'uid', 'summary', 'location', 'dtstamp', 'rrule', 'recurrence-id']; var hidden = []; ev.getAllProperties().forEach(function (p) { if (used.indexOf(p.name) !== -1) { return; } @@ -192,8 +331,25 @@ define([ 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 - res[uid] = { + var obj = { calendarId: id, id: uid, category: 'time', @@ -203,12 +359,41 @@ define([ start: start, end: end, reminders: reminders, - cp_hidden: hidden + cp_hidden: hidden, }; + if (rec) { obj.recurrenceRule = rec; } - if (!hidden.length) { delete res[uid].cp_hidden; } - if (!reminders.length) { delete res[uid].reminders; } + 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; }); cb(null, res); diff --git a/www/calendar/inner.js b/www/calendar/inner.js index acbf94bb4..a1a21f9ec 100644 --- a/www/calendar/inner.js +++ b/www/calendar/inner.js @@ -892,32 +892,7 @@ ICS ==> create a new event with the same UID and a RECURRENCE-ID field (with a v */ - var diffDate = function (oldTime, newTime) { - var n = new Date(newTime); - var o = new Date(oldTime); - - // Diff Days - var d = 0; - var mult = n < o ? -1 : 1; - while (n.toLocaleDateString() !== o.toLocaleDateString() || mult >= 10000) { - n.setDate(n.getDate() - mult); - d++; - } - d = mult * d; - - // Diff hours - n = new Date(newTime); - var h = n.getHours() - o.getHours(); - - // Diff minutes - var m = n.getMinutes() - o.getMinutes(); - - return { - d: d, - h: h, - m: m - }; - }; + var diffDate = Rec.diffDate; var makeCalendar = function (view) { var store = window.cryptpadStore; @@ -1018,7 +993,7 @@ ICS ==> create a new event with the same UID and a RECURRENCE-ID field (with a v changes.reminders = reminders; } - var oldRec = originalEvent.recurrenceRule; + var oldRec = ev.recurrenceRule; var rec = APP.recurrenceRule; if (JSONSortify(oldRec || '') !== JSONSortify(rec)) { changes.recurrenceRule = rec; @@ -1068,7 +1043,7 @@ ICS ==> create a new event with the same UID and a RECURRENCE-ID field (with a v Messages.calendar_rec_warn_delall = "The recurrence rule was deleted. Only the original event on {0} will be kept."; Messages.calendar_rec_warn_del = "The recurrence rule was deleted. All occurences after the selected one will be removed."; Messages.calendar_rec_warn_updateall = "The recurrence rule was modified. Only the original event on {0} will be kept and new occurences will be created."; - Messages.calendar_rec_warn_update = "The recurrence rule was modified. All occurences after the selected one will be removed and recreated with the new rule."; + Messages.calendar_rec_warn_update = "The recurrence rule was modified. All occurences after the selected one will be removed and recreated with the new rule."; // XXX NOTE: the "count" value will be reset // Confirm modal: select which recurring events to update if (!Object.keys(changes).length) { return void afterConfirm(); } @@ -1567,7 +1542,7 @@ APP.recurrenceRule = { }; var addUpdate = function () { if (!updatedOn) { return; } - var d = new Date(APP.recurrenceRule._next).toLocaleDateString(); + var d = new Date(updatedOn).toLocaleDateString(); $translated.append(h('div', Messages._getKey('calendar_rec_updated', [d]))); }; var addTranslation = function () { diff --git a/www/calendar/recurrence.js b/www/calendar/recurrence.js index a9b3358ce..a70f9b795 100644 --- a/www/calendar/recurrence.js +++ b/www/calendar/recurrence.js @@ -219,14 +219,27 @@ define([ var all = []; if (![0,1,2,3,4,5,6].includes(day)) { return false; } - var filterPos = function () { + var filterPos = function (m) { if (!pos) { return; } - if (pos < 0) { - pos = all.length + pos; - } else { - pos--; // An array starts at 0 but the recurrence rule starts at 1 - } - all = [all[pos]]; + + var _all = []; + 'aaaaaaaaaaaa'.split('').some(function (a, i) { + if (typeof(m) !== "undefined" && i !== m) { return; } + + var _pos; + var tmp = all.filter(function (d) { + return d.getMonth() === i; + }); + if (pos < 0) { + _pos = tmp.length + pos; + } else { + _pos = pos - 1; // An array starts at 0 but the recurrence rule starts at 1 + } + _all.push(tmp[_pos]); + + return typeof(m) !== "undefined" && i === m; + }); + all = _all.filter(Boolean); // The "5th" {day} won't always exist }; var tmp; @@ -250,7 +263,7 @@ define([ all.push(new Date(+tmp)); tmp.setDate(tmp.getDate()+7); } - filterPos(); + filterPos(m); return all; } @@ -374,7 +387,7 @@ define([ var origin = Util.clone(_origin); var oS = new Date(origin.start); - var id = origin.id; + var id = origin.id.split('|')[0]; // Use same cache when updating recurrence rule // "uid" is used for the cache var uid = s.toLocaleDateString(); @@ -686,7 +699,7 @@ define([ evS = +_evS; obj = _ev; rule = nextRule; - // XXX + nextRule = nextRules.shift(); return true; } }); @@ -700,6 +713,55 @@ define([ }); return toAdd; }; + Rec.getAllOccurrences = function (ev) { + if (!ev.recurrenceRule) { return [ev.start]; } + var r = ev.recurrenceRule; + // In case of infinite recursion, we can't get all + if (!r.until && !r.count) { return false; } + var all = [ev.start]; + var d = new Date(ev.start); + d.setDate(15); // Make sure we won't skip a month if the event starts on day > 28 + var toAdd = []; + + var i = 0; + var check = function () { + return r.count ? (all.length < r.count) : (+d <= r.until); + }; + while ((toAdd = Rec.getRecurring([Rec.getMonthId(d)], [ev])) && check() && i < (r.count*12)) { + Array.prototype.push.apply(all, toAdd.map(function (_ev) { return _ev.start; })); + d.setMonth(d.getMonth() + 1); + i++; + } + + return all; + }; + + Rec.diffDate = function (oldTime, newTime) { + var n = new Date(newTime); + var o = new Date(oldTime); + + // Diff Days + var d = 0; + var mult = n < o ? -1 : 1; + while (n.toLocaleDateString() !== o.toLocaleDateString() || mult >= 10000) { + n.setDate(n.getDate() - mult); + d++; + } + d = mult * d; + + // Diff hours + n = new Date(newTime); + var h = n.getHours() - o.getHours(); + + // Diff minutes + var m = n.getMinutes() - o.getMinutes(); + + return { + d: d, + h: h, + m: m + }; + }; var sortUpdate = function (obj) { return Object.keys(obj).sort(function (d1, d2) { diff --git a/www/common/outer/calendar.js b/www/common/outer/calendar.js index 8afe6f184..6537fe853 100644 --- a/www/common/outer/calendar.js +++ b/www/common/outer/calendar.js @@ -408,10 +408,20 @@ define([ c.lm = lm; var proxy = c.proxy = lm.proxy; + var _updateCalled = false; + var _update = function () { + if (_updateCalled) { return; } + _updateCalled = true; + setTimeout(function () { + _updateCalled = false; + update(); + }); + }; + lm.proxy.on('cacheready', function () { if (!proxy.metadata) { return; } c.cacheready = true; - setTimeout(update); + _update(); if (cb) { cb(null, lm.proxy); } addInitialReminders(ctx, channel, cfg.lastVisitNotif); }).on('ready', function (info) { @@ -428,12 +438,12 @@ define([ title: data.title }; } - setTimeout(update); + _update(); if (cb) { cb(null, lm.proxy); } addInitialReminders(ctx, channel, cfg.lastVisitNotif); }).on('change', [], function () { if (!c.ready) { return; } - setTimeout(update); + _update(); }).on('change', ['content'], function (o, n, p) { if (p.length === 2 && n && !o) { // New event return void addReminders(ctx, channel, n); @@ -457,7 +467,7 @@ define([ }); } }).on('remove', ['content'], function (x, p) { - setTimeout(update); + _update(); if ((p.length >= 3 && p[2] === 'reminders') || (p.length >= 6 && p[5] === 'reminders')) { return void setTimeout(function () { @@ -471,10 +481,10 @@ define([ updateLocalCalendars(ctx, c, md); }).on('disconnect', function () { c.offline = true; - setTimeout(update); + _update(); }).on('reconnect', function () { c.offline = false; - setTimeout(update); + _update(); }).on('error', function (info) { if (!info || !info.error) { return; } if (info.error === "EDELETED" ) { @@ -482,7 +492,7 @@ define([ } if (info.error === "ERESTRICTED" ) { c.restricted = true; - setTimeout(update); + _update(); } cb(info); });