/** * Client UI Javascript for the Calendar plugin * * @author Lazlo Westerhof * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * Copyright (C) 2010, Lazlo Westerhof * Copyright (C) 2014-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ // Roundcube calendar UI client class function rcube_calendar_ui(settings) { // extend base class rcube_calendar.call(this, settings); /*** member vars ***/ this.is_loading = false; this.selected_event = null; this.selected_calendar = null; this.search_request = null; this.saving_lock; this.calendars = {}; this.quickview_sources = []; /*** private vars ***/ var DAY_MS = 86400000; var HOUR_MS = 3600000; var me = this; var gmt_offset = (new Date().getTimezoneOffset() / -60) - (settings.timezone || 0) - (settings.dst || 0); var client_timezone = new Date().getTimezoneOffset(); var day_clicked = day_clicked_ts = 0; var ignore_click = false; var event_defaults = { free_busy:'busy', alarms:'' }; var event_attendees = []; var calendars_list; var calenders_search_list; var calenders_search_container; var search_calendars = {}; var attendees_list; var resources_list; var resources_treelist; var resources_data = {}; var resources_index = []; var resource_owners = {}; var resources_events_source = { url:null, editable:false }; var freebusy_ui = { workinhoursonly:false, needsupdate:false }; var freebusy_data = {}; var current_view = null; var count_sources = []; var exec_deferred = bw.ie6 ? 5 : 1; var sensitivitylabels = { 'public':rcmail.gettext('public','calendar'), 'private':rcmail.gettext('private','calendar'), 'confidential':rcmail.gettext('confidential','calendar') }; var ui_loading = rcmail.set_busy(true, 'loading'); // general datepicker settings var datepicker_settings = { // translate from fullcalendar format to datepicker format dateFormat: settings['date_format'].replace(/M/g, 'm').replace(/mmmmm/, 'MM').replace(/mmm/, 'M').replace(/dddd/, 'DD').replace(/ddd/, 'D').replace(/yy/g, 'y'), firstDay : settings['first_day'], dayNamesMin: settings['days_short'], monthNames: settings['months'], monthNamesShort: settings['months'], changeMonth: false, showOtherMonths: true, selectOtherMonths: true }; // global fullcalendar settings var fullcalendar_defaults = { aspectRatio: 1, ignoreTimezone: true, // will treat the given date strings as in local (browser's) timezone monthNames : settings.months, monthNamesShort : settings.months_short, dayNames : settings.days, dayNamesShort : settings.days_short, firstDay : settings.first_day, firstHour : settings.first_hour, slotMinutes : 60/settings.timeslots, timeFormat: { '': settings.time_format, agenda: settings.time_format + '{ - ' + settings.time_format + '}', list: settings.time_format + '{ - ' + settings.time_format + '}', table: settings.time_format + '{ - ' + settings.time_format + '}' }, axisFormat : settings.time_format, columnFormat: { month: 'ddd', // Mon week: 'ddd ' + settings.date_short, // Mon 9/7 day: 'dddd ' + settings.date_short, // Monday 9/7 table: settings.date_agenda }, titleFormat: { month: 'MMMM yyyy', week: settings.dates_long, day: 'dddd ' + settings['date_long'], table: settings.dates_long }, listPage: 7, // advance one week in agenda view listRange: settings.agenda_range, listSections: settings.agenda_sections, tableCols: ['handle', 'date', 'time', 'title', 'location'], defaultView: rcmail.env.view || settings.default_view, allDayText: rcmail.gettext('all-day', 'calendar'), buttonText: { prev: ' ◄ ', next: ' ► ', today: settings['today'], day: rcmail.gettext('day', 'calendar'), week: rcmail.gettext('week', 'calendar'), month: rcmail.gettext('month', 'calendar'), table: rcmail.gettext('agenda', 'calendar') }, listTexts: { until: rcmail.gettext('until', 'calendar'), past: rcmail.gettext('pastevents', 'calendar'), today: rcmail.gettext('today', 'calendar'), tomorrow: rcmail.gettext('tomorrow', 'calendar'), thisWeek: rcmail.gettext('thisweek', 'calendar'), nextWeek: rcmail.gettext('nextweek', 'calendar'), thisMonth: rcmail.gettext('thismonth', 'calendar'), nextMonth: rcmail.gettext('nextmonth', 'calendar'), future: rcmail.gettext('futureevents', 'calendar'), week: rcmail.gettext('weekofyear', 'calendar') }, currentTimeIndicator: settings.time_indicator, // event rendering eventRender: function(event, element, view) { if (view.name != 'list' && view.name != 'table') { var prefix = event.sensitivity && event.sensitivity != 'public' ? String(sensitivitylabels[event.sensitivity]).toUpperCase()+': ' : ''; element.attr('title', prefix + event.title); } if (view.name != 'month') { if (event.location) { element.find('div.fc-event-title').after('
@ ' + Q(event.location) + '
'); } if (event.sensitivity && event.sensitivity != 'public') element.find('div.fc-event-time').append(''); if (event.recurrence) element.find('div.fc-event-time').append(''); if (event.alarms || (event.valarms && event.valarms.length)) element.find('div.fc-event-time').append(''); } if (event.status) { element.addClass('cal-event-status-' + String(event.status).toLowerCase()); } element.attr('aria-label', event.title + ', ' + me.event_date_text(event, true)); }, // render element indicating more (invisible) events overflowRender: function(data, element) { element.html(rcmail.gettext('andnmore', 'calendar').replace('$nr', data.count)) .click(function(e){ me.fisheye_view(data.date); }); }, // callback when a specific event is clicked eventClick: function(event, ev, view) { if (!event.temp && String(event.className).indexOf('fc-type-freebusy') < 0) event_show_dialog(event, ev); } }; /*** imports ***/ var Q = this.quote_html; var text2html = this.text2html; var event_date_text = this.event_date_text; var parse_datetime = this.parse_datetime; var date2unixtime = this.date2unixtime; var fromunixtime = this.fromunixtime; var parseISO8601 = this.parseISO8601; var date2servertime = this.date2ISO8601; var render_message_links = this.render_message_links; /*** private methods ***/ // same as str.split(delimiter) but it ignores delimiters within quoted strings var explode_quoted_string = function(str, delimiter) { var result = [], strlen = str.length, q, p, i, char, last; for (q = p = i = 0; i < strlen; i++) { char = str.charAt(i); if (char == '"' && last != '\\') { q = !q; } else if (!q && char == delimiter) { result.push(str.substring(p, i)); p = i + 1; } last = char; } result.push(str.substr(p)); return result; }; // Change the first charcter to uppercase var ucfirst = function(str) { return str.charAt(0).toUpperCase() + str.substr(1); }; // clone the given date object and optionally adjust time var clone_date = function(date, adjust) { var d = new Date(date.getTime()); // set time to 00:00 if (adjust == 1) { d.setHours(0); d.setMinutes(0); } // set time to 23:59 else if (adjust == 2) { d.setHours(23); d.setMinutes(59); } return d; }; // fix date if jumped over a DST change var fix_date = function(date) { if (date.getHours() == 23) date.setTime(date.getTime() + HOUR_MS); else if (date.getHours() > 0) date.setHours(0); }; var date2timestring = function(date, dateonly) { return date2servertime(date).replace(/[^0-9]/g, '').substr(0, (dateonly ? 8 : 14)); } var format_datetime = function(date, mode, voice) { return me.format_datetime(date, mode, voice); } var render_link = function(url) { var islink = false, href = url; if (url.match(/^[fhtpsmailo]+?:\/\//i)) { islink = true; } else if (url.match(/^[a-z0-9.-:]+(\/|$)/i)) { islink = true; href = 'http://' + url; } return islink ? '' + Q(url) + '' : Q(url); } // determine whether the given date is on a weekend var is_weekend = function(date) { return date.getDay() == 0 || date.getDay() == 6; }; var is_workinghour = function(date) { if (settings['work_start'] > settings['work_end']) return date.getHours() >= settings['work_start'] || date.getHours() < settings['work_end']; else return date.getHours() >= settings['work_start'] && date.getHours() < settings['work_end']; }; // check if the event has 'real' attendees, excluding the current user var has_attendees = function(event) { return (event.attendees && event.attendees.length && (event.attendees.length > 1 || String(event.attendees[0].email).toLowerCase() != settings.identity.email)); }; // check if the current user is an attendee of this event var is_attendee = function(event, role, email) { var emails = email ? ';'+email.toLowerCase() : settings.identity.emails; for (var i=0; event.attendees && i < event.attendees.length; i++) { if ((!role || event.attendees[i].role == role) && event.attendees[i].email && emails.indexOf(';'+event.attendees[i].email.toLowerCase()) >= 0) return event.attendees[i]; } return false; }; // check if the current user is the organizer var is_organizer = function(event, email) { return is_attendee(event, 'ORGANIZER', email) || !event.id; }; /** * Check permissions on the given calendar object */ var has_permission = function(cal, perm) { // multiple chars means "either of" if (String(perm).length > 1) { for (var i=0; i < perm.length; i++) { if (has_permission(cal, perm[i])) return true; } } if (cal.rights && String(cal.rights).indexOf(perm) >= 0) { return true; } return (perm == 'i' && cal.editable) || (perm == 'v' && cal.editable); } var load_attachment = function(event, att) { var query = { _id: att.id, _event: event.recurrence_id || event.id, _cal:event.calendar, _frame: 1 }; if (event.rev) query._rev = event.rev; // open attachment in frame if it's of a supported mimetype if (id && att.mimetype && $.inArray(att.mimetype, settings.mimetypes)>=0) { if (rcmail.open_window(rcmail.url('get-attachment', query), true, true)) { return; } } query._frame = null; query._download = 1; rcmail.goto_url('get-attachment', query, false); }; // build event attachments list var event_show_attachments = function(list, container, event, edit) { var i, id, len, img, content, li, elem, ul = document.createElement('UL'); ul.className = 'attachmentslist'; for (i=0, len=list.length; i') .attr('title', rcmail.gettext('delete')) .attr('aria-label', rcmail.gettext('delete') + ' ' + Q(elem.name)) .addClass('delete') .click({id: elem.id}, function(e) { remove_attachment(this, e.data.id); return false; }); if (!rcmail.env.deleteicon) content.html(rcmail.gettext('delete')); else { img = document.createElement('IMG'); img.src = rcmail.env.deleteicon; img.alt = rcmail.gettext('delete'); content.append(img); } content.appendTo(li); } // name/link content = $('') .html(Q(elem.name)) .addClass('file') .click({event: event, att: elem}, function(e) { load_attachment(e.data.event, e.data.att); return false; }) .appendTo(li); ul.appendChild(li); } if (edit && rcmail.gui_objects.attachmentlist) { ul.id = rcmail.gui_objects.attachmentlist.id; rcmail.gui_objects.attachmentlist = ul; } container.empty().append(ul); }; var remove_attachment = function(elem, id) { $(elem.parentNode).hide(); rcmail.env.deleted_attachments.push(id); delete rcmail.env.attachments[id]; }; // event details dialog (show only) var event_show_dialog = function(event, ev, temp) { var $dialog = $("#eventshow"); var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:false, rights:'lrs' }; if (!temp) me.selected_event = event; if ($dialog.is(':ui-dialog')) $dialog.dialog('close'); // remove status-* classes $dialog.removeClass(function(i, oldclass) { var oldies = String(oldclass).split(' '); return $.grep(oldies, function(cls) { return cls.indexOf('status-') === 0 || cls.indexOf('sensitivity-') === 0 }).join(' '); }); // convert start/end dates if not done yet by fullcalendar if (typeof event.start == 'string') event.start = parseISO8601(event.start); if (typeof event.end == 'string') event.end = parseISO8601(event.end); // allow other plugins to do actions when event form is opened rcmail.triggerEvent('calendar-event-init', {o: event}); $dialog.find('div.event-section, div.event-line').hide(); $('#event-title').html(Q(event.title)).show(); if (event.location) $('#event-location').html('@ ' + text2html(event.location)).show(); if (event.description) $('#event-description').show().children('.event-text').html(text2html(event.description, 300, 6)); if (event.vurl) $('#event-url').show().children('.event-text').html(render_link(event.vurl)); // render from-to in a nice human-readable way // -> now shown in dialog title // $('#event-date').html(Q(me.event_date_text(event))).show(); if (event.recurrence && event.recurrence_text) $('#event-repeat').show().children('.event-text').html(Q(event.recurrence_text)); if (event.valarms && event.alarms_text) $('#event-alarm').show().children('.event-text').html(Q(event.alarms_text)); if (calendar.name) $('#event-calendar').show().children('.event-text').html(Q(calendar.name)).attr('class', 'event-text cal-'+calendar.id).css('color', calendar.textColor || calendar.color || ''); if (event.categories) $('#event-category').show().children('.event-text').html(Q(event.categories)).attr('class', 'event-text cat-'+String(event.categories).toLowerCase().replace(rcmail.identifier_expr, '')); if (event.free_busy) $('#event-free-busy').show().children('.event-text').html(Q(rcmail.gettext(event.free_busy, 'calendar'))); if (event.priority > 0) { var priolabels = [ '', rcmail.gettext('highest'), rcmail.gettext('high'), '', '', rcmail.gettext('normal'), '', '', rcmail.gettext('low'), rcmail.gettext('lowest') ]; $('#event-priority').show().children('.event-text').html(Q(event.priority+' '+priolabels[event.priority])); } if (event.status) { var status_lc = String(event.status).toLowerCase(); $('#event-status').show().children('.event-text').html(Q(rcmail.gettext('status-'+status_lc,'calendar'))); $dialog.addClass('status-'+status_lc); } if (event.sensitivity && event.sensitivity != 'public') { $('#event-sensitivity').show().children('.event-text').html(Q(sensitivitylabels[event.sensitivity])); $dialog.addClass('sensitivity-'+event.sensitivity); } if (event.created || event.changed) { var created = parseISO8601(event.created), changed = parseISO8601(event.changed) $('#event-created-changed .event-created').html(Q(created ? format_datetime(created) : rcmail.gettext('unknown','calendar'))) $('#event-created-changed .event-changed').html(Q(changed ? format_datetime(changed) : rcmail.gettext('unknown','calendar'))) $('#event-created-changed').show() } // create attachments list if ($.isArray(event.attachments)) { event_show_attachments(event.attachments, $('#event-attachments').children('.event-text'), event); if (event.attachments.length > 0) { $('#event-attachments').show(); } } else if (calendar.attachments) { // fetch attachments, some drivers doesn't set 'attachments' prop of the event? } // build attachments list $('#event-links').hide(); if ($.isArray(event.links) && event.links.length) { render_message_links(event.links || [], $('#event-links').children('.event-text'), false, 'calendar'); $('#event-links').show(); } // list event attendees if (calendar.attendees && event.attendees) { // sort resources to the end event.attendees.sort(function(a,b) { var j = a.cutype == 'RESOURCE' ? 1 : 0, k = b.cutype == 'RESOURCE' ? 1 : 0; return (j - k); }); var data, mystatus = null, rsvp, line, morelink, html = '', overflow = ''; for (var j=0; j < event.attendees.length; j++) { data = event.attendees[j]; if (data.email) { if (data.role == 'ORGANIZER') organizer = true; else if (settings.identity.emails.indexOf(';'+data.email) >= 0) { mystatus = data.status.toLowerCase(); if (data.status == 'NEEDS-ACTION' || data.status == 'TENTATIVE' || data.rsvp) rsvp = mystatus; } } line = event_attendee_html(data); if (morelink) overflow += line; else html += line; // stop listing attendees if (j == 7 && event.attendees.length >= 7) { morelink = $('').html(rcmail.gettext('andnmore', 'calendar').replace('$nr', event.attendees.length - j - 1)); } } if (html && (event.attendees.length > 1 || !organizer)) { $('#event-attendees').show() .children('.event-text') .html(html) .find('a.mailtolink').click(event_attendee_click); // display all attendees in a popup when clicking the "more" link if (morelink) { $('#event-attendees .event-text').append(morelink); morelink.click(function(e){ rcmail.show_popup_dialog( '
' + html + overflow + '
', rcmail.gettext('tabattendees','calendar'), null, { width:450, modal:false }); $('#all-event-attendees a.mailtolink').click(event_attendee_click); return false; }) } } if (mystatus && !rsvp) { $('#event-partstat').show().children('.changersvp') .removeClass('accepted tentative declined delegated needs-action') .addClass(mystatus) .children('.event-text') .html(Q(rcmail.gettext('itip' + mystatus, 'libcalendaring'))); } var show_rsvp = rsvp && !is_organizer(event) && event.status != 'CANCELLED' && has_permission(calendar, 'v'); $('#event-rsvp')[(show_rsvp ? 'show' : 'hide')](); $('#event-rsvp .rsvp-buttons input').prop('disabled', false).filter('input[rel='+mystatus+']').prop('disabled', true); if (show_rsvp && event.comment) $('#event-rsvp-comment').show().children('.event-text').html(Q(event.comment)); $('#event-rsvp a.reply-comment-toggle').show(); $('#event-rsvp .itip-reply-comment textarea').hide().val(''); if (event.recurrence && event.id) { var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all')); $('#event-rsvp .rsvp-buttons').addClass('recurring'); } else { $('#event-rsvp .rsvp-buttons').removeClass('recurring'); } } var buttons = []; if (!temp && calendar.editable && event.editable !== false) { buttons.push({ text: rcmail.gettext('edit', 'calendar'), click: function() { event_edit_dialog('edit', event); } }); } if (!temp && has_permission(calendar, 'td') && event.editable !== false) { buttons.push({ text: rcmail.gettext('delete', 'calendar'), 'class': 'delete', click: function() { me.delete_event(event); $dialog.dialog('close'); } }); } if (!buttons.length) { buttons.push({ text: rcmail.gettext('close', 'calendar'), click: function(){ $dialog.dialog('close'); } }); } // open jquery UI dialog $dialog.dialog({ modal: false, resizable: !bw.ie6, closeOnEscape: (!bw.ie6 && !bw.ie7), // disable for performance reasons title: me.event_date_text(event), open: function() { $dialog.attr('aria-hidden', 'false'); setTimeout(function(){ $dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus(); }, 5); }, close: function() { $dialog.dialog('destroy').attr('aria-hidden', 'true').hide(); rcmail.command('menu-close','eventoptionsmenu'); $('.libcal-rsvp-replymode').hide(); }, dragStart: function() { rcmail.command('menu-close','eventoptionsmenu'); $('.libcal-rsvp-replymode').hide(); }, resizeStart: function() { rcmail.command('menu-close','eventoptionsmenu'); $('.libcal-rsvp-replymode').hide(); }, buttons: buttons, minWidth: 320, width: 420 }).show(); // remember opener element (to be focused on close) $dialog.data('opener', ev && rcube_event.is_keyboard(ev) ? ev.target : null); // set voice title on dialog widget $dialog.dialog('widget').removeAttr('aria-labelledby') .attr('aria-label', me.event_date_text(event, true) + ', ', event.title); // set dialog size according to content me.dialog_resize($dialog.get(0), $dialog.height(), 420); // add link for "more options" drop-down if (!temp && !event.temporary && event.calendar != '_resource') { $('') .attr('href', '#') .html(rcmail.gettext('eventoptions','calendar')) .addClass('dropdown-link') .click(function(e) { return rcmail.command('menu-open','eventoptionsmenu', this, e) }) .appendTo($dialog.parent().find('.ui-dialog-buttonset')); } rcmail.enable_command('event-history', calendar.history) }; // render HTML code for displaying an attendee record var event_attendee_html = function(data) { var dispname = Q(data.name || data.email), tooltip = ''; if (data.email) { tooltip = data.email + '; ' + data.status; dispname = '' + dispname + ''; } if (data['delegated-to']) tooltip = rcmail.gettext('delegatedto', 'calendar') + data['delegated-to']; else if (data['delegated-from']) tooltip = rcmail.gettext('delegatedfrom', 'calendar') + data['delegated-from']; return '' + dispname + ' '; }; // event handler for clicks on an attendee link var event_attendee_click = function(e) { var cutype = $(this).attr('data-cutype'), mailto = this.href.substr(7); if (rcmail.env.calendar_resources && cutype == 'RESOURCE') { event_resources_dialog(mailto); } else { rcmail.command('compose', mailto, e ? e.target : null, e); } return false; }; // bring up the event dialog (jquery-ui popup) var event_edit_dialog = function(action, event) { // copy opener element from show dialog var op_elem = $("#eventshow:ui-dialog").data('opener'); // close show dialog first $("#eventshow:ui-dialog").data('opener', null).dialog('close'); var $dialog = $('
'); var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:true, rights: action=='new' ? 'lrwitd' : 'lrs' }; me.selected_event = $.extend($.extend({}, event_defaults), event); // clone event object (with defaults) event = me.selected_event; // change reference to clone freebusy_ui.needsupdate = false; // reset dialog first $('#eventtabs').get(0).reset(); $('#event-panel-recurrence input, #event-panel-recurrence select, #event-panel-attachments input').prop('disabled', false); $('#event-panel-recurrence, #event-panel-attachments').removeClass('disabled'); // allow other plugins to do actions when event form is opened rcmail.triggerEvent('calendar-event-init', {o: event}); // event details var title = $('#edit-title').val(event.title || ''); var location = $('#edit-location').val(event.location || ''); var description = $('#edit-description').text(event.description || ''); var vurl = $('#edit-url').val(event.vurl || ''); var categories = $('#edit-categories').val(event.categories); var calendars = $('#edit-calendar').val(event.calendar); var eventstatus = $('#edit-event-status').val(event.status); var freebusy = $('#edit-free-busy').val(event.free_busy); var priority = $('#edit-priority').val(event.priority); var sensitivity = $('#edit-sensitivity').val(event.sensitivity); var duration = Math.round((event.end.getTime() - event.start.getTime()) / 1000); var startdate = $('#edit-startdate').val($.fullCalendar.formatDate(event.start, settings['date_format'])).data('duration', duration); var starttime = $('#edit-starttime').val($.fullCalendar.formatDate(event.start, settings['time_format'])).show(); var enddate = $('#edit-enddate').val($.fullCalendar.formatDate(event.end, settings['date_format'])); var endtime = $('#edit-endtime').val($.fullCalendar.formatDate(event.end, settings['time_format'])).show(); var allday = $('#edit-allday').get(0); var notify = $('#edit-attendees-donotify').get(0); var invite = $('#edit-attendees-invite').get(0); var comment = $('#edit-attendees-comment'); invite.checked = settings.itip_notify & 1 > 0; notify.checked = has_attendees(event) && invite.checked; if (event.allDay) { starttime.val("12:00").hide(); endtime.val("13:00").hide(); allday.checked = true; } else { allday.checked = false; } // set calendar selection according to permissions calendars.find('option').each(function(i, opt) { var cal = me.calendars[opt.value] || {}; $(opt).prop('disabled', !(cal.editable || (action == 'new' && has_permission(cal, 'i')))) }); // set alarm(s) me.set_alarms_edit('#edit-alarms', action != 'new' && event.valarms && calendar.alarms ? event.valarms : []); // enable/disable alarm property according to backend support $('#edit-alarms')[(calendar.alarms ? 'show' : 'hide')](); // check categories drop-down: add value if not exists if (event.categories && !categories.find("option[value='"+event.categories+"']").length) { $('