diff --git a/customize.dist/src/less2/include/forms.less b/customize.dist/src/less2/include/forms.less index 969c873c7..ffe061fa3 100644 --- a/customize.dist/src/less2/include/forms.less +++ b/customize.dist/src/less2/include/forms.less @@ -122,7 +122,7 @@ &.small { line-height: initial; padding: 5px; - height: auto; + height: auto !important; } &:hover, &:not(:disabled):not(.disabled):active, &:focus { diff --git a/www/calendar/app-calendar.less b/www/calendar/app-calendar.less index ccb4564ab..407ec8004 100644 --- a/www/calendar/app-calendar.less +++ b/www/calendar/app-calendar.less @@ -178,6 +178,44 @@ } } + .cp-calendar-add-notif { + flex-flow: column; + align-items: baseline !important; + margin: 10px 0; + .cp-notif-label { + color: @cp_sidebar-hint; + margin-right: 20px; + } + * { + font-size: @colortheme_app-font-size; + font-weight: normal; + } + & > div { + display: flex; + } + .cp-calendar-notif-list { + display: flex; + flex-flow: column; + .cp-notif-entry { + span:not(:last-child) { + margin-right: 20px; + } + } + } + .cp-notif-empty { + display: none; + } + .cp-calendar-notif-list:empty ~ .cp-notif-empty { + display: block; + } + .cp-calendar-notif-form { + align-items: center; + input { + width: 100px; + } + } + } + .cp-calendar-close { height: auto; line-height: initial; diff --git a/www/calendar/export.js b/www/calendar/export.js new file mode 100644 index 000000000..6019290c6 --- /dev/null +++ b/www/calendar/export.js @@ -0,0 +1,199 @@ +// 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') { + // Email notification: keep it in "hidden" and create a cryptpad notification + hidden.push(al.toString()); + } + 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 bb5bed41c..43e5f15df 100644 --- a/www/calendar/inner.js +++ b/www/calendar/inner.js @@ -1,5 +1,6 @@ define([ 'jquery', + 'json.sortify', '/bower_components/chainpad-crypto/crypto.js', '/common/toolbar.js', '/bower_components/nthen/index.js', @@ -15,17 +16,20 @@ 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', ], function ( $, + JSONSortify, Crypto, Toolbar, nThen, @@ -41,9 +45,11 @@ define([ Messages, AppConfig, Calendar, + Export, Share, Access, Properties ) { + var SaveAs = window.saveAs; var APP = window.APP = { calendars: {} }; @@ -77,6 +83,14 @@ Messages.calendar_loc = "Location"; Messages.calendar_location = "Location: {0}"; Messages.calendar_allDay = "All day"; +Messages.calendar_minutes = "Minutes"; +Messages.calendar_hours = "Hours"; +Messages.calendar_days = "Days"; + +Messages.calendar_notifications = "Reminders"; +Messages.calendar_addNotification = "Add reminder"; +Messages.calendar_noNotification = "None"; + var onCalendarsUpdate = Util.mkEvent(); var newCalendar = function (data, cb) { @@ -103,6 +117,12 @@ Messages.calendar_allDay = "All day"; 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); } @@ -230,11 +250,17 @@ Messages.calendar_allDay = "All day"; })()) { getTime = undefined; } var templates = { + popupSave: function (obj) { + APP.editModalData = obj.data && obj.data.root; + return Messages.settings_save; + }, + popupUpdate: function(obj) { + APP.editModalData = obj.data && obj.data.root; + return Messages.calendar_update; + }, monthGridHeaderExceed: function(hiddenSchedules) { return '' + Messages._getKey('calendar_more', [hiddenSchedules]) + ''; }, - popupSave: function () { return Messages.settings_save; }, - popupUpdate: function() { return Messages.calendar_update; }, popupEdit: function() { return Messages.poll_edit; }, popupDelete: function() { return Messages.kanban_delete; }, popupDetailLocation: function(schedule) { @@ -453,6 +479,79 @@ Messages.calendar_allDay = "All day"; 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: { @@ -498,7 +597,7 @@ Messages.calendar_allDay = "All day"; if (cal.owned) { key += Messages.calendar_deleteOwned; } - UI.confirm(Messages.calendar_deleteConfirm, function (yes) { + UI.confirm(key, function (yes) { if (!yes) { return; } deleteCalendar({ id: id, @@ -681,6 +780,7 @@ Messages.calendar_allDay = "All day"; // ie: recurrenceRule: DAILY|{uid} // Use template to hide "recurrenceRule" from the detailPopup or at least to use // a non technical value + var reminders = APP.notificationsEntries; var startDate = event.start._date; var endDate = event.end._date; @@ -694,6 +794,7 @@ Messages.calendar_allDay = "All day"; start: +startDate, isAllDay: event.isAllDay, end: +endDate, + reminders: reminders, }; newEvent(schedule, function (err) { @@ -711,6 +812,12 @@ Messages.calendar_allDay = "All day"; if (changes.start) { changes.start = +new Date(changes.start._date); } var old = event.schedule; + var oldReminders = Util.find(APP.calendars, [old.calendarId, 'content', 'content', old.id, 'reminders']); + var reminders = APP.notificationsEntries; + if (JSONSortify(oldReminders || []) !== JSONSortify(reminders)) { + changes.reminders = reminders; + } + updateEvent({ ev: old, changes: changes @@ -811,6 +918,113 @@ Messages.calendar_allDay = "All day"; }; + var parseNotif = function (minutes) { + var res = { + unit: 'minutes', + value: minutes + }; + var hours = minutes / 60; + if (!Number.isInteger(hours)) { return res; } + res.unit = 'hours'; + res.value = hours; + var days = hours / 24; + if (!Number.isInteger(days)) { return res; } + res.unit = 'days'; + res.value = days; + return res; + }; + var getNotificationDropdown = function () { + var ev = APP.editModalData; + var calId = ev.selectedCal.id; + // XXX DEFAULT HERE [10] ==> 10 minutes before the event + var oldReminders = Util.find(APP.calendars, [calId, 'content', 'content', ev.id, 'reminders']) || [10]; + APP.notificationsEntries = []; + var number = h('input.tui-full-calendar-content', { + type: "number", + value: 10, + min: 1, + max: 60 + }); + var $number = $(number); + var options = ['minutes', 'hours', 'days'].map(function (k) { + return { + tag: 'a', + attributes: { + 'class': 'cp-calendar-reminder', + 'data-value': k, + 'href': '#', + }, + content: Messages['calendar_'+k] + // Messages.calendar_minutes + // Messages.calendar_hours + // Messages.calendar_days + }; + }); + var dropdownConfig = { + text: Messages.calendar_minutes, + options: options, // Entries displayed in the menu + isSelect: true, + common: common, + buttonCls: 'btn btn-secondary', + caretDown: true, + }; + + var $block = UIElements.createDropdown(dropdownConfig); + $block.setValue('minutes'); + var $types = $block.find('a'); + $types.click(function () { + var mode = $(this).attr('data-value'); + var max = mode === "minutes" ? 60 : 24; + $number.attr('max', max); + if ($number.val() > max) { $number.val(max); } + }); + var addNotif = h('button.btn.btn-primary.fa.fa-plus'); + var $list = $(h('div.cp-calendar-notif-list')); + var listContainer = h('div.cp-calendar-notif-list-container', [ + h('span.cp-notif-label', Messages.calendar_notifications), + $list[0], + h('span.cp-notif-empty', Messages.calendar_noNotification) + ]); + var addNotification = function (unit, value) { + var unitValue = (unit === "minutes") ? 1 : (unit === "hours" ? 60 : (60*24)); + var del = h('button.btn.btn-danger.small.fa.fa-times'); + var minutes = value * unitValue; + if ($list.find('[data-minutes="'+minutes+'"]').length) { return; } + var span = h('span.cp-notif-entry', { + 'data-minutes': minutes + }, [ + h('span', value), + h('span', Messages['calendar_'+unit]), + del + ]); + $(del).click(function () { + $(span).remove(); + var idx = APP.notificationsEntries.indexOf(minutes); + APP.notificationsEntries.splice(idx, 1); + }); + $list.append(span); + APP.notificationsEntries.push(minutes); + }; + $(addNotif).click(function () { + var unit = $block.getValue(); + var value = $number.val(); + addNotification(unit, value); + }); + oldReminders.forEach(function (minutes) { + var p = parseNotif(minutes); + addNotification(p.unit, p.value); + }); + return h('div.tui-full-calendar-popup-section.cp-calendar-add-notif', [ + listContainer, + h('div.cp-calendar-notif-form', [ + h('span.cp-notif-label', Messages.calendar_addNotification), + number, + $block[0], + addNotif + ]) + ]); + }; + var createToolbar = function () { var displayed = ['useradmin', 'newpad', 'limit', 'pageTitle', 'notifications']; var configTb = { @@ -900,6 +1114,10 @@ Messages.calendar_allDay = "All day"; var isUpdate = Boolean($el.find('#tui-full-calendar-schedule-title').val()); if (!isUpdate) { $el.find('.tui-full-calendar-dropdown-menu li').first().click(); } + var $button = $el.find('.tui-full-calendar-section-button-save'); + var div = getNotificationDropdown(); + $button.before(div); + var $cbox = $el.find('#tui-full-calendar-schedule-allday'); var $start = $el.find('.tui-full-calendar-section-start-date'); var $dash = $el.find('.tui-full-calendar-section-date-dash'); diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 602c52100..471bd5011 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/cryptpad-common.js b/www/common/cryptpad-common.js index 04dabb02d..7d6fee8b7 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -2288,7 +2288,8 @@ define([ cache: rdyCfg.cache, noDrive: rdyCfg.noDrive, disableCache: localStorage['CRYPTPAD_STORE|disableCache'], - driveEvents: !rdyCfg.noDrive //rdyCfg.driveEvents // Boolean + driveEvents: !rdyCfg.noDrive, //rdyCfg.driveEvents // Boolean + lastVisit: Number(localStorage.lastVisit) || undefined }; common.userHash = userHash; @@ -2560,6 +2561,12 @@ define([ AppConfig.afterLogin(common, waitFor()); } }).nThen(function () { + // Last visit is used to warn you about missed events from your calendars + localStorage.lastVisit = +new Date(); + setInterval(function () { + // Bump last visit every minute + localStorage.lastVisit = +new Date(); + }, 60000); f(void 0, env); if (typeof(window.onhashchange) === 'function') { window.onhashchange(); } }); diff --git a/www/common/notifications.js b/www/common/notifications.js index 34c00b092..7a64815cc 100644 --- a/www/common/notifications.js +++ b/www/common/notifications.js @@ -461,6 +461,76 @@ define([ } }; + Messages.reminder_missed = "You missed {0} on {1}"; // XXX + Messages.reminder_now = "{0} is starting!"; // XXX + Messages.reminder_inProgress = "{0} has started on {1}"; // XXX + Messages.reminder_inProgressAllDay = "{0} is happening today"; // XXX + Messages.reminder_minutes = "{0} will start in {1} minutes!"; // XXX + Messages.reminder_time = "{0} will start today at {1}!"; // XXX + Messages.reminder_date = "{0} will start on {1}!"; // XXX + handlers['REMINDER'] = function (common, data) { + var content = data.content; + var msg = content.msg.content; + var missed = content.msg.missed; + var start = msg.start; + var title = Util.fixHTML(msg.title); + content.getFormatText = function () { + var now = +new Date(); + + // Events that have already started + var wasRefresh = content.autorefresh; + content.autorefresh = false; + + var nowDateStr = new Date().toLocaleDateString(); + var startDate = new Date(start); + if (msg.isAllDay && msg.startDay) { + startDate = new Date(msg.startDay); + } + + // Missed events + if (start < now && missed) { + return Messages._getKey('reminder_missed', [title, startDate.toLocaleString()]); + } + // Starting now + if (start < now && wasRefresh) { + return Messages._getKey('reminder_now', [title]); + } + // In progress, is all day + if (start < now && msg.isAllDay) { + return Messages._getKey('reminder_inProgressAllDay', [title]); + } + // In progress, normal event + if (start < now) { + return Messages._getKey('reminder_inProgress', [title, startDate.toLocaleString()]); + } + + // Not started yet + + // No precise time for allDay events + if (msg.isAllDay) { + return Messages._getKey('reminder_date', [title, startDate.toLocaleDateString()]); + } + + // In less than an hour: show countdown in minutes + if ((start - now) < 3600000) { + var minutes = Math.round((start - now) / 60000); + content.autorefresh = true; + return Messages._getKey('reminder_minutes', [title, minutes]); + } + + // Not today: show full date + if (nowDateStr !== startDate.toLocaleDateString()) { + return Messages._getKey('reminder_date', [title, startDate.toLocaleString()]); + } + + // Today: show time + return Messages._getKey('reminder_time', [title, startDate.toLocaleTimeString()]); + }; + if (!content.archived) { + content.dismissHandler = defaultDismiss(common, data); + } + }; + // NOTE: don't forget to fixHTML everything returned by "getFormatText" return { diff --git a/www/common/outer/calendar.js b/www/common/outer/calendar.js index b9f25462c..5f39004c1 100644 --- a/www/common/outer/calendar.js +++ b/www/common/outer/calendar.js @@ -12,60 +12,6 @@ define([ ], 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; @@ -109,6 +55,15 @@ ctx.calendars[channel] = { }, ctx.clients); }; + var clearReminders = function (ctx, id) { + var calendar = ctx.calendars[id]; + if (!calendar || !calendar.reminders) { return; } + // Clear existing reminders + Object.keys(calendar.reminders).forEach(function (uid) { + if (!Array.isArray(calendar.reminders[uid])) { return; } + calendar.reminders[uid].forEach(function (to) { clearTimeout(to); }); + }); + }; var closeCalendar = function (ctx, id) { var ctxCal = ctx.calendars[id]; if (!ctxCal) { return; } @@ -116,6 +71,7 @@ ctx.calendars[channel] = { // If the calendar doesn't exist in any other team, stop it and delete it from ctx if (!ctxCal.stores.length) { ctxCal.lm.stop(); + clearReminders(ctx, id); delete ctx.calendars[id]; } }; @@ -133,6 +89,105 @@ ctx.calendars[channel] = { if (cal.title !== data.title) { cal.title = data.title; } }); }; + + var updateEventReminders = function (ctx, reminders, _ev, useLastVisit) { + var now = +new Date(); + var ev = Util.clone(_ev); + var uid = ev.id; + + // Clear reminders for this event + if (Array.isArray(reminders[uid])) { + reminders[uid].forEach(function (to) { clearTimeout(to); }); + } + reminders[uid] = []; + + var last = ctx.store.data.lastVisit; + + if (ev.isAllDay) { + if (ev.startDay) { ev.start = +new Date(ev.startDay); } + if (ev.endDay) { + var endDate = new Date(ev.endDay); + endDate.setHours(23); + endDate.setMinutes(59); + endDate.setSeconds(59); + ev.end = +endDate; + } + } + + // XXX add a limit to make sure we don't go too far in the past? + var missed = useLastVisit && ev.start > last && ev.end <= now; + if (ev.end <= now && !missed) { + // No reminder for past events + delete reminders[uid]; + return; + } + + var send = function () { + var hide = Util.find(ctx, ['store', 'proxy', 'settings', 'general', 'calendar', 'hideNotif']); + if (hide) { return; } + var ctime = ev.start <= now ? ev.start : +new Date(); // Correct order for past events + ctx.store.mailbox.showMessage('reminders', { + msg: { + ctime: ctime, + type: "REMINDER", + missed: Boolean(missed), + content: ev + }, + hash: 'REMINDER|'+uid + }, null, function () { + }); + }; + var sendNotif = function () { ctx.Store.onReadyEvt.reg(send); }; + + var notifs = ev.reminders || []; + notifs.sort(function (a, b) { + return a - b; + }); + + notifs.some(function (delayMinutes) { + var delay = delayMinutes * 60000; + var time = now + delay; + + // setTimeout only work with 32bit timeout values. If the event is too far away, + // ignore this event for now + // FIXME: call this function again in xxx days to reload these missing timeout? + if (ev.start - time >= 2147483647) { return true; } + + // If we're too late to send a notification, send it instantly and ignore + // all notifications that were supposed to be sent even earlier + if (ev.start <= time) { + sendNotif(); + return true; + } + + // It starts in more than "delay": prepare the notification + reminders[uid].push(setTimeout(function () { + sendNotif(); + }, (ev.start - time))); + }); + }; + var addReminders = function (ctx, id, ev) { + var calendar = ctx.calendars[id]; + if (!calendar || !calendar.reminders) { return; } + if (calendar.stores.length === 1 && calendar.stores[0] === 0) { return; } + + updateEventReminders(ctx, calendar.reminders, ev); + }; + var addInitialReminders = function (ctx, id, useLastVisit) { + var calendar = ctx.calendars[id]; + if (!calendar || !calendar.reminders) { return; } + if (Object.keys(calendar.reminders).length) { return; } // Already initialized + + // No reminders for calendars not stored + if (calendar.stores.length === 1 && calendar.stores[0] === 0) { return; } + + // Re-add all reminders + var content = Util.find(calendar, ['proxy', 'content']); + if (!content) { return; } + Object.keys(content).forEach(function (uid) { + updateEventReminders(ctx, calendar.reminders, content[uid], useLastVisit); + }); + }; var openChannel = function (ctx, cfg, _cb) { var cb = Util.once(Util.mkAsync(_cb || function () {})); var teamId = cfg.storeId; @@ -192,6 +247,7 @@ ctx.calendars[channel] = { readOnly: !data.href, stores: [teamId], roStores: data.href ? [] : [teamId], + reminders: {}, hashes: {} }; @@ -231,6 +287,7 @@ ctx.calendars[channel] = { if (c.lm) { c.lm.stop(); } c.stores = []; sendUpdate(ctx, c); + clearReminders(ctx, channel); delete ctx.calendars[channel]; }; @@ -289,6 +346,7 @@ ctx.calendars[channel] = { c.cacheready = true; setTimeout(update); if (cb) { cb(null, lm.proxy); } + addInitialReminders(ctx, channel, cfg.lastVisitNotif); }).on('ready', function (info) { var md = info.metadata; c.owners = md.owners || []; @@ -305,9 +363,25 @@ ctx.calendars[channel] = { } setTimeout(update); if (cb) { cb(null, lm.proxy); } + addInitialReminders(ctx, channel, cfg.lastVisitNotif); }).on('change', [], function () { if (!c.ready) { return; } setTimeout(update); + }).on('change', ['content'], function (o, n, p) { + if (p.length === 2 && n && !o) { // New event + addReminders(ctx, channel, n); + } + if (p.length === 2 && !n && o) { // Deleted event + addReminders(ctx, channel, { + id: p[1], + start: 0 + }); + } + if (p.length === 3 && n && o && p[2] === 'start') { // Update event start + setTimeout(function () { + addReminders(ctx, channel, proxy.content[p[1]]); + }); + } }).on('change', ['metadata'], function () { // if title or color have changed, update our local values var md = proxy.metadata; @@ -402,6 +476,7 @@ ctx.calendars[channel] = { decryptTeamCalendarHref(store, cal); openChannel(ctx, { storeId: storeId, + lastVisitNotif: true, data: cal }); }); @@ -434,6 +509,23 @@ ctx.calendars[channel] = { }); }; + 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]; + addReminders(ctx, id, 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); @@ -638,6 +730,7 @@ ctx.calendars[channel] = { c.proxy.content[data.id] = data; Realtime.whenRealtimeSyncs(c.lm.realtime, function () { + addReminders(ctx, id, data); sendUpdate(ctx, c); cb(); }); @@ -686,6 +779,18 @@ ctx.calendars[channel] = { Realtime.whenRealtimeSyncs(c.lm.realtime, waitFor()); if (newC) { Realtime.whenRealtimeSyncs(newC.lm.realtime, waitFor()); } }).nThen(function () { + if (newC) { + // Move reminders to the new calendar + addReminders(ctx, id, { + id: ev.id, + start: 0 + }); + addReminders(ctx, ev.calendarId, ev); + } else if (changes.start || changes.reminders || changes.isAllDay) { + // Update reminders + addReminders(ctx, id, ev); + } + sendUpdate(ctx, c); if (newC) { sendUpdate(ctx, newC); } cb(); @@ -698,6 +803,10 @@ ctx.calendars[channel] = { c.proxy.content = c.proxy.content || {}; delete c.proxy.content[data.id]; Realtime.whenRealtimeSyncs(c.lm.realtime, function () { + addReminders(ctx, id, { + id: data.id, + start: 0 + }); sendUpdate(ctx, c); cb(); }); @@ -721,7 +830,7 @@ ctx.calendars[channel] = { emit: emit, onReady: Util.mkEvent(true), calendars: {}, - clients: [], + clients: [] }; initializeCalendars(ctx, waitFor(function (err) { @@ -785,6 +894,10 @@ ctx.calendars[channel] = { 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); diff --git a/www/common/outer/mailbox.js b/www/common/outer/mailbox.js index 7d0653e76..a7269c6e5 100644 --- a/www/common/outer/mailbox.js +++ b/www/common/outer/mailbox.js @@ -169,6 +169,18 @@ proxy.mailboxes = { var dismiss = function (ctx, data, cId, cb) { var type = data.type; var hash = data.hash; + + // Reminder messages don't persist + if (/^REMINDER\|/.test(hash)) { + cb(); + delete ctx.boxes.reminders.content[hash]; + hideMessage(ctx, type, hash, ctx.clients.filter(function (clientId) { + return clientId !== cId; + })); + return; + } + + var box = ctx.boxes[type]; if (!box) { return void cb({error: 'NOT_LOADED'}); } var m = box.data || {}; @@ -488,7 +500,13 @@ proxy.mailboxes = { msg: ctx.boxes[type].content[h], hash: h }; - showMessage(ctx, type, message, cId); + showMessage(ctx, type, message, cId, function (obj) { + if (obj.error) { return; } + // Notify only if "requiresNotif" is true + if (!message.msg || !message.msg.requiresNotif) { return; } + Notify.system(undefined, obj.msg); + delete message.msg.requiresNotif; + }); }); }); // Subscribe to new notifications @@ -528,6 +546,10 @@ proxy.mailboxes = { initializeHistory(ctx); } + ctx.boxes.reminders = { + content: {} + }; + Object.keys(mailboxes).forEach(function (key) { if (TYPES.indexOf(key) === -1) { return; } var m = mailboxes[key]; @@ -568,6 +590,21 @@ proxy.mailboxes = { }); }; + mailbox.showMessage = function (type, msg, cId, cb) { + if (type === "reminders" && msg) { + ctx.boxes.reminders.content[msg.hash] = msg.msg; + if (!ctx.clients.length) { + ctx.boxes.reminders.content[msg.hash].requiresNotif = true; + } + // Hide existing messages for this event + hideMessage(ctx, type, msg.hash, ctx.clients); + } + showMessage(ctx, type, msg, cId, function (obj) { + Notify.system(undefined, obj.msg); + if (cb) { cb(); } + }); + }; + mailbox.open = function (key, m, cb, team, opts) { if (TYPES.indexOf(key) === -1 && !team) { return; } openChannel(ctx, key, m, cb, opts); diff --git a/www/common/sframe-common-mailbox.js b/www/common/sframe-common-mailbox.js index 861ff8d53..7ea717d35 100644 --- a/www/common/sframe-common-mailbox.js +++ b/www/common/sframe-common-mailbox.js @@ -65,6 +65,8 @@ define([ if (/^LOCAL\|/.test(data.content.hash)) { $(avatar).addClass('preview'); } + } else if (data.type === 'reminders') { + avatar = h('i.fa.fa-calendar.cp-broadcast.preview'); } else if (userData && typeof(userData) === "object" && userData.profile) { avatar = h('span.cp-avatar'); Common.displayAvatar($(avatar), userData.avatar, userData.displayName || userData.name); @@ -85,6 +87,15 @@ define([ if (typeof(data.content.getFormatText) === "function") { $(notif).find('.cp-notification-content p').html(data.content.getFormatText()); + if (data.content.autorefresh) { + var it = setInterval(function () { + if (!data.content.autorefresh) { + clearInterval(it); + return; + } + $(notif).find('.cp-notification-content p').html(data.content.getFormatText()); + }, 60000); + } } if (data.content.isClickable) { @@ -117,7 +128,7 @@ define([ // Call the onMessage handlers var isNotification = function (type) { - return type === "notifications" || /^team-/.test(type) || type === "broadcast"; + return type === "notifications" || /^team-/.test(type) || type === "broadcast" || type === "reminders"; }; var pushMessage = function (data, handler) { var todo = function (f) { diff --git a/www/common/toolbar.js b/www/common/toolbar.js index acdcbd441..119d7fb3d 100644 --- a/www/common/toolbar.js +++ b/www/common/toolbar.js @@ -1155,7 +1155,7 @@ MessengerUI, Messages, Pages) { $button.addClass('fa-bell'); }; - Common.mailbox.subscribe(['notifications', 'team', 'broadcast'], { + Common.mailbox.subscribe(['notifications', 'team', 'broadcast', 'reminders'], { onMessage: function (data, el) { if (el) { $(div).prepend(el); diff --git a/www/lib/ical.min.js b/www/lib/ical.min.js new file mode 100644 index 000000000..e92401be0 --- /dev/null +++ b/www/lib/ical.min.js @@ -0,0 +1,2 @@ +"object"==typeof module?ICAL=module.exports:"object"!=typeof ICAL&&(this.ICAL={}),ICAL.foldLength=75,ICAL.newLineChar="\r\n",ICAL.helpers={updateTimezones:function(t){var e,r,n,i,a,s;if(!t||"vcalendar"!==t.name)return t;for(e=t.getAllSubcomponents(),r=[],n={},a=0;a>18&63,r=a>>12&63,n=a>>6&63,i=63&a,c[u++]=s.charAt(e)+s.charAt(r)+s.charAt(n)+s.charAt(i),o>16&255,r=s>>8&255,n=255&s,c[h++]=64==i?String.fromCharCode(e):64==a?String.fromCharCode(e,r):String.fromCharCode(e,r,n),u=this.changes.length)break}var s=this.changes[n];if(s.utcOffset-s.prevUtcOffset<0&&0=this.changes.length?this.changes.length-1:e},_ensureCoverage:function(t){if(-1==ICAL.Timezone._minimumExpansionYear){var e=ICAL.Time.now();ICAL.Timezone._minimumExpansionYear=e.year}var r=t;if(rICAL.Timezone.MAX_YEAR&&(r=ICAL.Timezone.MAX_YEAR),!this.changes.length||this.expandedUntilYeart)&&l);)n.year=l.year,n.month=l.month,n.day=l.day,n.hour=l.hour,n.minute=l.minute,n.second=l.second,n.isDate=l.isDate,ICAL.Timezone.adjust_change(n,0,0,0,-n.prevUtcOffset),r.push(n)}}else(n=s()).year=i.year,n.month=i.month,n.day=i.day,n.hour=i.hour,n.minute=i.minute,n.second=i.second,ICAL.Timezone.adjust_change(n,0,0,0,-n.prevUtcOffset),r.push(n);return r},toString:function(){return this.tznames?this.tznames:this.tzid}},ICAL.Timezone._compare_change_fn=function(t,e){return t.yeare.year?1:t.monthe.month?1:t.daye.day?1:t.houre.hour?1:t.minutee.minute?1:t.seconde.second?1:0},ICAL.Timezone.convert_time=function(t,e,r){if(t.isDate||e.tzid==r.tzid||e==ICAL.Timezone.localTimezone||r==ICAL.Timezone.localTimezone)return t.zone=r,t;var n=e.utcOffset(t);return t.adjust(0,0,0,-n),n=r.utcOffset(t),t.adjust(0,0,0,n),null},ICAL.Timezone.fromData=function(t){return(new ICAL.Timezone).fromData(t)},ICAL.Timezone.utcTimezone=ICAL.Timezone.fromData({tzid:"UTC"}),ICAL.Timezone.localTimezone=ICAL.Timezone.fromData({tzid:"floating"}),ICAL.Timezone.adjust_change=function(t,e,r,n,i){return ICAL.Time.prototype.adjust.call(t,e,r,n,i,t)},ICAL.Timezone._minimumExpansionYear=-1,ICAL.Timezone.MAX_YEAR=2035,ICAL.Timezone.EXTRA_COVERAGE=5}(),ICAL.TimezoneService=function(){var r,t={get count(){return Object.keys(r).length},reset:function(){r=Object.create(null);var t=ICAL.Timezone.utcTimezone;r.Z=t,r.UTC=t,r.GMT=t},has:function(t){return!!r[t]},get:function(t){return r[t]},register:function(t,e){if(t instanceof ICAL.Component&&"vtimezone"===t.name&&(t=(e=new ICAL.Timezone(t)).tzid),!(e instanceof ICAL.Timezone))throw new TypeError("timezone must be ICAL.Timezone or ICAL.Component");r[t]=e},remove:function(t){return delete r[t]}};return t.reset(),t}(),function(){function t(e){Object.defineProperty(ICAL.Time.prototype,e,{get:function(){return this._pendingNormalization&&(this._normalize(),this._pendingNormalization=!1),this._time[e]},set:function(t){return"isDate"===e&&t&&!this._time.isDate&&this.adjust(0,0,0,0),this._cachedUnixTime=null,this._pendingNormalization=!0,this._time[e]=t}})}ICAL.Time=function(t,e){var r=(this.wrappedJSObject=this)._time=Object.create(null);r.year=0,r.month=1,r.day=1,r.hour=0,r.minute=0,r.second=0,r.isDate=!1,this.fromData(t,e)},ICAL.Time._dowCache={},ICAL.Time._wnCache={},ICAL.Time.prototype={icalclass:"icaltime",_cachedUnixTime:null,get icaltype(){return this.isDate?"date":"date-time"},zone:null,_pendingNormalization:!1,clone:function(){return new ICAL.Time(this._time,this.zone)},reset:function(){this.fromData(ICAL.Time.epochTime),this.zone=ICAL.Timezone.utcTimezone},resetTo:function(t,e,r,n,i,a,s){this.fromData({year:t,month:e,day:r,hour:n,minute:i,second:a,zone:s})},fromJSDate:function(t,e){return t?e?(this.zone=ICAL.Timezone.utcTimezone,this.year=t.getUTCFullYear(),this.month=t.getUTCMonth()+1,this.day=t.getUTCDate(),this.hour=t.getUTCHours(),this.minute=t.getUTCMinutes(),this.second=t.getUTCSeconds()):(this.zone=ICAL.Timezone.localTimezone,this.year=t.getFullYear(),this.month=t.getMonth()+1,this.day=t.getDate(),this.hour=t.getHours(),this.minute=t.getMinutes(),this.second=t.getSeconds()):this.reset(),this._cachedUnixTime=null,this},fromData:function(t,e){if(t)for(var r in t)if(Object.prototype.hasOwnProperty.call(t,r)){if("icaltype"===r)continue;this[r]=t[r]}if(e&&(this.zone=e),!t||"isDate"in t?t&&"isDate"in t&&(this.isDate=t.isDate):this.isDate=!("hour"in t),t&&"timezone"in t){var n=ICAL.TimezoneService.get(t.timezone);this.zone=n||ICAL.Timezone.localTimezone}return t&&"zone"in t&&(this.zone=t.zone),this.zone||(this.zone=ICAL.Timezone.localTimezone),this._cachedUnixTime=null,this},dayOfWeek:function(t){var e=t||ICAL.Time.SUNDAY,r=(this.year<<12)+(this.month<<8)+(this.day<<3)+e;if(r in ICAL.Time._dowCache)return ICAL.Time._dowCache[r];var n=this.day,i=this.month+(this.month<3?12:0),a=this.year-(this.month<3?1:0),s=n+a+ICAL.helpers.trunc(26*(i+1)/10)+ICAL.helpers.trunc(a/4);return s=((s+=6*ICAL.helpers.trunc(a/100)+ICAL.helpers.trunc(a/400))+7-e)%7+1,ICAL.Time._dowCache[r]=s},dayOfYear:function(){var t=ICAL.Time.isLeapYear(this.year)?1:0;return ICAL.Time.daysInYearPassedMonth[t][this.month-1]+this.day},startOfWeek:function(t){var e=t||ICAL.Time.SUNDAY,r=this.clone();return r.day-=(this.dayOfWeek()+7-e)%7,r.isDate=!0,r.hour=0,r.minute=0,r.second=0,r},endOfWeek:function(t){var e=t||ICAL.Time.SUNDAY,r=this.clone();return r.day+=(7-this.dayOfWeek()+e-ICAL.Time.SUNDAY)%7,r.isDate=!0,r.hour=0,r.minute=0,r.second=0,r},startOfMonth:function(){var t=this.clone();return t.day=1,t.isDate=!0,t.hour=0,t.minute=0,t.second=0,t},endOfMonth:function(){var t=this.clone();return t.day=ICAL.Time.daysInMonth(t.month,t.year),t.isDate=!0,t.hour=0,t.minute=0,t.second=0,t},startOfYear:function(){var t=this.clone();return t.day=1,t.month=1,t.isDate=!0,t.hour=0,t.minute=0,t.second=0,t},endOfYear:function(){var t=this.clone();return t.day=31,t.month=12,t.isDate=!0,t.hour=0,t.minute=0,t.second=0,t},startDoyWeek:function(t){var e=t||ICAL.Time.SUNDAY,r=this.dayOfWeek()-e;return r<0&&(r+=7),this.dayOfYear()-r},getDominicalLetter:function(){return ICAL.Time.getDominicalLetter(this.year)},nthWeekDay:function(t,e){var r,n=ICAL.Time.daysInMonth(this.month,this.year),i=e,a=0,s=this.clone();if(0<=i){s.day=1,0!=i&&i--,a=s.day;var o=t-s.dayOfWeek();o<0&&(o+=7),a+=o,a-=t,r=t}else{s.day=n,i++,(r=s.dayOfWeek()-t)<0&&(r+=7),r=n-r}return a+(r+=7*i)},isNthWeekDay:function(t,e){var r=this.dayOfWeek();return 0===e&&r===t||this.nthWeekDay(t,e)===this.day},weekNumber:function(t){var e,r=(this.year<<12)+(this.month<<8)+(this.day<<3)+t;if(r in ICAL.Time._wnCache)return ICAL.Time._wnCache[r];var n=this.clone();n.isDate=!0;var i=this.year;12==n.month&&25ICAL.Time.daysInYearPassedMonth[a][12])return a=ICAL.Time.isLeapYear(r)?1:0,n-=ICAL.Time.daysInYearPassedMonth[a][12],r++,ICAL.Time.fromDayOfYear(n,r);i.year=r,i.isDate=!0;for(var s=11;0<=s;s--)if(n>ICAL.Time.daysInYearPassedMonth[a][s]){i.month=s+1,i.day=n-ICAL.Time.daysInYearPassedMonth[a][s];break}return i.auto_normalize=!0,i},ICAL.Time.fromStringv2=function(t){return new ICAL.Time({year:parseInt(t.substr(0,4),10),month:parseInt(t.substr(5,2),10),day:parseInt(t.substr(8,2),10),isDate:!0})},ICAL.Time.fromDateString=function(t){return new ICAL.Time({year:ICAL.helpers.strictParseInt(t.substr(0,4)),month:ICAL.helpers.strictParseInt(t.substr(5,2)),day:ICAL.helpers.strictParseInt(t.substr(8,2)),isDate:!0})},ICAL.Time.fromDateTimeString=function(t,e){if(t.length<19)throw new Error('invalid date-time value: "'+t+'"');var r;return t[19]&&"Z"===t[19]?r="Z":e&&(r=e.getParameter("tzid")),new ICAL.Time({year:ICAL.helpers.strictParseInt(t.substr(0,4)),month:ICAL.helpers.strictParseInt(t.substr(5,2)),day:ICAL.helpers.strictParseInt(t.substr(8,2)),hour:ICAL.helpers.strictParseInt(t.substr(11,2)),minute:ICAL.helpers.strictParseInt(t.substr(14,2)),second:ICAL.helpers.strictParseInt(t.substr(17,2)),timezone:r})},ICAL.Time.fromString=function(t,e){return 10ICAL.Time.THURSDAY&&(r.day+=7),i>ICAL.Time.THURSDAY&&(r.day-=7),r.day-=n-i,r},ICAL.Time.getDominicalLetter=function(t){var e="GFEDCBA",r=(t+(t/4|0)+(t/400|0)-(t/100|0)-1)%7;return ICAL.Time.isLeapYear(t)?e[(6+r)%7]+e[r]:e[r]},ICAL.Time.epochTime=ICAL.Time.fromData({year:1970,month:1,day:1,hour:0,minute:0,second:0,isDate:!1,timezone:"Z"}),ICAL.Time._cmp_attr=function(t,e,r){return t[r]>e[r]?1:t[r] '+e);if(void 0!==r&&rs||0==this.last.day)throw new Error("Malformed values in BYDAY part")}else if(this.has_by_data("BYMONTHDAY")&&this.last.day<0){s=ICAL.Time.daysInMonth(this.last.month,this.last.year);this.last.day=s+this.last.day+1}},next:function(){var t,e=this.last?this.last.clone():null;if(this.rule.count&&this.occurrence_number>=this.rule.count||this.rule.until&&0i)){if(n<0)n=i+(n+1);else if(0===n)continue;-1===a.indexOf(n)&&a.push(n)}return a.sort(function(t,e){return t-e})},_byDayAndMonthDay:function(t){var e,r,n,i,a=this.by_data.BYDAY,s=0,o=a.length,u=0,h=this,c=this.last.day;function l(){for(i=ICAL.Time.daysInMonth(h.last.month,h.last.year),e=h.normalizeByMonthDayRules(h.last.year,h.last.month,h.by_data.BYMONTHDAY),n=e.length;e[s]<=c&&(!t||e[s]!=c)&&s=this.by_data.BYMONTHDAY.length&&(this.by_indices.BYMONTHDAY=0,this.increment_month());e=ICAL.Time.daysInMonth(this.last.month,this.last.year);(a=this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY])<0&&(a=e+a+1),ee?t=0:this.last.day=this.by_data.BYMONTHDAY[0]}return t},next_weekday_by_week:function(){var t=0;if(0==this.next_hour())return t;if(!this.has_by_data("BYDAY"))return 1;for(;;){var e=new ICAL.Time;this.by_indices.BYDAY++,this.by_indices.BYDAY==Object.keys(this.by_data.BYDAY).length&&(this.by_indices.BYDAY=0,t=1);var r=this.by_data.BYDAY[this.by_indices.BYDAY],n=this.ruleDayOfWeek(r)[1];(n-=this.rule.wkst)<0&&(n+=7),e.year=this.last.year,e.month=this.last.month,e.day=this.last.day;var i=e.startDoyWeek(this.rule.wkst);if(!(n+i<1)||t){var a=ICAL.Time.fromDayOfYear(i+n,this.last.year);return this.last.year=a.year,this.last.month=a.month,this.last.day=a.day,t}}},next_year:function(){if(0==this.next_hour())return 0;if(++this.days_index==this.days.length)for(this.days_index=0;this.increment_year(this.rule.interval),this.expand_year_days(this.last.year),0==this.days.length;);return this._nextByYearDay(),1},_nextByYearDay:function(){var t=this.days[this.days_index],e=this.last.year;t<1&&(t+=1,e+=1);var r=ICAL.Time.fromDayOfYear(t,e);this.last.day=r.day,this.last.month=r.month},ruleDayOfWeek:function(t,e){var r=t.match(/([+-]?[0-9])?(MO|TU|WE|TH|FR|SA|SU)/);return r?[parseInt(r[1]||0,10),t=ICAL.Recur.icalDayToNumericDay(r[2],e)]:[0,0]},next_generic:function(t,e,r,n,i){var a=t in this.by_data,s=this.rule.freq==e,o=0;if(i&&0==this[i]())return o;if(a){this.by_indices[t]++;this.by_indices[t];var u=this.by_data[t];this.by_indices[t]==u.length&&(this.by_indices[t]=0,o=1),this.last[r]=u[this.by_indices[t]]}else s&&this["increment_"+r](this.rule.interval);return a&&o&&s&&this["increment_"+n](1),o},increment_monthday:function(t){for(var e=0;er&&(this.last.day-=r,this.increment_month())}},increment_month:function(){if(this.last.day=1,this.has_by_data("BYMONTH"))this.by_indices.BYMONTH++,this.by_indices.BYMONTH==this.by_data.BYMONTH.length&&(this.by_indices.BYMONTH=0,this.increment_year(1)),this.last.month=this.by_data.BYMONTH[this.by_indices.BYMONTH];else{"MONTHLY"==this.rule.freq?this.last.month+=this.rule.interval:this.last.month++,this.last.month--;var t=ICAL.helpers.trunc(this.last.month/12);this.last.month%=12,this.last.month++,0!=t&&this.increment_year(t)}},increment_year:function(t){this.last.year+=t},increment_generic:function(t,e,r,n){this.last[e]+=t;var i=ICAL.helpers.trunc(this.last[e]/r);this.last[e]%=r,0!=i&&this["increment_"+n](i)},has_by_data:function(t){return t in this.rule.parts},expand_year_days:function(t){var e=new ICAL.Time;this.days=[];var r={},n=["BYDAY","BYWEEKNO","BYMONTHDAY","BYMONTH","BYYEARDAY"];for(var i in n)if(n.hasOwnProperty(i)){var a=n[i];a in this.rule.parts&&(r[a]=this.rule.parts[a])}if("BYMONTH"in r&&"BYWEEKNO"in r){var s=1,o={};e.year=t,e.isDate=!0;for(var u=0;ue[0]?1:e[0]>t[0]?-1:0}return t.prototype={THISANDFUTURE:"THISANDFUTURE",exceptions:null,strictExceptions:!1,relateException:function(t){if(this.isRecurrenceException())throw new Error("cannot relate exception to exceptions");if(t instanceof ICAL.Component&&(t=new ICAL.Event(t)),this.strictExceptions&&t.uid!==this.uid)throw new Error("attempted to relate unrelated exception");var e=t.recurrenceId.toString();if((this.exceptions[e]=t).modifiesFuture()){var r=[t.recurrenceId.toUnixTime(),e],n=ICAL.helpers.binsearchInsert(this.rangeExceptions,r,i);this.rangeExceptions.splice(n,0,r)}},modifiesFuture:function(){return!!this.component.hasProperty("recurrence-id")&&this.component.getFirstProperty("recurrence-id").getParameter("range")===this.THISANDFUTURE},findRangeException:function(t){if(!this.rangeExceptions.length)return null;var e=t.toUnixTime(),r=ICAL.helpers.binsearchInsert(this.rangeExceptions,[e],i);if(--r<0)return null;var n=this.rangeExceptions[r];return e', { 'class': 'cp-sidebarlayout-categories' }) .appendTo(APP.$leftside);