diff plugins/libcalendaring/libcalendaring.js @ 4:888e774ee983

libcalendar plugin as distributed
author Charlie Root
date Sat, 13 Jan 2018 08:57:56 -0500
parents
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/libcalendaring/libcalendaring.js	Sat Jan 13 08:57:56 2018 -0500
@@ -0,0 +1,1464 @@
+/**
+ * Basic Javascript utilities for calendar-related plugins
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * @licstart  The following is the entire license notice for the
+ * JavaScript code in this page.
+ *
+ * Copyright (C) 2012-2015, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ *
+ * @licend  The above is the entire license notice
+ * for the JavaScript code in this page.
+ */
+
+function rcube_libcalendaring(settings)
+{
+    // member vars
+    this.settings = settings || {};
+    this.alarm_ids = [];
+    this.alarm_dialog = null;
+    this.snooze_popup = null;
+    this.dismiss_link = null;
+    this.group2expand = {};
+
+    // abort if env isn't set
+    if (!settings || !settings.date_format)
+      return;
+
+    // private vars
+    var me = this;
+    var gmt_offset = (new Date().getTimezoneOffset() / -60) - (settings.timezone || 0) - (settings.dst || 0);
+    var client_timezone = new Date().getTimezoneOffset();
+
+    // 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
+    };
+
+
+    /**
+     * Quote html entities
+     */
+    var Q = this.quote_html = function(str)
+    {
+      return String(str).replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+    };
+
+    /**
+     * Create a nice human-readable string for the date/time range
+     */
+    this.event_date_text = function(event, voice)
+    {
+      if (!event.start)
+        return '';
+      if (!event.end)
+        event.end = event.start;
+
+      var fromto, duration = event.end.getTime() / 1000 - event.start.getTime() / 1000,
+        until = voice ? ' ' + rcmail.gettext('until','libcalendaring') + ' ' : ' — ';
+      if (event.allDay) {
+        fromto = this.format_datetime(event.start, 1, voice)
+          + (duration > 86400 || event.start.getDay() != event.end.getDay() ? until + this.format_datetime(event.end, 1, voice) : '');
+      }
+      else if (duration < 86400 && event.start.getDay() == event.end.getDay()) {
+        fromto = this.format_datetime(event.start, 0, voice)
+          + (duration > 0 ? until + this.format_datetime(event.end, 2, voice) : '');
+      }
+      else {
+        fromto = this.format_datetime(event.start, 0, voice)
+          + (duration > 0 ? until + this.format_datetime(event.end, 0, voice) : '');
+      }
+
+      return fromto;
+    };
+
+    /**
+     * Checks if the event/task has 'real' attendees, excluding the current user
+     */
+    this.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/task
+     */
+    this.is_attendee = function(event, role, email)
+    {
+        var i, emails = email ? ';' + email.toLowerCase() : settings.identity.emails;
+
+        for (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;
+    };
+
+    /**
+     * Checks if the current user is the organizer of the event/task
+     */
+    this.is_organizer = function(event, email)
+    {
+        return this.is_attendee(event, 'ORGANIZER', email) || !event.id;
+    };
+
+    /**
+     * Check permissions on the given folder object
+     */
+    this.has_permission = function(folder, perm)
+    {
+        // multiple chars means "either of"
+        if (String(perm).length > 1) {
+            for (var i=0; i < perm.length; i++) {
+                if (this.has_permission(folder, perm[i])) {
+                    return true;
+                }
+            }
+        }
+
+        if (folder.rights && String(folder.rights).indexOf(perm) >= 0) {
+            return true;
+        }
+
+        return (perm == 'i' && folder.editable) || (perm == 'v' && folder.editable);
+    };
+
+
+    /**
+     * From time and date strings to a real date object
+     */
+    this.parse_datetime = function(time, date)
+    {
+        // we use the utility function from datepicker to parse dates
+        var date = date ? $.datepicker.parseDate(datepicker_settings.dateFormat, date, datepicker_settings) : new Date();
+
+        var time_arr = time.replace(/\s*[ap][.m]*/i, '').replace(/0([0-9])/g, '$1').split(/[:.]/);
+        if (!isNaN(time_arr[0])) {
+            date.setHours(time_arr[0]);
+        if (time.match(/p[.m]*/i) && date.getHours() < 12)
+            date.setHours(parseInt(time_arr[0]) + 12);
+        else if (time.match(/a[.m]*/i) && date.getHours() == 12)
+            date.setHours(0);
+      }
+      if (!isNaN(time_arr[1]))
+            date.setMinutes(time_arr[1]);
+
+      return date;
+    }
+
+    /**
+     * Convert an ISO 8601 formatted date string from the server into a Date object.
+     * Timezone information will be ignored, the server already provides dates in user's timezone.
+     */
+    this.parseISO8601 = function(s)
+    {
+        // already a Date object?
+        if (s && s.getMonth) {
+            return s;
+        }
+
+        // force d to be on check's YMD, for daylight savings purposes
+        var fixDate = function(d, check) {
+            if (+d) { // prevent infinite looping on invalid dates
+                while (d.getDate() != check.getDate()) {
+                    d.setTime(+d + (d < check ? 1 : -1) * 3600000);
+                }
+            }
+        }
+
+        // derived from http://delete.me.uk/2005/03/iso8601.html
+        var m = s && s.match(/^([0-9]{4})(-([0-9]{2})(-([0-9]{2})([T ]([0-9]{2}):([0-9]{2})(:([0-9]{2})(\.([0-9]+))?)?(Z|(([-+])([0-9]{2})(:?([0-9]{2}))?))?)?)?)?$/);
+        if (!m) {
+            return null;
+        }
+
+        var date = new Date(m[1], 0, 2),
+            check = new Date(m[1], 0, 2, 9, 0);
+        if (m[3]) {
+            date.setMonth(m[3] - 1);
+            check.setMonth(m[3] - 1);
+        }
+        if (m[5]) {
+            date.setDate(m[5]);
+            check.setDate(m[5]);
+        }
+        fixDate(date, check);
+        if (m[7]) {
+            date.setHours(m[7]);
+        }
+        if (m[8]) {
+            date.setMinutes(m[8]);
+        }
+        if (m[10]) {
+            date.setSeconds(m[10]);
+        }
+        if (m[12]) {
+            date.setMilliseconds(Number("0." + m[12]) * 1000);
+        }
+        fixDate(date, check);
+
+        return date;
+    }
+
+    /**
+     * Turn the given date into an ISO 8601 date string understandable by PHPs strtotime()
+     */
+    this.date2ISO8601 = function(date)
+    {
+        var zeropad = function(num) { return (num < 10 ? '0' : '') + num; };
+
+        return date.getFullYear() + '-' + zeropad(date.getMonth()+1) + '-' + zeropad(date.getDate())
+            + 'T' + zeropad(date.getHours()) + ':' + zeropad(date.getMinutes()) + ':' + zeropad(date.getSeconds());
+    };
+
+    /**
+     * Format the given date object according to user's prefs
+     */
+    this.format_datetime = function(date, mode, voice)
+    {
+        var res = '';
+        if (!mode || mode == 1) {
+          res += $.datepicker.formatDate(voice ? 'MM d yy' : datepicker_settings.dateFormat, date, datepicker_settings);
+        }
+        if (!mode) {
+            res += voice ? ' ' + rcmail.gettext('at','libcalendaring') + ' ' : ' ';
+        }
+        if (!mode || mode == 2) {
+            res += this.format_time(date, voice);
+        }
+
+        return res;
+    }
+
+    /**
+     * Clone from fullcalendar.js
+     */
+    this.format_time = function(date, voice)
+    {
+        var zeroPad = function(n) { return (n < 10 ? '0' : '') + n; }
+        var formatters = {
+            s   : function(d) { return d.getSeconds() },
+            ss  : function(d) { return zeroPad(d.getSeconds()) },
+            m   : function(d) { return d.getMinutes() },
+            mm  : function(d) { return zeroPad(d.getMinutes()) },
+            h   : function(d) { return d.getHours() % 12 || 12 },
+            hh  : function(d) { return zeroPad(d.getHours() % 12 || 12) },
+            H   : function(d) { return d.getHours() },
+            HH  : function(d) { return zeroPad(d.getHours()) },
+            t   : function(d) { return d.getHours() < 12 ? 'a' : 'p' },
+            tt  : function(d) { return d.getHours() < 12 ? 'am' : 'pm' },
+            T   : function(d) { return d.getHours() < 12 ? 'A' : 'P' },
+            TT  : function(d) { return d.getHours() < 12 ? 'AM' : 'PM' }
+        };
+
+        var i, i2, c, formatter, res = '',
+          format = voice ? settings['time_format'].replace(':',' ').replace('HH','H').replace('hh','h').replace('mm','m').replace('ss','s') : settings['time_format'];
+        for (i=0; i < format.length; i++) {
+            c = format.charAt(i);
+            for (i2=Math.min(i+2, format.length); i2 > i; i2--) {
+                if (formatter = formatters[format.substring(i, i2)]) {
+                    res += formatter(date);
+                    i = i2 - 1;
+                    break;
+                }
+            }
+            if (i2 == i) {
+                res += c;
+            }
+        }
+
+        return res;
+    }
+
+    /**
+     * Convert the given Date object into a unix timestamp respecting browser's and user's timezone settings
+     */
+    this.date2unixtime = function(date)
+    {
+        var dst_offset = (client_timezone - date.getTimezoneOffset()) * 60;  // adjust DST offset
+        return Math.round(date.getTime()/1000 + gmt_offset * 3600 + dst_offset);
+    }
+
+    /**
+     * Turn a unix timestamp value into a Date object
+     */
+    this.fromunixtime = function(ts)
+    {
+        ts -= gmt_offset * 3600;
+        var date = new Date(ts * 1000),
+            dst_offset = (client_timezone - date.getTimezoneOffset()) * 60;
+        if (dst_offset)  // adjust DST offset
+            date.setTime((ts + 3600) * 1000);
+        return date;
+    }
+
+    /**
+     * Simple plaintext to HTML converter, makig URLs clickable
+     */
+    this.text2html = function(str, maxlen, maxlines)
+    {
+        var html = Q(String(str));
+
+        // limit visible text length
+        if (maxlen) {
+            var morelink = '<span>... <a href="#more" onclick="$(this).parent().hide().next().show();return false" class="morelink">'+rcmail.gettext('showmore','libcalendaring')+'</a></span><span style="display:none">',
+                lines = html.split(/\r?\n/),
+                words, out = '', len = 0;
+
+            for (var i=0; i < lines.length; i++) {
+                len += lines[i].length;
+                if (maxlines && i == maxlines - 1) {
+                    out += lines[i] + '\n' + morelink;
+                    maxlen = html.length * 2;
+                }
+                else if (len > maxlen) {
+                    len = out.length;
+                    words = lines[i].split(' ');
+                    for (var j=0; j < words.length; j++) {
+                        len += words[j].length + 1;
+                        out += words[j] + ' ';
+                        if (len > maxlen) {
+                            out += morelink;
+                            maxlen = html.length * 2;
+                            maxlines = 0;
+                        }
+                    }
+                    out += '\n';
+                }
+                else
+                    out += lines[i] + '\n';
+            }
+
+            if (maxlen > str.length)
+                out += '</span>';
+
+            html = out;
+        }
+
+        // simple link parser (similar to rcube_string_replacer class in PHP)
+        var utf_domain = '[^?&@"\'/\\(\\)\\s\\r\\t\\n]+\\.([^\x00-\x2f\x3b-\x40\x5b-\x60\x7b-\x7f]{2,}|xn--[a-z0-9]{2,})';
+        var url1 = '.:;,', url2 = 'a-z0-9%=#@+?&/_~\\[\\]-';
+        var link_pattern = new RegExp('([hf]t+ps?://)('+utf_domain+'(['+url1+']?['+url2+']+)*)', 'ig');
+        var mailto_pattern = new RegExp('([^\\s\\n\\(\\);]+@'+utf_domain+')', 'ig');
+        var link_replace = function(matches, p1, p2) {
+          var title = '', text = p2;
+          if (p2 && p2.length > 55) {
+            text = p2.substr(0, 45) + '...' + p2.substr(-8);
+            title = p1 + p2;
+          }
+          return '<a href="'+p1+p2+'" class="extlink" target="_blank" title="'+title+'">'+p1+text+'</a>'
+        };
+
+        return html
+            .replace(link_pattern, link_replace)
+            .replace(mailto_pattern, '<a href="mailto:$1">$1</a>')
+            .replace(/(mailto:)([^"]+)"/g, '$1$2" onclick="rcmail.command(\'compose\', \'$2\');return false"')
+            .replace(/\n/g, "<br/>");
+    };
+
+    this.init_alarms_edit = function(prefix, index)
+    {
+        var edit_type = $(prefix+' select.edit-alarm-type'),
+          dom_id = edit_type.attr('id');
+
+        // register events on alarm fields
+        edit_type.change(function(){
+            $(this).parent().find('span.edit-alarm-values')[(this.selectedIndex>0?'show':'hide')]();
+        });
+        $(prefix+' select.edit-alarm-offset').change(function(){
+            var val = $(this).val(), parent = $(this).parent();
+            parent.find('.edit-alarm-date, .edit-alarm-time')[val == '@' ? 'show' : 'hide']();
+            parent.find('.edit-alarm-value').prop('disabled', val === '@' || val === '0');
+            parent.find('.edit-alarm-related')[val == '@' ? 'hide' : 'show']();
+        });
+
+        $(prefix+' .edit-alarm-date').removeClass('hasDatepicker').removeAttr('id').datepicker(datepicker_settings);
+
+        $(prefix).on('click', 'a.delete-alarm', function(e){
+            if ($(this).closest('.edit-alarm-item').siblings().length > 0) {
+                $(this).closest('.edit-alarm-item').remove();
+            }
+            return false;
+        });
+
+        // set a unique id attribute and set label reference accordingly
+        if ((index || 0) > 0 && dom_id) {
+            dom_id += ':' + (new Date().getTime());
+            edit_type.attr('id', dom_id);
+            $(prefix+' label:first').attr('for', dom_id);
+        }
+
+        $(prefix).on('click', 'a.add-alarm', function(e){
+            var i = $(this).closest('.edit-alarm-item').siblings().length + 1;
+            var item = $(this).closest('.edit-alarm-item').clone(false)
+              .removeClass('first')
+              .appendTo(prefix);
+
+              me.init_alarms_edit(prefix + ' .edit-alarm-item:eq(' + i + ')', i);
+              $('select.edit-alarm-type, select.edit-alarm-offset', item).change();
+              return false;
+        });
+    }
+
+    this.set_alarms_edit = function(prefix, valarms)
+    {
+        $(prefix + ' .edit-alarm-item:gt(0)').remove();
+
+        var i, alarm, domnode, val, offset;
+        for (i=0; i < valarms.length; i++) {
+          alarm = valarms[i];
+          if (!alarm.action)
+              alarm.action = 'DISPLAY';
+
+          if (i == 0) {
+              domnode = $(prefix + ' .edit-alarm-item').eq(0);
+          }
+          else {
+              domnode = $(prefix + ' .edit-alarm-item').eq(0).clone(false).removeClass('first').appendTo(prefix);
+              this.init_alarms_edit(prefix + ' .edit-alarm-item:eq(' + i + ')', i);
+          }
+
+          $('select.edit-alarm-type', domnode).val(alarm.action);
+          $('select.edit-alarm-related', domnode).val(/END/i.test(alarm.related) ? 'end' : 'start');
+
+          if (String(alarm.trigger).match(/@(\d+)/)) {
+              var ondate = this.fromunixtime(parseInt(RegExp.$1));
+              $('select.edit-alarm-offset', domnode).val('@');
+              $('input.edit-alarm-value', domnode).val('');
+              $('input.edit-alarm-date', domnode).val(this.format_datetime(ondate, 1));
+              $('input.edit-alarm-time', domnode).val(this.format_datetime(ondate, 2));
+          }
+          else if (String(alarm.trigger).match(/^[-+]*0[MHDS]$/)) {
+              $('input.edit-alarm-value', domnode).val('0');
+              $('select.edit-alarm-offset', domnode).val('0');
+          }
+          else if (String(alarm.trigger).match(/([-+])(\d+)([MHDS])/)) {
+              val = RegExp.$2; offset = ''+RegExp.$1+RegExp.$3;
+              $('input.edit-alarm-value', domnode).val(val);
+              $('select.edit-alarm-offset', domnode).val(offset);
+          }
+        }
+
+        // set correct visibility by triggering onchange handlers
+        $(prefix + ' select.edit-alarm-type, ' + prefix + ' select.edit-alarm-offset').change();
+    };
+
+    this.serialize_alarms = function(prefix)
+    {
+        var valarms = [];
+
+        $(prefix + ' .edit-alarm-item').each(function(i, elem) {
+            var val, offset, alarm = {
+                    action: $('select.edit-alarm-type', elem).val(),
+                    related: $('select.edit-alarm-related', elem).val()
+                };
+
+            if (alarm.action) {
+                offset = $('select.edit-alarm-offset', elem).val();
+                if (offset == '@') {
+                    alarm.trigger = '@' + me.date2unixtime(me.parse_datetime($('input.edit-alarm-time', elem).val(), $('input.edit-alarm-date', elem).val()));
+                }
+                else if (offset === '0') {
+                    alarm.trigger = '0S';
+                }
+                else if (!isNaN((val = parseInt($('input.edit-alarm-value', elem).val()))) && val >= 0) {
+                    alarm.trigger = offset[0] + val + offset[1];
+                }
+
+                valarms.push(alarm);
+            }
+        });
+
+        return valarms;
+    };
+
+    // format time string
+    var time_autocomplete_format = function(hour, minutes, start) {
+        var time, diff, unit, duration = '', d = new Date();
+
+        d.setHours(hour);
+        d.setMinutes(minutes);
+        time = me.format_time(d);
+
+        if (start) {
+            diff = Math.floor((d.getTime() - start.getTime()) / 60000);
+            if (diff > 0) {
+                unit = 'm';
+                if (diff >= 60) {
+                    unit = 'h';
+                    diff = Math.round(diff / 3) / 20;
+                }
+                duration = ' (' + diff + unit + ')';
+            }
+        }
+
+        return [time, duration];
+    };
+
+    var time_autocomplete_list = function(p, callback) {
+        // Time completions
+        var st, h, step = 15, result = [], now = new Date(),
+            id = String(this.element.attr('id')),
+            m = id.match(/^(.*)-(starttime|endtime)$/),
+            start = (m && m[2] == 'endtime'
+                && (st = $('#' + m[1] + '-starttime').val())
+                && $('#' + m[1] + '-startdate').val() == $('#' + m[1] + '-enddate').val())
+                ? me.parse_datetime(st, '') : null,
+            full = p.term - 1 > 0 || p.term.length > 1,
+            hours = start ? start.getHours() : (full ? me.parse_datetime(p.term, '') : now).getHours(),
+            minutes = hours * 60 + (full ? 0 : now.getMinutes()),
+            min = Math.ceil(minutes / step) * step % 60,
+            hour = Math.floor(Math.ceil(minutes / step) * step / 60);
+
+        // list hours from 0:00 till now
+        for (h = start ? start.getHours() : 0; h < hours; h++)
+            result.push(time_autocomplete_format(h, 0, start));
+
+        // list 15min steps for the next two hours
+        for (; h < hour + 2 && h < 24; h++) {
+            while (min < 60) {
+                result.push(time_autocomplete_format(h, min, start));
+                min += step;
+            }
+            min = 0;
+        }
+
+        // list the remaining hours till 23:00
+        while (h < 24)
+            result.push(time_autocomplete_format((h++), 0, start));
+
+        return callback(result);
+    };
+
+    var time_autocomplete_open = function(event, ui) {
+        // scroll to current time
+        var $this = $(this),
+            widget = $this.autocomplete('widget')
+            menu = $this.data('ui-autocomplete').menu,
+            amregex = /^(.+)(a[.m]*)/i,
+            pmregex = /^(.+)(a[.m]*)/i,
+            val = $(this).val().replace(amregex, '0:$1').replace(pmregex, '1:$1');
+
+        widget.css('width', '10em');
+
+        if (val === '')
+            menu._scrollIntoView(widget.children('li:first'));
+        else
+            widget.children().each(function() {
+                var li = $(this),
+                    html = li.children().first().html()
+                        .replace(/\s+\(.+\)$/, '')
+                        .replace(amregex, '0:$1')
+                        .replace(pmregex, '1:$1');
+
+                if (html.indexOf(val) == 0)
+                    menu._scrollIntoView(li);
+            });
+    };
+
+    /**
+     * Initializes time autocompletion
+     */
+    this.init_time_autocomplete = function(elem, props)
+    {
+        var default_props = {
+                delay: 100,
+                minLength: 1,
+                appendTo: props.container,
+                source: time_autocomplete_list,
+                open: time_autocomplete_open,
+                // change: time_autocomplete_change,
+                select: function(event, ui) {
+                    $(this).val(ui.item[0]).change();
+                    return false;
+                }
+            };
+
+        $(elem).attr('autocomplete', "off")
+            .autocomplete($.extend(default_props, props))
+            .click(function() {  // show drop-down upon clicks
+                $(this).autocomplete('search', $(this).val() ? $(this).val().replace(/\D.*/, "") : " ");
+            });
+
+        $(elem).data('ui-autocomplete')._renderItem = function(ul, item) {
+            return $('<li>')
+                .data('ui-autocomplete-item', item)
+                .append('<a>' + item[0] + item[1] + '</a>')
+                .appendTo(ul);
+        };
+    };
+
+    /*****  Alarms handling  *****/
+
+    /**
+     * Display a notification for the given pending alarms
+     */
+    this.display_alarms = function(alarms)
+    {
+        // clear old alert first
+        if (this.alarm_dialog)
+            this.alarm_dialog.dialog('destroy').remove();
+
+        var i, actions, adismiss, asnooze, alarm, html,
+            audio_alarms = [], records = [], event_ids = [], buttons = {};
+
+        for (i=0; i < alarms.length; i++) {
+            alarm = alarms[i];
+            alarm.start = this.parseISO8601(alarm.start);
+            alarm.end = this.parseISO8601(alarm.end);
+
+            if (alarm.action == 'AUDIO') {
+                audio_alarms.push(alarm);
+                continue;
+            }
+
+            event_ids.push(alarm.id);
+
+            html = '<h3 class="event-title">' + Q(alarm.title) + '</h3>';
+            html += '<div class="event-section">' + Q(alarm.location || '') + '</div>';
+            html += '<div class="event-section">' + Q(this.event_date_text(alarm)) + '</div>';
+
+            adismiss = $('<a href="#" class="alarm-action-dismiss"></a>').html(rcmail.gettext('dismiss','libcalendaring')).click(function(e){
+                me.dismiss_link = $(this);
+                me.dismiss_alarm(me.dismiss_link.data('id'), 0, e);
+            });
+            asnooze = $('<a href="#" class="alarm-action-snooze"></a>').html(rcmail.gettext('snooze','libcalendaring')).click(function(e){
+                me.snooze_dropdown($(this), e);
+                e.stopPropagation();
+                return false;
+            });
+            actions = $('<div>').addClass('alarm-actions').append(adismiss.data('id', alarm.id)).append(asnooze.data('id', alarm.id));
+
+            records.push($('<div>').addClass('alarm-item').html(html).append(actions));
+        }
+
+        if (audio_alarms.length)
+            this.audio_alarms(audio_alarms);
+
+        if (!records.length)
+            return;
+
+        this.alarm_dialog = $('<div>').attr('id', 'alarm-display').append(records);
+
+        buttons[rcmail.gettext('close')] = function() {
+            $(this).dialog('close');
+        };
+
+        buttons[rcmail.gettext('dismissall','libcalendaring')] = function(e) {
+            // submit dismissed event_ids to server
+            me.dismiss_alarm(me.alarm_ids.join(','), 0, e);
+            $(this).dialog('close');
+        };
+
+        this.alarm_dialog.appendTo(document.body).dialog({
+            modal: false,
+            resizable: true,
+            closeOnEscape: false,
+            dialogClass: 'alarms',
+            title: rcmail.gettext('alarmtitle','libcalendaring'),
+            buttons: buttons,
+            open: function() {
+              setTimeout(function() {
+                me.alarm_dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus();
+              }, 5);
+            },
+            close: function() {
+              $('#alarm-snooze-dropdown').hide();
+              $(this).dialog('destroy').remove();
+              me.alarm_dialog = null;
+              me.alarm_ids = null;
+            },
+            drag: function(event, ui) {
+              $('#alarm-snooze-dropdown').hide();
+            }
+        });
+
+        this.alarm_dialog.closest('div[role=dialog]').attr('role', 'alertdialog');
+
+        this.alarm_ids = event_ids;
+    };
+
+    /**
+     * Display a notification and play a sound for a set of alarms
+     */
+    this.audio_alarms = function(alarms)
+    {
+        var elem, txt = [],
+            src = rcmail.assets_path('plugins/libcalendaring/alarm'),
+            plugin = navigator.mimeTypes ? navigator.mimeTypes['audio/mp3'] : {};
+
+        // first generate and display notification text
+        $.each(alarms, function() { txt.push(this.title); });
+
+        rcmail.display_message(rcmail.gettext('alarmtitle','libcalendaring') + ': ' + Q(txt.join(', ')), 'notice', 10000);
+
+        // Internet Explorer does not support wav files,
+        // support in other browsers depends on enabled plugins,
+        // so we use wav as a fallback
+        src += bw.ie || (plugin && plugin.enabledPlugin) ? '.mp3' : '.wav';
+
+        // HTML5
+        try {
+            elem = $('<audio>').attr('src', src);
+            elem.get(0).play();
+        }
+        // old method
+        catch (e) {
+            elem = $('<embed id="libcalsound" src="' + src + '" hidden=true autostart=true loop=false />');
+            elem.appendTo($('body'));
+            window.setTimeout("$('#libcalsound').remove()", 10000);
+        }
+    };
+
+    /**
+     * Show a drop-down menu with a selection of snooze times
+     */
+    this.snooze_dropdown = function(link, event)
+    {
+        if (!this.snooze_popup) {
+            this.snooze_popup = $('#alarm-snooze-dropdown');
+            // create popup if not found
+            if (!this.snooze_popup.length) {
+                this.snooze_popup = $('<div>').attr('id', 'alarm-snooze-dropdown').addClass('popupmenu').appendTo(document.body);
+                this.snooze_popup.html(rcmail.env.snooze_select)
+            }
+            $('#alarm-snooze-dropdown a').click(function(e){
+                var time = String(this.href).replace(/.+#/, '');
+                me.dismiss_alarm($('#alarm-snooze-dropdown').data('id'), time, e);
+                return false;
+            });
+        }
+
+        // hide visible popup
+        if (this.snooze_popup.is(':visible') && this.snooze_popup.data('id') == link.data('id')) {
+            rcmail.command('menu-close', 'alarm-snooze-dropdown', link.get(0), event);
+            this.dismiss_link = null;
+        }
+        else {  // open popup below the clicked link
+            rcmail.command('menu-open', 'alarm-snooze-dropdown', link.get(0), event);
+            this.snooze_popup.data('id', link.data('id'));
+            this.dismiss_link = link;
+        }
+    };
+
+    /**
+     * Dismiss or snooze alarms for the given event
+     */
+    this.dismiss_alarm = function(id, snooze, event)
+    {
+        rcmail.command('menu-close', 'alarm-snooze-dropdown', null, event);
+        rcmail.http_post('utils/plugin.alarms', { action:'dismiss', data:{ id:id, snooze:snooze } });
+
+        // remove dismissed alarm from list
+        if (this.dismiss_link) {
+            this.dismiss_link.closest('div.alarm-item').hide();
+            var new_ids = jQuery.grep(this.alarm_ids, function(v){ return v != id; });
+            if (new_ids.length)
+                this.alarm_ids = new_ids;
+            else
+                this.alarm_dialog.dialog('close');
+        }
+
+        this.dismiss_link = null;
+    };
+
+
+    /*****  Recurrence form handling  *****/
+
+    /**
+     * Install event handlers on recurrence form elements
+     */
+    this.init_recurrence_edit = function(prefix)
+    {
+        // toggle recurrence frequency forms
+        $('#edit-recurrence-frequency').change(function(e){
+            var freq = $(this).val().toLowerCase();
+            $('.recurrence-form').hide();
+            if (freq) {
+              $('#recurrence-form-'+freq).show();
+              if (freq != 'rdate')
+                $('#recurrence-form-until').show();
+            }
+        });
+        $('#recurrence-form-rdate input.button.add').click(function(e){
+            var dt, dv = $('#edit-recurrence-rdate-input').val();
+            if (dv && (dt = me.parse_datetime('12:00', dv))) {
+                me.add_rdate(dt);
+                me.sort_rdates();
+                $('#edit-recurrence-rdate-input').val('')
+            }
+            else {
+                $('#edit-recurrence-rdate-input').select();
+            }
+        });
+        $('#edit-recurrence-rdates').on('click', 'a.delete', function(e){
+            $(this).closest('li').remove();
+            return false;
+        });
+
+        $('#edit-recurrence-enddate').datepicker(datepicker_settings).click(function(){ $("#edit-recurrence-repeat-until").prop('checked', true) });
+        $('#edit-recurrence-repeat-times').change(function(e){ $('#edit-recurrence-repeat-count').prop('checked', true); });
+        $('#edit-recurrence-rdate-input').datepicker(datepicker_settings);
+    };
+
+    /**
+     * Set recurrence form according to the given event/task record
+     */
+    this.set_recurrence_edit = function(rec)
+    {
+        var recurrence = $('#edit-recurrence-frequency').val(rec.recurrence ? rec.recurrence.FREQ || (rec.recurrence.RDATE ? 'RDATE' : '') : '').change(),
+            interval = $('.recurrence-form select.edit-recurrence-interval').val(rec.recurrence ? rec.recurrence.INTERVAL || 1 : 1),
+            rrtimes = $('#edit-recurrence-repeat-times').val(rec.recurrence ? rec.recurrence.COUNT || 1 : 1),
+            rrenddate = $('#edit-recurrence-enddate').val(rec.recurrence && rec.recurrence.UNTIL ? this.format_datetime(this.parseISO8601(rec.recurrence.UNTIL), 1) : '');
+        $('.recurrence-form input.edit-recurrence-until:checked').prop('checked', false);
+        $('#edit-recurrence-rdates').html('');
+
+        var weekdays = ['SU','MO','TU','WE','TH','FR','SA'],
+            rrepeat_id = '#edit-recurrence-repeat-forever';
+        if      (rec.recurrence && rec.recurrence.COUNT) rrepeat_id = '#edit-recurrence-repeat-count';
+        else if (rec.recurrence && rec.recurrence.UNTIL) rrepeat_id = '#edit-recurrence-repeat-until';
+        $(rrepeat_id).prop('checked', true);
+
+        if (rec.recurrence && rec.recurrence.BYDAY && rec.recurrence.FREQ == 'WEEKLY') {
+            var wdays = rec.recurrence.BYDAY.split(',');
+            $('input.edit-recurrence-weekly-byday').val(wdays);
+        }
+        if (rec.recurrence && rec.recurrence.BYMONTHDAY) {
+            $('input.edit-recurrence-monthly-bymonthday').val(String(rec.recurrence.BYMONTHDAY).split(','));
+            $('input.edit-recurrence-monthly-mode').val(['BYMONTHDAY']);
+        }
+        if (rec.recurrence && rec.recurrence.BYDAY && (rec.recurrence.FREQ == 'MONTHLY' || rec.recurrence.FREQ == 'YEARLY')) {
+            var byday, section = rec.recurrence.FREQ.toLowerCase();
+            if ((byday = String(rec.recurrence.BYDAY).match(/(-?[1-4])([A-Z]+)/))) {
+                $('#edit-recurrence-'+section+'-prefix').val(byday[1]);
+                $('#edit-recurrence-'+section+'-byday').val(byday[2]);
+            }
+            $('input.edit-recurrence-'+section+'-mode').val(['BYDAY']);
+        }
+        else if (rec.start) {
+            $('#edit-recurrence-monthly-byday').val(weekdays[rec.start.getDay()]);
+        }
+        if (rec.recurrence && rec.recurrence.BYMONTH) {
+            $('input.edit-recurrence-yearly-bymonth').val(String(rec.recurrence.BYMONTH).split(','));
+        }
+        else if (rec.start) {
+            $('input.edit-recurrence-yearly-bymonth').val([String(rec.start.getMonth()+1)]);
+        }
+        if (rec.recurrence && rec.recurrence.RDATE) {
+            $.each(rec.recurrence.RDATE, function(i,rdate){
+                me.add_rdate(me.parseISO8601(rdate));
+            });
+        }
+    };
+
+    /**
+     * Gather recurrence settings from form
+     */
+    this.serialize_recurrence = function(timestr)
+    {
+        var recurrence = '',
+            freq = $('#edit-recurrence-frequency').val();
+
+        if (freq != '') {
+            recurrence = {
+                FREQ: freq,
+                INTERVAL: $('#edit-recurrence-interval-'+freq.toLowerCase()).val()
+            };
+
+            var until = $('input.edit-recurrence-until:checked').val();
+            if (until == 'count')
+                recurrence.COUNT = $('#edit-recurrence-repeat-times').val();
+            else if (until == 'until')
+                recurrence.UNTIL = me.date2ISO8601(me.parse_datetime(timestr || '00:00', $('#edit-recurrence-enddate').val()));
+
+            if (freq == 'WEEKLY') {
+                var byday = [];
+                $('input.edit-recurrence-weekly-byday:checked').each(function(){ byday.push(this.value); });
+                if (byday.length)
+                    recurrence.BYDAY = byday.join(',');
+            }
+            else if (freq == 'MONTHLY') {
+                var mode = $('input.edit-recurrence-monthly-mode:checked').val(), bymonday = [];
+                if (mode == 'BYMONTHDAY') {
+                    $('input.edit-recurrence-monthly-bymonthday:checked').each(function(){ bymonday.push(this.value); });
+                    if (bymonday.length)
+                        recurrence.BYMONTHDAY = bymonday.join(',');
+                }
+                else
+                    recurrence.BYDAY = $('#edit-recurrence-monthly-prefix').val() + $('#edit-recurrence-monthly-byday').val();
+            }
+            else if (freq == 'YEARLY') {
+                var byday, bymonth = [];
+                $('input.edit-recurrence-yearly-bymonth:checked').each(function(){ bymonth.push(this.value); });
+                if (bymonth.length)
+                    recurrence.BYMONTH = bymonth.join(',');
+                if ((byday = $('#edit-recurrence-yearly-byday').val()))
+                    recurrence.BYDAY = $('#edit-recurrence-yearly-prefix').val() + byday;
+            }
+            else if (freq == 'RDATE') {
+                recurrence = { RDATE:[] };
+                // take selected but not yet added date into account
+                if ($('#edit-recurrence-rdate-input').val() != '') {
+                    $('#recurrence-form-rdate input.button.add').click();
+                }
+                $('#edit-recurrence-rdates li').each(function(i, li){
+                    recurrence.RDATE.push($(li).attr('data-value'));
+                });
+            }
+        }
+
+        return recurrence;
+    };
+
+    // add the given date to the RDATE list
+    this.add_rdate = function(date)
+    {
+        var li = $('<li>')
+            .attr('data-value', this.date2ISO8601(date))
+            .html('<span>' + Q(this.format_datetime(date, 1)) + '</span>')
+            .appendTo('#edit-recurrence-rdates');
+
+        $('<a>').attr('href', '#del')
+            .addClass('iconbutton delete')
+            .html(rcmail.get_label('delete', 'libcalendaring'))
+            .attr('title', rcmail.get_label('delete', 'libcalendaring'))
+            .appendTo(li);
+    };
+
+    // re-sort the list items by their 'data-value' attribute
+    this.sort_rdates = function()
+    {
+        var mylist = $('#edit-recurrence-rdates'),
+            listitems = mylist.children('li').get();
+        listitems.sort(function(a, b) {
+            var compA = $(a).attr('data-value');
+            var compB = $(b).attr('data-value');
+            return (compA < compB) ? -1 : (compA > compB) ? 1 : 0;
+        })
+        $.each(listitems, function(idx, item) { mylist.append(item); });
+    };
+
+
+    /*****  Attendee form handling  *****/
+
+    // expand the given contact group into individual event/task attendees
+    this.expand_attendee_group = function(e, add, remove)
+    {
+        var id = (e.data ? e.data.email : null) || $(e.target).attr('data-email'),
+            role_select = $(e.target).closest('tr').find('select.edit-attendee-role option:selected');
+
+        this.group2expand[id] = { link: e.target, data: $.extend({}, e.data || {}), adder: add, remover: remove }
+
+        // copy group role from the according form element
+        if (role_select.length) {
+            this.group2expand[id].data.role = role_select.val();
+        }
+
+        // register callback handler
+        if (!this._expand_attendee_listener) {
+            this._expand_attendee_listener = this.expand_attendee_callback;
+            rcmail.addEventListener('plugin.expand_attendee_callback', function(result) {
+                me._expand_attendee_listener(result);
+            });
+        }
+
+        rcmail.http_post('libcal/plugin.expand_attendee_group', { id: id, data: e.data || {} }, rcmail.set_busy(true, 'loading'));
+    };
+
+    // callback from server to expand an attendee group
+    this.expand_attendee_callback = function(result)
+    {
+        var attendee, id = result.id,
+            data = this.group2expand[id],
+            row = $(data.link).closest('tr');
+
+        // replace group entry with all members returned by the server
+        if (data && data.adder && result.members && result.members.length) {
+            for (var i=0; i < result.members.length; i++) {
+                attendee = result.members[i];
+                attendee.role = data.data.role;
+                attendee.cutype = 'INDIVIDUAL';
+                attendee.status = 'NEEDS-ACTION';
+                data.adder(attendee, null, row);
+            }
+
+            if (data.remover) {
+                data.remover(data.link, id)
+            }
+            else {
+                row.remove();
+            }
+
+            delete this.group2expand[id];
+        }
+        else {
+            rcmail.display_message(result.error || rcmail.gettext('expandattendeegroupnodata','libcalendaring'), 'error');
+        }
+    };
+
+
+    // Render message reference links to the given container
+    this.render_message_links = function(links, container, edit, plugin)
+    {
+        var ul = $('<ul>').addClass('attachmentslist');
+
+        $.each(links, function(i, link) {
+            if (!link.mailurl)
+                return true;  // continue
+
+            var li = $('<li>').addClass('link')
+                .addClass('message eml')
+                .append($('<a>')
+                    .attr('href', link.mailurl)
+                    .addClass('messagelink')
+                    .text(link.subject || link.uri)
+                )
+                .appendTo(ul);
+
+            // add icon to remove the link
+            if (edit) {
+                $('<a>')
+                    .attr('href', '#delete')
+                    .attr('title', rcmail.gettext('removelink', plugin))
+                    .attr('data-uri', link.uri)
+                    .addClass('delete')
+                    .text(rcmail.gettext('delete'))
+                    .appendTo(li);
+            }
+        });
+
+        container.empty().append(ul);
+    }
+
+    // resize and reposition (center) the dialog window
+    this.dialog_resize = function(id, height, width)
+    {
+        var win = $(window), w = win.width(), h = win.height();
+
+        $(id).dialog('option', {
+            height: Math.min(h-20, height+130),
+            width: Math.min(w-20, width+50)
+        });
+    };
+}
+
+//////  static methods
+
+// render HTML code for displaying an attendee record
+rcube_libcalendaring.attendee_html = function(data)
+{
+    var name, tooltip = '', context = 'libcalendaring',
+        dispname = data.name || data.email,
+        status = data.role == 'ORGANIZER' ? 'ORGANIZER' : data.status;
+
+    if (status)
+        status = status.toLowerCase();
+
+    if (data.email) {
+        tooltip = data.email;
+        name = $('<a>').attr({href: 'mailto:' + data.email, 'class': 'mailtolink', 'data-cutype': data.cutype})
+
+        if (status)
+            tooltip += ' (' + rcmail.gettext('status' + status, context) + ')';
+    }
+    else {
+        name = $('<span>');
+    }
+
+    if (data['delegated-to'])
+        tooltip = rcmail.gettext('delegatedto', context) + ' ' + data['delegated-to'];
+    else if (data['delegated-from'])
+        tooltip = rcmail.gettext('delegatedfrom', context) + ' ' + data['delegated-from'];
+
+    return $('<span>').append(
+            $('<span>').attr({'class': 'attendee ' + status, title: tooltip}).append(name.text(dispname))
+        ).html();
+};
+
+/**
+ *
+ */
+rcube_libcalendaring.add_from_itip_mail = function(mime_id, task, status, dom_id)
+{
+    // ask user to delete the declined event from the local calendar (#1670)
+    var del = false;
+    if (rcmail.env.rsvp_saved && status == 'declined') {
+        del = confirm(rcmail.gettext('itip.declinedeleteconfirm'));
+    }
+
+    // open dialog for iTip delegation
+    if (status == 'delegated') {
+        rcube_libcalendaring.itip_delegate_dialog(function(data) {
+            rcmail.http_post(task + '/itip-delegate', {
+                _uid: rcmail.env.uid,
+                _mbox: rcmail.env.mailbox,
+                _part: mime_id,
+                _to: data.to,
+                _rsvp: data.rsvp ? 1 : 0,
+                _comment: data.comment,
+                _folder: data.target
+            }, rcmail.set_busy(true, 'itip.savingdata'));
+        }, $('#rsvp-'+dom_id+' .folder-select'));
+        return false;
+    }
+
+    var noreply = 0, comment = '';
+    if (dom_id) {
+      noreply = $('#noreply-'+dom_id+':checked').length ? 1 : 0;
+      if (!noreply)
+        comment = $('#reply-comment-'+dom_id).val();
+    }
+
+    rcmail.http_post(task + '/mailimportitip', {
+        _uid: rcmail.env.uid,
+        _mbox: rcmail.env.mailbox,
+        _part: mime_id,
+        _folder: $('#itip-saveto').val(),
+        _status: status,
+        _del: del?1:0,
+        _noreply: noreply,
+        _comment: comment
+      }, rcmail.set_busy(true, 'itip.savingdata'));
+
+    return false;
+};
+
+/**
+ * Helper function to render the iTip delegation dialog
+ * and trigger a callback function when submitted.
+ */
+rcube_libcalendaring.itip_delegate_dialog = function(callback, selector)
+{
+    // show dialog for entering the delegatee address and comment
+    var html = '<form class="itip-dialog-form" action="javascript:void()">' +
+        '<div class="form-section">' +
+            '<label for="itip-delegate-to">' + rcmail.gettext('itip.delegateto') + '</label><br/>' +
+            '<input type="text" id="itip-delegate-to" class="text" size="40" value="" />' +
+        '</div>' +
+        '<div class="form-section">' +
+            '<label for="itip-delegate-rsvp">' +
+                '<input type="checkbox" id="itip-delegate-rsvp" class="checkbox" size="40" value="" />' +
+                rcmail.gettext('itip.delegatersvpme') +
+            '</label>' +
+        '</div>' +
+        '<div class="form-section">' +
+            '<textarea id="itip-delegate-comment" class="itip-comment" cols="40" rows="8" placeholder="' +
+                rcmail.gettext('itip.itipcomment') + '"></textarea>' + 
+        '</div>' +
+        '<div class="form-section">' +
+            (selector && selector.length ? selector.html() : '') +
+        '</div>' +
+    '</form>';
+
+    var dialog, buttons = [];
+    buttons.push({
+        text: rcmail.gettext('itipdelegated', 'itip'),
+        click: function() {
+            var doc = window.parent.document,
+                delegatee = String($('#itip-delegate-to', doc).val()).replace(/(^\s+)|(\s+$)/, '');
+
+            if (delegatee != '' && rcube_check_email(delegatee, true)) {
+                callback({
+                    to: delegatee,
+                    rsvp: $('#itip-delegate-rsvp', doc).prop('checked'),
+                    comment: $('#itip-delegate-comment', doc).val(),
+                    target: $('#itip-saveto', doc).val()
+                });
+
+                setTimeout(function() { dialog.dialog("close"); }, 500);
+            }
+            else {
+                alert(rcmail.gettext('itip.delegateinvalidaddress'));
+                $('#itip-delegate-to', doc).focus();
+            }
+        }
+    });
+
+    buttons.push({
+        text: rcmail.gettext('cancel', 'itip'),
+        click: function() {
+            dialog.dialog('close');
+        }
+    });
+
+    dialog = rcmail.show_popup_dialog(html, rcmail.gettext('delegateinvitation', 'itip'), buttons, {
+        width: 460,
+        open: function(event, ui) {
+            $(this).parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().addClass('mainaction');
+            $(this).find('#itip-saveto').val('');
+
+            // initialize autocompletion
+            var ac_props, rcm = rcmail.is_framed() ? parent.rcmail : rcmail;
+            if (rcmail.env.autocomplete_threads > 0) {
+                ac_props = {
+                    threads: rcmail.env.autocomplete_threads,
+                    sources: rcmail.env.autocomplete_sources
+                };
+            }
+            rcm.init_address_input_events($(this).find('#itip-delegate-to').focus(), ac_props);
+            rcm.env.recipients_delimiter = '';
+        },
+        close: function(event, ui) {
+            rcm = rcmail.is_framed() ? parent.rcmail : rcmail;
+            rcm.ksearch_blur();
+            $(this).remove();
+        }
+    });
+
+    return dialog;
+};
+
+/**
+ * Show a menu for selecting the RSVP reply mode
+ */
+rcube_libcalendaring.itip_rsvp_recurring = function(btn, callback)
+{
+    var mnu = $('<ul></ul>').addClass('popupmenu libcal-rsvp-replymode');
+
+    $.each(['all','current'/*,'future'*/], function(i, mode) {
+        $('<li><a>' + rcmail.get_label('rsvpmode'+mode, 'libcalendaring') + '</a>')
+        .addClass('ui-menu-item')
+        .attr('rel', mode)
+        .appendTo(mnu);
+    });
+
+    var action = btn.attr('rel');
+
+    // open the mennu
+    mnu.menu({
+        select: function(event, ui) {
+            callback(action, ui.item.attr('rel'));
+        }
+    })
+    .appendTo(document.body)
+    .position({ my: 'left top', at: 'left bottom+2', of: btn })
+    .data('action', action);
+
+    setTimeout(function() {
+        $(document).one('click', function() {
+            mnu.menu('destroy');
+            mnu.remove();
+        });
+    }, 100);
+};
+
+/**
+ *
+ */
+rcube_libcalendaring.remove_from_itip = function(event, task, title)
+{
+    if (confirm(rcmail.gettext('itip.deleteobjectconfirm').replace('$title', title))) {
+        rcmail.http_post(task + '/itip-remove',
+            event,
+            rcmail.set_busy(true, 'itip.savingdata')
+        );
+    }
+};
+
+/**
+ *
+ */
+rcube_libcalendaring.decline_attendee_reply = function(mime_id, task)
+{
+    // show dialog for entering a comment and send to server
+    var html = '<div class="itip-dialog-confirm-text">' + rcmail.gettext('itip.declineattendeeconfirm') + '</div>' +
+        '<textarea id="itip-decline-comment" class="itip-comment" cols="40" rows="8"></textarea>';
+
+    var dialog, buttons = [];
+    buttons.push({
+        text: rcmail.gettext('declineattendee', 'itip'),
+        click: function() {
+            rcmail.http_post(task + '/itip-decline-reply', {
+                _uid: rcmail.env.uid,
+                _mbox: rcmail.env.mailbox,
+                _part: mime_id,
+                _comment: $('#itip-decline-comment', window.parent.document).val()
+            }, rcmail.set_busy(true, 'itip.savingdata'));
+          dialog.dialog("close");
+        }
+    });
+
+    buttons.push({
+        text: rcmail.gettext('cancel', 'itip'),
+        click: function() {
+          dialog.dialog('close');
+        }
+    });
+
+    dialog = rcmail.show_popup_dialog(html, rcmail.gettext('declineattendee', 'itip'), buttons, {
+        width: 460,
+        open: function() {
+            $(this).parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().addClass('mainaction');
+            $('#itip-decline-comment').focus();
+        }
+    });
+
+    return false;
+};
+
+/**
+ *
+ */
+rcube_libcalendaring.fetch_itip_object_status = function(p)
+{
+  rcmail.http_post(p.task + '/itip-status', { data: p });
+};
+
+/**
+ *
+ */
+rcube_libcalendaring.update_itip_object_status = function(p)
+{
+  rcmail.env.rsvp_saved = p.saved;
+  rcmail.env.itip_existing = p.existing;
+
+  // hide all elements first
+  $('#itip-buttons-'+p.id+' > div').hide();
+  $('#rsvp-'+p.id+' .folder-select').remove();
+
+  if (p.html) {
+    // append/replace rsvp status display
+    $('#loading-'+p.id).next('.rsvp-status').remove();
+    $('#loading-'+p.id).hide().after(p.html);
+  }
+
+  // enable/disable rsvp buttons
+  if (p.action == 'rsvp') {
+    $('#rsvp-'+p.id+' input.button').prop('disabled', false)
+      .filter('.'+String(p.status||'unknown').toLowerCase()).prop('disabled', p.latest);
+  }
+
+  // show rsvp/import buttons (with calendar selector)
+  $('#'+p.action+'-'+p.id).show().find('input.button').last().after(p.select);
+
+  // highlight date if date change detected
+  if (p.resheduled)
+    $('.calendar-eventdetails td.date').addClass('modified');
+
+  // show itip box appendix after replacing the given placeholders
+  if (p.append && p.append.selector) {
+    var elem = $(p.append.selector);
+    if (p.append.replacements) {
+      $.each(p.append.replacements, function(k, html) {
+        elem.html(elem.html().replace(k, html));
+      });
+    }
+    else if (p.append.html) {
+      elem.html(p.append.html)
+    }
+    elem.show();
+  }
+};
+
+/**
+ * Callback from server after an iTip message has been processed
+ */
+rcube_libcalendaring.itip_message_processed = function(metadata)
+{
+  if (metadata.after_action) {
+    setTimeout(function(){ rcube_libcalendaring.itip_after_action(metadata.after_action); }, 1200);
+  }
+  else {
+    rcube_libcalendaring.fetch_itip_object_status(metadata);
+  }
+};
+
+/**
+ * After-action on iTip request message. Action types:
+ *     0 - no action
+ *     1 - move to Trash
+ *     2 - delete the message
+ *     3 - flag as deleted
+ *     folder_name - move the message to the specified folder
+ */
+rcube_libcalendaring.itip_after_action = function(action)
+{
+  if (!action) {
+    return;
+  }
+
+  var rc = rcmail.is_framed() ? parent.rcmail : rcmail;
+
+  if (action === 2) {
+    rc.permanently_remove_messages();
+  }
+  else if (action === 3) {
+    rc.mark_message('delete');
+  }
+  else {
+    rc.move_messages(action === 1 ? rc.env.trash_mailbox : action);
+  }
+};
+
+/**
+ * Open the calendar preview for the current iTip event
+ */
+rcube_libcalendaring.open_itip_preview = function(url, msgref)
+{
+  if (!rcmail.env.itip_existing)
+    url += '&itip=' + escape(msgref);
+
+  var win = rcmail.open_window(url);
+};
+
+
+// extend jQuery
+(function($){
+  $.fn.serializeJSON = function(){
+    var json = {};
+    jQuery.map($(this).serializeArray(), function(n, i) {
+      json[n['name']] = n['value'];
+    });
+    return json;
+  };
+})(jQuery);
+
+
+/* libcalendaring plugin initialization */
+window.rcmail && rcmail.addEventListener('init', function(evt) {
+  if (rcmail.env.libcal_settings) {
+    var libcal = new rcube_libcalendaring(rcmail.env.libcal_settings);
+    rcmail.addEventListener('plugin.display_alarms', function(alarms){ libcal.display_alarms(alarms); });
+  }
+
+  rcmail.addEventListener('plugin.update_itip_object_status', rcube_libcalendaring.update_itip_object_status)
+    .addEventListener('plugin.fetch_itip_object_status', rcube_libcalendaring.fetch_itip_object_status)
+    .addEventListener('plugin.itip_message_processed', rcube_libcalendaring.itip_message_processed);
+
+  if (rcmail.env.action == 'get-attachment' && rcmail.gui_objects['attachmentframe']) {
+    rcmail.register_command('print-attachment', function() {
+      var frame = rcmail.get_frame_window(rcmail.gui_objects['attachmentframe'].id);
+      if (frame) frame.print();
+    }, true);
+  }
+
+  if (rcmail.env.action == 'get-attachment' && rcmail.env.attachment_download_url) {
+    rcmail.register_command('download-attachment', function() {
+      rcmail.location_href(rcmail.env.attachment_download_url, window);
+    }, true);
+  }
+});