diff plugins/libcalendaring/libcalendaring.php @ 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.php	Sat Jan 13 08:57:56 2018 -0500
@@ -0,0 +1,1762 @@
+<?php
+
+/**
+ * Library providing common functions for calendaring plugins
+ *
+ * Provides utility functions for calendar-related modules such as
+ * - alarms display and dismissal
+ * - attachment handling
+ * - recurrence computation and UI elements
+ * - ical parsing and exporting
+ * - itip scheduling protocol
+ *
+ * @version @package_version@
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * 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/>.
+ */
+
+class libcalendaring extends rcube_plugin
+{
+    public $rc;
+    public $timezone;
+    public $gmt_offset;
+    public $dst_active;
+    public $timezone_offset;
+    public $ical_parts = array();
+    public $ical_message;
+
+    public $defaults = array(
+      'calendar_date_format'  => "yyyy-MM-dd",
+      'calendar_date_short'   => "M-d",
+      'calendar_date_long'    => "MMM d yyyy",
+      'calendar_date_agenda'  => "ddd MM-dd",
+      'calendar_time_format'  => "HH:mm",
+      'calendar_first_day'    => 1,
+      'calendar_first_hour'   => 6,
+      'calendar_date_format_sets' => array(
+        'yyyy-MM-dd' => array('MMM d yyyy',   'M-d',  'ddd MM-dd'),
+        'dd-MM-yyyy' => array('d MMM yyyy',   'd-M',  'ddd dd-MM'),
+        'yyyy/MM/dd' => array('MMM d yyyy',   'M/d',  'ddd MM/dd'),
+        'MM/dd/yyyy' => array('MMM d yyyy',   'M/d',  'ddd MM/dd'),
+        'dd/MM/yyyy' => array('d MMM yyyy',   'd/M',  'ddd dd/MM'),
+        'dd.MM.yyyy' => array('dd. MMM yyyy', 'd.M',  'ddd dd.MM.'),
+        'd.M.yyyy'   => array('d. MMM yyyy',  'd.M',  'ddd d.MM.'),
+      ),
+    );
+
+    private static $instance;
+
+    private $mail_ical_parser;
+
+    /**
+     * Singleton getter to allow direct access from other plugins
+     */
+    public static function get_instance()
+    {
+        if (!self::$instance) {
+            self::$instance = new libcalendaring(rcube::get_instance()->plugins);
+            self::$instance->init_instance();
+        }
+
+        return self::$instance;
+    }
+
+    /**
+     * Initializes class properties
+     */
+    public function init_instance()
+    {
+        $this->rc = rcube::get_instance();
+
+        // set user's timezone
+        try {
+            $this->timezone = new DateTimeZone($this->rc->config->get('timezone', 'GMT'));
+        }
+        catch (Exception $e) {
+            $this->timezone = new DateTimeZone('GMT');
+        }
+
+        $now = new DateTime('now', $this->timezone);
+
+        $this->gmt_offset      = $now->getOffset();
+        $this->dst_active      = $now->format('I');
+        $this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active;
+
+        $this->add_texts('localization/', false);
+    }
+
+    /**
+     * Required plugin startup method
+     */
+    public function init()
+    {
+        self::$instance = $this;
+
+        $this->rc = rcube::get_instance();
+        $this->init_instance();
+
+        // include client scripts and styles
+        if ($this->rc->output) {
+            // add hook to display alarms
+            $this->add_hook('refresh', array($this, 'refresh'));
+            $this->register_action('plugin.alarms', array($this, 'alarms_action'));
+            $this->register_action('plugin.expand_attendee_group', array($this, 'expand_attendee_group'));
+        }
+
+        // proceed initialization in startup hook
+        $this->add_hook('startup', array($this, 'startup'));
+    }
+
+    /**
+     * Startup hook
+     */
+    public function startup($args)
+    {
+        if ($this->rc->output && $this->rc->output->type == 'html') {
+            $this->rc->output->set_env('libcal_settings', $this->load_settings());
+            $this->include_script('libcalendaring.js');
+            $this->include_stylesheet($this->local_skin_path() . '/libcal.css');
+
+            $this->add_label(
+                'itipaccepted', 'itiptentative', 'itipdeclined',
+                'itipdelegated', 'expandattendeegroup', 'expandattendeegroupnodata',
+                'statusorganizer', 'statusaccepted', 'statusdeclined',
+                'statusdelegated', 'statusunknown', 'statusneeds-action',
+                'statustentative', 'statuscompleted', 'statusin-process',
+                'delegatedto', 'delegatedfrom'
+            );
+        }
+
+        if ($args['task'] == 'mail') {
+            if ($args['action'] == 'show' || $args['action'] == 'preview') {
+                $this->add_hook('message_load', array($this, 'mail_message_load'));
+            }
+        }
+    }
+
+    /**
+     * Load iCalendar functions
+     */
+    public static function get_ical()
+    {
+        $self = self::get_instance();
+        require_once __DIR__ . '/libvcalendar.php';
+        return new libvcalendar();
+    }
+
+    /**
+     * Load iTip functions
+     */
+    public static function get_itip($domain = 'libcalendaring')
+    {
+        $self = self::get_instance();
+        require_once __DIR__ . '/lib/libcalendaring_itip.php';
+        return new libcalendaring_itip($self, $domain);
+    }
+
+    /**
+     * Load recurrence computation engine
+     */
+    public static function get_recurrence()
+    {
+        $self = self::get_instance();
+        require_once __DIR__ . '/lib/libcalendaring_recurrence.php';
+        return new libcalendaring_recurrence($self);
+    }
+
+    /**
+     * Shift dates into user's current timezone
+     *
+     * @param mixed Any kind of a date representation (DateTime object, string or unix timestamp)
+     * @return object DateTime object in user's timezone
+     */
+    public function adjust_timezone($dt, $dateonly = false)
+    {
+        if (is_numeric($dt))
+            $dt = new DateTime('@'.$dt);
+        else if (is_string($dt))
+            $dt = rcube_utils::anytodatetime($dt);
+
+        if ($dt instanceof DateTime && !($dt->_dateonly || $dateonly)) {
+            $dt->setTimezone($this->timezone);
+        }
+
+        return $dt;
+    }
+
+
+    /**
+     *
+     */
+    public function load_settings()
+    {
+        $this->date_format_defaults();
+
+        $settings = array();
+        $keys     = array('date_format', 'time_format', 'date_short', 'date_long');
+
+        foreach ($keys as $key) {
+            $settings[$key] = (string)$this->rc->config->get('calendar_' . $key, $this->defaults['calendar_' . $key]);
+            $settings[$key] = str_replace('Y', 'y', $settings[$key]);
+        }
+
+        $settings['dates_long']  = str_replace(' yyyy', '[ yyyy]', $settings['date_long']) . "{ '&mdash;' " . $settings['date_long'] . '}';
+        $settings['first_day']   = (int)$this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']);
+        $settings['timezone']    = $this->timezone_offset;
+        $settings['dst']         = $this->dst_active;
+
+        // localization
+        $settings['days'] = array(
+            $this->rc->gettext('sunday'),   $this->rc->gettext('monday'),
+            $this->rc->gettext('tuesday'),  $this->rc->gettext('wednesday'),
+            $this->rc->gettext('thursday'), $this->rc->gettext('friday'),
+            $this->rc->gettext('saturday')
+        );
+        $settings['days_short'] = array(
+            $this->rc->gettext('sun'), $this->rc->gettext('mon'),
+            $this->rc->gettext('tue'), $this->rc->gettext('wed'),
+            $this->rc->gettext('thu'), $this->rc->gettext('fri'),
+            $this->rc->gettext('sat')
+        );
+        $settings['months'] = array(
+            $this->rc->gettext('longjan'), $this->rc->gettext('longfeb'),
+            $this->rc->gettext('longmar'), $this->rc->gettext('longapr'),
+            $this->rc->gettext('longmay'), $this->rc->gettext('longjun'),
+            $this->rc->gettext('longjul'), $this->rc->gettext('longaug'),
+            $this->rc->gettext('longsep'), $this->rc->gettext('longoct'),
+            $this->rc->gettext('longnov'), $this->rc->gettext('longdec')
+        );
+        $settings['months_short'] = array(
+            $this->rc->gettext('jan'), $this->rc->gettext('feb'),
+            $this->rc->gettext('mar'), $this->rc->gettext('apr'),
+            $this->rc->gettext('may'), $this->rc->gettext('jun'),
+            $this->rc->gettext('jul'), $this->rc->gettext('aug'),
+            $this->rc->gettext('sep'), $this->rc->gettext('oct'),
+            $this->rc->gettext('nov'), $this->rc->gettext('dec')
+        );
+        $settings['today'] = $this->rc->gettext('today');
+
+        // define list of file types which can be displayed inline
+        // same as in program/steps/mail/show.inc
+        $settings['mimetypes'] = (array)$this->rc->config->get('client_mimetypes');
+
+        return $settings;
+    }
+
+
+    /**
+     * Helper function to set date/time format according to config and user preferences
+     */
+    private function date_format_defaults()
+    {
+        static $defaults = array();
+
+        // nothing to be done
+        if (isset($defaults['date_format']))
+          return;
+
+        $defaults['date_format'] = $this->rc->config->get('calendar_date_format', self::from_php_date_format($this->rc->config->get('date_format')));
+        $defaults['time_format'] = $this->rc->config->get('calendar_time_format', self::from_php_date_format($this->rc->config->get('time_format')));
+
+        // override defaults
+        if ($defaults['date_format'])
+            $this->defaults['calendar_date_format'] = $defaults['date_format'];
+        if ($defaults['time_format'])
+            $this->defaults['calendar_time_format'] = $defaults['time_format'];
+
+        // derive format variants from basic date format
+        $format_sets = $this->rc->config->get('calendar_date_format_sets', $this->defaults['calendar_date_format_sets']);
+        if ($format_set = $format_sets[$this->defaults['calendar_date_format']]) {
+            $this->defaults['calendar_date_long'] = $format_set[0];
+            $this->defaults['calendar_date_short'] = $format_set[1];
+            $this->defaults['calendar_date_agenda'] = $format_set[2];
+        }
+    }
+
+    /**
+     * Compose a date string for the given event
+     */
+    public function event_date_text($event, $tzinfo = false)
+    {
+        $fromto = '--';
+
+        // handle task objects
+        if ($event['_type'] == 'task' && is_object($event['due'])) {
+            $date_format = $event['due']->_dateonly ? self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])) : null;
+            $fromto = $this->rc->format_date($event['due'], $date_format, false);
+
+            // add timezone information
+            if ($fromto && $tzinfo && ($tzname = $this->timezone->getName())) {
+                $fromto .= ' (' . strtr($tzname, '_', ' ') . ')';
+            }
+
+            return $fromto;
+        }
+
+        // abort if no valid event dates are given
+        if (!is_object($event['start']) || !is_a($event['start'], 'DateTime') || !is_object($event['end']) || !is_a($event['end'], 'DateTime')) {
+            return $fromto;
+        }
+
+        $duration = $event['start']->diff($event['end'])->format('s');
+
+        $this->date_format_defaults();
+        $date_format = self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']));
+        $time_format = self::to_php_date_format($this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format']));
+
+        if ($event['allday']) {
+            $fromto = $this->rc->format_date($event['start'], $date_format);
+            if (($todate = $this->rc->format_date($event['end'], $date_format)) != $fromto)
+                $fromto .= ' - ' . $todate;
+        }
+        else if ($duration < 86400 && $event['start']->format('d') == $event['end']->format('d')) {
+            $fromto = $this->rc->format_date($event['start'], $date_format) . ' ' . $this->rc->format_date($event['start'], $time_format) .
+                ' - ' . $this->rc->format_date($event['end'], $time_format);
+        }
+        else {
+            $fromto = $this->rc->format_date($event['start'], $date_format) . ' ' . $this->rc->format_date($event['start'], $time_format) .
+                ' - ' . $this->rc->format_date($event['end'], $date_format) . ' ' . $this->rc->format_date($event['end'], $time_format);
+        }
+
+        // add timezone information
+        if ($tzinfo && ($tzname = $this->timezone->getName())) {
+            $fromto .= ' (' . strtr($tzname, '_', ' ') . ')';
+        }
+
+        return $fromto;
+    }
+
+
+    /**
+     * Render HTML form for alarm configuration
+     */
+    public function alarm_select($attrib, $alarm_types, $absolute_time = true)
+    {
+        unset($attrib['name']);
+
+        $input_value = new html_inputfield(array('name' => 'alarmvalue[]', 'class' => 'edit-alarm-value', 'size' => 3));
+        $input_date  = new html_inputfield(array('name' => 'alarmdate[]', 'class' => 'edit-alarm-date', 'size' => 10));
+        $input_time  = new html_inputfield(array('name' => 'alarmtime[]', 'class' => 'edit-alarm-time', 'size' => 6));
+        $select_type    = new html_select(array('name' => 'alarmtype[]', 'class' => 'edit-alarm-type', 'id' => $attrib['id']));
+        $select_offset  = new html_select(array('name' => 'alarmoffset[]', 'class' => 'edit-alarm-offset'));
+        $select_related = new html_select(array('name' => 'alarmrelated[]', 'class' => 'edit-alarm-related'));
+        $object_type    = $attrib['_type'] ?: 'event';
+
+        $select_type->add($this->gettext('none'), '');
+        foreach ($alarm_types as $type)
+            $select_type->add($this->gettext(strtolower("alarm{$type}option")), $type);
+
+        foreach (array('-M','-H','-D','+M','+H','+D') as $trigger)
+            $select_offset->add($this->gettext('trigger' . $trigger), $trigger);
+
+        $select_offset->add($this->gettext('trigger0'), '0');
+        if ($absolute_time)
+            $select_offset->add($this->gettext('trigger@'), '@');
+
+        $select_related->add($this->gettext('relatedstart'), 'start');
+        $select_related->add($this->gettext('relatedend' . $object_type), 'end');
+
+        // pre-set with default values from user settings
+        $preset = self::parse_alarm_value($this->rc->config->get('calendar_default_alarm_offset', '-15M'));
+        $hidden = array('style' => 'display:none');
+        $html = html::span('edit-alarm-set',
+            $select_type->show($this->rc->config->get('calendar_default_alarm_type', '')) . ' ' .
+            html::span(array('class' => 'edit-alarm-values', 'style' => 'display:none'),
+                $input_value->show($preset[0]) . ' ' .
+                $select_offset->show($preset[1]) . ' ' .
+                $select_related->show() . ' ' .
+                $input_date->show('', $hidden) . ' ' .
+                $input_time->show('', $hidden)
+            )
+        );
+
+        // TODO: support adding more alarms
+        #$html .= html::a(array('href' => '#', 'id' => 'edit-alam-add', 'title' => $this->gettext('addalarm')),
+        #  $attrib['addicon'] ? html::img(array('src' => $attrib['addicon'], 'alt' => 'add')) : '(+)');
+
+        return $html;
+    }
+
+    /**
+     * Get a list of email addresses of the given user (from login and identities)
+     *
+     * @param string User Email (default to current user)
+     * @return array Email addresses related to the user
+     */
+    public function get_user_emails($user = null)
+    {
+        static $_emails = array();
+
+        if (empty($user)) {
+            $user = $this->rc->user->get_username();
+        }
+
+        // return cached result
+        if (is_array($_emails[$user])) {
+            return $_emails[$user];
+        }
+
+        $emails = array($user);
+        $plugin = $this->rc->plugins->exec_hook('calendar_user_emails', array('emails' => $emails));
+        $emails = array_map('strtolower', $plugin['emails']);
+
+        // add all emails from the current user's identities
+        if (!$plugin['abort'] && ($user == $this->rc->user->get_username())) {
+            foreach ($this->rc->user->list_emails() as $identity) {
+                $emails[] = strtolower($identity['email']);
+            }
+        }
+
+        $_emails[$user] = array_unique($emails);
+        return $_emails[$user];
+    }
+
+    /**
+     * Set the given participant status to the attendee matching the current user's identities
+     *
+     * @param array   Hash array with event struct
+     * @param string  The PARTSTAT value to set
+     * @return mixed  Email address of the updated attendee or False if none matching found
+     */
+    public function set_partstat(&$event, $status, $recursive = true)
+    {
+        $success = false;
+        $emails = $this->get_user_emails();
+        foreach ((array)$event['attendees'] as $i => $attendee) {
+            if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) {
+                $event['attendees'][$i]['status'] = strtoupper($status);
+                $success = $attendee['email'];
+            }
+        }
+
+        // apply partstat update to each existing exception
+        if ($event['recurrence'] && is_array($event['recurrence']['EXCEPTIONS'])) {
+            foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) {
+                $this->set_partstat($event['recurrence']['EXCEPTIONS'][$i], $status, false);
+            }
+
+            // set link to top-level exceptions
+            $event['exceptions'] = &$event['recurrence']['EXCEPTIONS'];
+        }
+
+        return $success;
+    }
+
+
+    /*********  Alarms handling  *********/
+
+    /**
+     * Helper function to convert alarm trigger strings
+     * into two-field values (e.g. "-45M" => 45, "-M")
+     */
+    public static function parse_alarm_value($val)
+    {
+        if ($val[0] == '@') {
+            return array(new DateTime($val));
+        }
+        else if (preg_match('/([+-]?)P?(T?\d+[HMSDW])+/', $val, $m) && preg_match_all('/T?(\d+)([HMSDW])/', $val, $m2, PREG_SET_ORDER)) {
+            if ($m[1] == '')
+                $m[1] = '+';
+            foreach ($m2 as $seg) {
+                $prefix = $seg[2] == 'D' || $seg[2] == 'W' ? 'P' : 'PT';
+                if ($seg[1] > 0) {  // ignore zero values
+                    // convert seconds to minutes
+                    if ($seg[2] == 'S') {
+                        $seg[2] = 'M';
+                        $seg[1] = max(1, round($seg[1]/60));
+                    }
+
+                    return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]);
+                }
+            }
+
+            // return zero value nevertheless
+            return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]);
+        }
+
+        return false;
+    }
+
+    /**
+     * Convert the alarms list items to be processed on the client
+     */
+    public static function to_client_alarms($valarms)
+    {
+        return array_map(function($alarm){
+            if ($alarm['trigger'] instanceof DateTime) {
+                $alarm['trigger'] = '@' . $alarm['trigger']->format('U');
+            }
+            else if ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) {
+                $alarm['trigger'] = $trigger[2];
+            }
+            return $alarm;
+        }, (array)$valarms);
+    }
+
+    /**
+     * Process the alarms values submitted by the client
+     */
+    public static function from_client_alarms($valarms)
+    {
+        return array_map(function($alarm){
+            if ($alarm['trigger'][0] == '@') {
+                try {
+                    $alarm['trigger'] = new DateTime($alarm['trigger']);
+                    $alarm['trigger']->setTimezone(new DateTimeZone('UTC'));
+                }
+                catch (Exception $e) { /* handle this ? */ }
+            }
+            else if ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) {
+                $alarm['trigger'] = $trigger[3];
+            }
+            return $alarm;
+        }, (array)$valarms);
+    }
+
+    /**
+     * Render localized text for alarm settings
+     */
+    public static function alarms_text($alarms)
+    {
+        if (is_array($alarms) && is_array($alarms[0])) {
+            $texts = array();
+            foreach ($alarms as $alarm) {
+                if ($text = self::alarm_text($alarm))
+                    $texts[] = $text;
+            }
+
+            return join(', ', $texts);
+        }
+        else {
+            return self::alarm_text($alarms);
+        }
+    }
+
+    /**
+     * Render localized text for a single alarm property
+     */
+    public static function alarm_text($alarm)
+    {
+        if (is_string($alarm)) {
+            list($trigger, $action) = explode(':', $alarm);
+        }
+        else {
+            $trigger = $alarm['trigger'];
+            $action  = $alarm['action'];
+            $related = $alarm['related'];
+        }
+
+        $text  = '';
+        $rcube = rcube::get_instance();
+
+        switch ($action) {
+        case 'EMAIL':
+            $text = $rcube->gettext('libcalendaring.alarmemail');
+            break;
+        case 'DISPLAY':
+            $text = $rcube->gettext('libcalendaring.alarmdisplay');
+            break;
+        case 'AUDIO':
+            $text = $rcube->gettext('libcalendaring.alarmaudio');
+            break;
+        }
+
+        if ($trigger instanceof DateTime) {
+            $text .= ' ' . $rcube->gettext(array(
+                'name' => 'libcalendaring.alarmat',
+                'vars' => array('datetime' => $rcube->format_date($trigger))
+            ));
+        }
+        else if (preg_match('/@(\d+)/', $trigger, $m)) {
+            $text .= ' ' . $rcube->gettext(array(
+                'name' => 'libcalendaring.alarmat',
+                'vars' => array('datetime' => $rcube->format_date($m[1]))
+            ));
+        }
+        else if ($val = self::parse_alarm_value($trigger)) {
+            $r = strtoupper($related ?: 'start') == 'END' ? 'end' : '';
+            // TODO: for all-day events say 'on date of event at XX' ?
+            if ($val[0] == 0) {
+                $text .= ' ' . $rcube->gettext('libcalendaring.triggerattime' . $r);
+            }
+            else {
+                $label = 'libcalendaring.trigger' . $r . $val[1];
+                $text .= ' ' . intval($val[0]) . ' ' . $rcube->gettext($label);
+            }
+        }
+        else {
+            return false;
+        }
+
+        return $text;
+    }
+
+    /**
+     * Get the next alarm (time & action) for the given event
+     *
+     * @param array Record data
+     * @return array Hash array with alarm time/type or null if no alarms are configured
+     */
+    public static function get_next_alarm($rec, $type = 'event')
+    {
+        if (!($rec['valarms'] || $rec['alarms']) || $rec['cancelled'] || $rec['status'] == 'CANCELLED')
+            return null;
+
+        if ($type == 'task') {
+            $timezone = self::get_instance()->timezone;
+            if ($rec['startdate'])
+                $rec['start'] = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?: '12:00'), $timezone);
+            if ($rec['date'])
+                $rec[($rec['start'] ? 'end' : 'start')] = new DateTime($rec['date'] . ' ' . ($rec['time'] ?: '12:00'), $timezone);
+        }
+
+        if (!$rec['end'])
+            $rec['end'] = $rec['start'];
+
+        // support legacy format
+        if (!$rec['valarms']) {
+            list($trigger, $action) = explode(':', $rec['alarms'], 2);
+            if ($alarm = self::parse_alarm_value($trigger)) {
+                $rec['valarms'] = array(array('action' => $action, 'trigger' => $alarm[3] ?: $alarm[0]));
+            }
+        }
+
+        $expires  = new DateTime('now - 12 hours');
+        $alarm_id = $rec['id'];  // alarm ID eq. record ID by default to keep backwards compatibility
+
+        // handle multiple alarms
+        $notify_at = null;
+        foreach ($rec['valarms'] as $alarm) {
+            $notify_time = null;
+
+            if ($alarm['trigger'] instanceof DateTime) {
+                $notify_time = $alarm['trigger'];
+            }
+            else if (is_string($alarm['trigger'])) {
+                $refdate = $alarm['related'] == 'END' ? $rec['end'] : $rec['start'];
+
+                // abort if no reference date is available to compute notification time
+                if (!is_a($refdate, 'DateTime'))
+                    continue;
+
+                // TODO: for all-day events, take start @ 00:00 as reference date ?
+
+                try {
+                    $interval = new DateInterval(trim($alarm['trigger'], '+-'));
+                    $interval->invert = $alarm['trigger'][0] == '-';
+                    $notify_time = clone $refdate;
+                    $notify_time->add($interval);
+                }
+                catch (Exception $e) {
+                    rcube::raise_error($e, true);
+                    continue;
+                }
+            }
+
+            if ($notify_time && (!$notify_at || ($notify_time > $notify_at && $notify_time > $expires))) {
+                $notify_at  = $notify_time;
+                $action     = $alarm['action'];
+                $alarm_prop = $alarm;
+
+                // generate a unique alarm ID if multiple alarms are set
+                if (count($rec['valarms']) > 1) {
+                    $alarm_id = substr(md5($rec['id']), 0, 16) . '-' . $notify_at->format('Ymd\THis');
+                }
+            }
+        }
+
+        return !$notify_at ? null : array(
+            'time'   => $notify_at->format('U'),
+            'action' => $action ? strtoupper($action) : 'DISPLAY',
+            'id'     => $alarm_id,
+            'prop'   => $alarm_prop,
+        );
+    }
+
+    /**
+     * Handler for keep-alive requests
+     * This will check for pending notifications and pass them to the client
+     */
+    public function refresh($attr)
+    {
+        // collect pending alarms from all providers (e.g. calendar, tasks)
+        $plugin = $this->rc->plugins->exec_hook('pending_alarms', array(
+            'time' => time(),
+            'alarms' => array(),
+        ));
+
+        if (!$plugin['abort'] && !empty($plugin['alarms'])) {
+            // make sure texts and env vars are available on client
+            $this->add_texts('localization/', true);
+            $this->rc->output->add_label('close');
+            $this->rc->output->set_env('snooze_select', $this->snooze_select());
+            $this->rc->output->command('plugin.display_alarms', $this->_alarms_output($plugin['alarms']));
+        }
+    }
+
+    /**
+     * Handler for alarm dismiss/snooze requests
+     */
+    public function alarms_action()
+    {
+//        $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC);
+        $data  = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true);
+
+        $data['ids'] = explode(',', $data['id']);
+        $plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $data);
+
+        if ($plugin['success'])
+            $this->rc->output->show_message('successfullysaved', 'confirmation');
+        else
+            $this->rc->output->show_message('calendar.errorsaving', 'error');
+    }
+
+    /**
+     * Generate reduced and streamlined output for pending alarms
+     */
+    private function _alarms_output($alarms)
+    {
+        $out = array();
+        foreach ($alarms as $alarm) {
+            $out[] = array(
+                'id'       => $alarm['id'],
+                'start'    => $alarm['start'] ? $this->adjust_timezone($alarm['start'])->format('c') : '',
+                'end'      => $alarm['end']   ? $this->adjust_timezone($alarm['end'])->format('c') : '',
+                'allDay'   => $alarm['allday'] == 1,
+                'action'   => $alarm['action'],
+                'title'    => $alarm['title'],
+                'location' => $alarm['location'],
+                'calendar' => $alarm['calendar'],
+            );
+        }
+
+        return $out;
+    }
+
+    /**
+     * Render a dropdown menu to choose snooze time
+     */
+    private function snooze_select($attrib = array())
+    {
+        $steps = array(
+             5 => 'repeatinmin',
+            10 => 'repeatinmin',
+            15 => 'repeatinmin',
+            20 => 'repeatinmin',
+            30 => 'repeatinmin',
+            60 => 'repeatinhr',
+            120 => 'repeatinhrs',
+            1440 => 'repeattomorrow',
+            10080 => 'repeatinweek',
+        );
+
+        $items = array();
+        foreach ($steps as $n => $label) {
+            $items[] = html::tag('li', null, html::a(array('href' => "#" . ($n * 60), 'class' => 'active'),
+                $this->gettext(array('name' => $label, 'vars' => array('min' => $n % 60, 'hrs' => intval($n / 60))))));
+        }
+
+        return html::tag('ul', $attrib + array('class' => 'toolbarmenu'), join("\n", $items), html::$common_attrib);
+    }
+
+
+    /*********  Recurrence rules handling ********/
+
+    /**
+     * Render localized text describing the recurrence rule of an event
+     */
+    public function recurrence_text($rrule)
+    {
+        $limit     = 10;
+        $exdates   = array();
+        $format    = $this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']);
+        $format    = self::to_php_date_format($format);
+        $format_fn = function($dt) use ($format) {
+            return rcmail::get_instance()->format_date($dt, $format);
+        };
+
+        if (is_array($rrule['EXDATE']) && !empty($rrule['EXDATE'])) {
+            $exdates = array_map($format_fn, $rrule['EXDATE']);
+        }
+
+        if (empty($rrule['FREQ']) && !empty($rrule['RDATE'])) {
+            $rdates = array_map($format_fn, $rrule['RDATE']);
+
+            if (!empty($exdates)) {
+                $rdates = array_diff($rdates, $exdates);
+            }
+
+            if (count($rdates) > $limit) {
+                $rdates = array_slice($rdates, 0, $limit);
+                $more   = true;
+            }
+
+            return $this->gettext('ondate') . ' ' . join(', ', $rdates)
+                . ($more ? '...' : '');
+        }
+
+        $output  = sprintf('%s %d ', $this->gettext('every'), $rrule['INTERVAL'] ?: 1);
+
+        switch ($rrule['FREQ']) {
+        case 'DAILY':
+            $output .= $this->gettext('days');
+            break;
+        case 'WEEKLY':
+            $output .= $this->gettext('weeks');
+            break;
+        case 'MONTHLY':
+            $output .= $this->gettext('months');
+            break;
+        case 'YEARLY':
+            $output .= $this->gettext('years');
+            break;
+        }
+
+        if ($rrule['COUNT']) {
+            $until = $this->gettext(array('name' => 'forntimes', 'vars' => array('nr' => $rrule['COUNT'])));
+        }
+        else if ($rrule['UNTIL']) {
+            $until = $this->gettext('recurrencend') . ' ' . $this->rc->format_date($rrule['UNTIL'], $format);
+        }
+        else {
+            $until = $this->gettext('forever');
+        }
+
+        $output .= ', ' . $until;
+
+        if (!empty($exdates)) {
+            if (count($exdates) > $limit) {
+                $exdates = array_slice($exdates, 0, $limit);
+                $more    = true;
+            }
+
+            $output  = '; ' . $this->gettext('except') . ' ' . join(', ', $exdates)
+                . ($more ? '...' : '');
+        }
+
+        return $output;
+    }
+
+    /**
+     * Generate the form for recurrence settings
+     */
+    public function recurrence_form($attrib = array())
+    {
+        switch ($attrib['part']) {
+            // frequency selector
+            case 'frequency':
+                $select = new html_select(array('name' => 'frequency', 'id' => 'edit-recurrence-frequency'));
+                $select->add($this->gettext('never'),   '');
+                $select->add($this->gettext('daily'),   'DAILY');
+                $select->add($this->gettext('weekly'),  'WEEKLY');
+                $select->add($this->gettext('monthly'), 'MONTHLY');
+                $select->add($this->gettext('yearly'),  'YEARLY');
+                $select->add($this->gettext('rdate'),   'RDATE');
+                $html = html::label('edit-recurrence-frequency', $this->gettext('frequency')) . $select->show('');
+                break;
+
+            // daily recurrence
+            case 'daily':
+                $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-daily'));
+                $html = html::div($attrib, html::label('edit-recurrence-interval-daily', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('days')));
+                break;
+
+            // weekly recurrence form
+            case 'weekly':
+                $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-weekly'));
+                $html = html::div($attrib, html::label('edit-recurrence-interval-weekly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('weeks')));
+                // weekday selection
+                $daymap = array('sun','mon','tue','wed','thu','fri','sat');
+                $checkbox = new html_checkbox(array('name' => 'byday', 'class' => 'edit-recurrence-weekly-byday'));
+                $first = $this->rc->config->get('calendar_first_day', 1);
+                for ($weekdays = '', $j = $first; $j <= $first+6; $j++) {
+                    $d = $j % 7;
+                    $weekdays .= html::label(array('class' => 'weekday'),
+                        $checkbox->show('', array('value' => strtoupper(substr($daymap[$d], 0, 2)))) .
+                        $this->gettext($daymap[$d])
+                    ) . ' ';
+                }
+                $html .= html::div($attrib, html::label(null, $this->gettext('bydays')) . $weekdays);
+                break;
+
+            // monthly recurrence form
+            case 'monthly':
+                $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-monthly'));
+                $html = html::div($attrib, html::label('edit-recurrence-interval-monthly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('months')));
+
+                $checkbox = new html_checkbox(array('name' => 'bymonthday', 'class' => 'edit-recurrence-monthly-bymonthday'));
+                for ($monthdays = '', $d = 1; $d <= 31; $d++) {
+                    $monthdays .= html::label(array('class' => 'monthday'), $checkbox->show('', array('value' => $d)) . $d);
+                    $monthdays .= $d % 7 ? ' ' : html::br();
+                }
+
+                // rule selectors
+                $radio = new html_radiobutton(array('name' => 'repeatmode', 'class' => 'edit-recurrence-monthly-mode'));
+                $table = new html_table(array('cols' => 2, 'border' => 0, 'cellpadding' => 0, 'class' => 'formtable'));
+                $table->add('label', html::label(null, $radio->show('BYMONTHDAY', array('value' => 'BYMONTHDAY')) . ' ' . $this->gettext('each')));
+                $table->add(null, $monthdays);
+                $table->add('label', html::label(null, $radio->show('', array('value' => 'BYDAY')) . ' ' . $this->gettext('onevery')));
+                $table->add(null, $this->rrule_selectors($attrib['part']));
+
+                $html .= html::div($attrib, $table->show());
+                break;
+
+            // annually recurrence form
+            case 'yearly':
+                $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-yearly'));
+                $html = html::div($attrib, html::label('edit-recurrence-interval-yearly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('years')));
+                // month selector
+                $monthmap = array('','jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec');
+                $checkbox = new html_checkbox(array('name' => 'bymonth', 'class' => 'edit-recurrence-yearly-bymonth'));
+                for ($months = '', $m = 1; $m <= 12; $m++) {
+                    $months .= html::label(array('class' => 'month'), $checkbox->show(null, array('value' => $m)) . $this->gettext($monthmap[$m]));
+                    $months .= $m % 4 ? ' ' : html::br();
+                }
+                $html .= html::div($attrib + array('id' => 'edit-recurrence-yearly-bymonthblock'), $months);
+
+                // day rule selection
+                $html .= html::div($attrib, html::label(null, $this->gettext('onevery')) . $this->rrule_selectors($attrib['part'], '---'));
+                break;
+
+            // end of recurrence form
+            case 'until':
+                $radio = new html_radiobutton(array('name' => 'repeat', 'class' => 'edit-recurrence-until'));
+                $select = $this->interval_selector(array('name' => 'times', 'id' => 'edit-recurrence-repeat-times'));
+                $input = new html_inputfield(array('name' => 'untildate', 'id' => 'edit-recurrence-enddate', 'size' => "10"));
+
+                $html = html::div('line first',
+                    html::label(null, $radio->show('', array('value' => '', 'id' => 'edit-recurrence-repeat-forever')) . ' ' .
+                        $this->gettext('forever'))
+                );
+
+                $forntimes = $this->gettext(array(
+                    'name' => 'forntimes',
+                    'vars' => array('nr' => '%s'))
+                );
+                $html .= html::div('line',
+                    $radio->show('', array('value' => 'count', 'id' => 'edit-recurrence-repeat-count', 'aria-label' => sprintf($forntimes, 'N'))) . ' ' .
+                        sprintf($forntimes, $select->show(1))
+                );
+
+                $html .= html::div('line',
+                    $radio->show('', array('value' => 'until', 'id' => 'edit-recurrence-repeat-until', 'aria-label' => $this->gettext('untilenddate'))) . ' ' .
+                        $this->gettext('untildate') . ' ' . $input->show('', array('aria-label' => $this->gettext('untilenddate')))
+                );
+
+                $html = html::div($attrib, html::label(null, ucfirst($this->gettext('recurrencend'))) . $html);
+                break;
+
+            case 'rdate':
+                $ul = html::tag('ul', array('id' => 'edit-recurrence-rdates'), '');
+                $input = new html_inputfield(array('name' => 'rdate', 'id' => 'edit-recurrence-rdate-input', 'size' => "10"));
+                $button = new html_inputfield(array('type' => 'button', 'class' => 'button add', 'value' => $this->gettext('addrdate')));
+                $html .= html::div($attrib, $ul . html::div('inputform', $input->show() . $button->show()));
+                break;
+        }
+
+        return $html;
+    }
+
+    /**
+     * Input field for interval selection
+     */
+    private function interval_selector($attrib)
+    {
+        $select = new html_select($attrib);
+        $select->add(range(1,30), range(1,30));
+        return $select;
+    }
+
+    /**
+     * Drop-down menus for recurrence rules like "each last sunday of"
+     */
+    private function rrule_selectors($part, $noselect = null)
+    {
+        // rule selectors
+        $select_prefix = new html_select(array('name' => 'bydayprefix', 'id' => "edit-recurrence-$part-prefix"));
+        if ($noselect) $select_prefix->add($noselect, '');
+        $select_prefix->add(array(
+                $this->gettext('first'),
+                $this->gettext('second'),
+                $this->gettext('third'),
+                $this->gettext('fourth'),
+                $this->gettext('last')
+            ),
+            array(1, 2, 3, 4, -1));
+
+        $select_wday = new html_select(array('name' => 'byday', 'id' => "edit-recurrence-$part-byday"));
+        if ($noselect) $select_wday->add($noselect, '');
+
+        $daymap = array('sunday','monday','tuesday','wednesday','thursday','friday','saturday');
+        $first = $this->rc->config->get('calendar_first_day', 1);
+        for ($j = $first; $j <= $first+6; $j++) {
+            $d = $j % 7;
+            $select_wday->add($this->gettext($daymap[$d]), strtoupper(substr($daymap[$d], 0, 2)));
+        }
+
+        return $select_prefix->show() . '&nbsp;' . $select_wday->show();
+    }
+
+    /**
+     * Convert the recurrence settings to be processed on the client
+     */
+    public function to_client_recurrence($recurrence, $allday = false)
+    {
+        if ($recurrence['UNTIL'])
+            $recurrence['UNTIL'] = $this->adjust_timezone($recurrence['UNTIL'], $allday)->format('c');
+
+        // format RDATE values
+        if (is_array($recurrence['RDATE'])) {
+            $libcal = $this;
+            $recurrence['RDATE'] = array_map(function($rdate) use ($libcal) {
+                return $libcal->adjust_timezone($rdate, true)->format('c');
+            }, $recurrence['RDATE']);
+        }
+
+        unset($recurrence['EXCEPTIONS']);
+
+        return $recurrence;
+    }
+
+    /**
+     * Process the alarms values submitted by the client
+     */
+    public function from_client_recurrence($recurrence, $start = null)
+    {
+        if (is_array($recurrence) && !empty($recurrence['UNTIL'])) {
+            $recurrence['UNTIL'] = new DateTime($recurrence['UNTIL'], $this->timezone);
+        }
+
+        if (is_array($recurrence) && is_array($recurrence['RDATE'])) {
+            $tz = $this->timezone;
+            $recurrence['RDATE'] = array_map(function($rdate) use ($tz, $start) {
+                try {
+                    $dt = new DateTime($rdate, $tz);
+                    if (is_a($start, 'DateTime'))
+                        $dt->setTime($start->format('G'), $start->format('i'));
+                    return $dt;
+                }
+                catch (Exception $e) {
+                    return null;
+                }
+            }, $recurrence['RDATE']);
+        }
+
+        return $recurrence;
+    }
+
+
+    /*********  Attachments handling  *********/
+
+    /**
+     * Handler for attachment uploads
+     */
+    public function attachment_upload($session_key, $id_prefix = '')
+    {
+        // Upload progress update
+        if (!empty($_GET['_progress'])) {
+            $this->rc->upload_progress();
+        }
+
+        $recid = $id_prefix . rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC);
+        $uploadid = rcube_utils::get_input_value('_uploadid', rcube_utils::INPUT_GPC);
+
+        if (!is_array($_SESSION[$session_key]) || $_SESSION[$session_key]['id'] != $recid) {
+            $_SESSION[$session_key] = array();
+            $_SESSION[$session_key]['id'] = $recid;
+            $_SESSION[$session_key]['attachments'] = array();
+        }
+
+        // clear all stored output properties (like scripts and env vars)
+        $this->rc->output->reset();
+
+        if (is_array($_FILES['_attachments']['tmp_name'])) {
+            foreach ($_FILES['_attachments']['tmp_name'] as $i => $filepath) {
+              // Process uploaded attachment if there is no error
+              $err = $_FILES['_attachments']['error'][$i];
+
+              if (!$err) {
+                $attachment = array(
+                    'path' => $filepath,
+                    'size' => $_FILES['_attachments']['size'][$i],
+                    'name' => $_FILES['_attachments']['name'][$i],
+                    'mimetype' => rcube_mime::file_content_type($filepath, $_FILES['_attachments']['name'][$i], $_FILES['_attachments']['type'][$i]),
+                    'group' => $recid,
+                );
+
+                $attachment = $this->rc->plugins->exec_hook('attachment_upload', $attachment);
+              }
+
+              if (!$err && $attachment['status'] && !$attachment['abort']) {
+                  $id = $attachment['id'];
+
+                  // store new attachment in session
+                  unset($attachment['status'], $attachment['abort']);
+                  $_SESSION[$session_key]['attachments'][$id] = $attachment;
+
+                  if (($icon = $_SESSION[$session_key . '_deleteicon']) && is_file($icon)) {
+                      $button = html::img(array(
+                          'src' => $icon,
+                          'alt' => $this->rc->gettext('delete')
+                      ));
+                  }
+                  else {
+                      $button = rcube::Q($this->rc->gettext('delete'));
+                  }
+
+                  $content = html::a(array(
+                      'href' => "#delete",
+                      'class' => 'delete',
+                      'onclick' => sprintf("return %s.remove_from_attachment_list('rcmfile%s')", rcmail_output::JS_OBJECT_NAME, $id),
+                      'title' => $this->rc->gettext('delete'),
+                      'aria-label' => $this->rc->gettext('delete') . ' ' . $attachment['name'],
+                  ), $button);
+
+                  $content .= rcube::Q($attachment['name']);
+
+                  $this->rc->output->command('add2attachment_list', "rcmfile$id", array(
+                      'html' => $content,
+                      'name' => $attachment['name'],
+                      'mimetype' => $attachment['mimetype'],
+                      'classname' => rcube_utils::file2class($attachment['mimetype'], $attachment['name']),
+                      'complete' => true), $uploadid);
+              }
+              else {  // upload failed
+                  if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) {
+                    $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array(
+                        'size' => $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize'))))));
+                  }
+                  else if ($attachment['error']) {
+                      $msg = $attachment['error'];
+                  }
+                  else {
+                      $msg = $this->rc->gettext('fileuploaderror');
+                  }
+
+                  $this->rc->output->command('display_message', $msg, 'error');
+                  $this->rc->output->command('remove_from_attachment_list', $uploadid);
+                }
+            }
+        }
+        else if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+            // if filesize exceeds post_max_size then $_FILES array is empty,
+            // show filesizeerror instead of fileuploaderror
+            if ($maxsize = ini_get('post_max_size'))
+                $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array(
+                    'size' => $this->rc->show_bytes(parse_bytes($maxsize)))));
+            else
+                $msg = $this->rc->gettext('fileuploaderror');
+
+            $this->rc->output->command('display_message', $msg, 'error');
+            $this->rc->output->command('remove_from_attachment_list', $uploadid);
+        }
+
+        $this->rc->output->send('iframe');
+    }
+
+
+    /**
+     * Deliver an event/task attachment to the client
+     * (similar as in Roundcube core program/steps/mail/get.inc)
+     */
+    public function attachment_get($attachment)
+    {
+        ob_end_clean();
+
+        if ($attachment && $attachment['body']) {
+            // allow post-processing of the attachment body
+            $part = new rcube_message_part;
+            $part->filename  = $attachment['name'];
+            $part->size      = $attachment['size'];
+            $part->mimetype  = $attachment['mimetype'];
+
+            $plugin = $this->rc->plugins->exec_hook('message_part_get', array(
+                'body'     => $attachment['body'],
+                'mimetype' => strtolower($attachment['mimetype']),
+                'download' => !empty($_GET['_download']),
+                'part'     => $part,
+            ));
+
+            if ($plugin['abort'])
+                exit;
+
+            $mimetype = $plugin['mimetype'];
+            list($ctype_primary, $ctype_secondary) = explode('/', $mimetype);
+
+            $browser = $this->rc->output->browser;
+
+            // send download headers
+            if ($plugin['download']) {
+                header("Content-Type: application/octet-stream");
+                if ($browser->ie)
+                    header("Content-Type: application/force-download");
+            }
+            else if ($ctype_primary == 'text') {
+                header("Content-Type: text/$ctype_secondary");
+            }
+            else {
+                header("Content-Type: $mimetype");
+                header("Content-Transfer-Encoding: binary");
+            }
+
+            // display page, @TODO: support text/plain (and maybe some other text formats)
+            if ($mimetype == 'text/html' && empty($_GET['_download'])) {
+                $OUTPUT = new rcmail_html_page();
+                // @TODO: use washtml on $body
+                $OUTPUT->write($plugin['body']);
+            }
+            else {
+                // don't kill the connection if download takes more than 30 sec.
+                @set_time_limit(0);
+
+                $filename = $attachment['name'];
+                $filename = preg_replace('[\r\n]', '', $filename);
+
+                if ($browser->ie && $browser->ver < 7)
+                    $filename = rawurlencode(abbreviate_string($filename, 55));
+                else if ($browser->ie)
+                    $filename = rawurlencode($filename);
+                else
+                    $filename = addcslashes($filename, '"');
+
+                $disposition = !empty($_GET['_download']) ? 'attachment' : 'inline';
+                header("Content-Disposition: $disposition; filename=\"$filename\"");
+
+                echo $plugin['body'];
+            }
+
+            exit;
+        }
+
+        // if we arrive here, the requested part was not found
+        header('HTTP/1.1 404 Not Found');
+        exit;
+    }
+
+    /**
+     * Show "loading..." page in attachment iframe
+     */
+    public function attachment_loading_page()
+    {
+        $url = str_replace('&_preload=1', '', $_SERVER['REQUEST_URI']);
+        $message = $this->rc->gettext('loadingdata');
+
+        header('Content-Type: text/html; charset=' . RCUBE_CHARSET);
+        print "<html>\n<head>\n"
+            . '<meta http-equiv="refresh" content="0; url='.rcube::Q($url).'">' . "\n"
+            . '<meta http-equiv="content-type" content="text/html; charset='.RCUBE_CHARSET.'">' . "\n"
+            . "</head>\n<body>\n$message\n</body>\n</html>";
+        exit;
+    }
+
+    /**
+     * Template object for attachment display frame
+     */
+    public function attachment_frame($attrib = array())
+    {
+        $mimetype = strtolower($this->attachment['mimetype']);
+        list($ctype_primary, $ctype_secondary) = explode('/', $mimetype);
+
+        $attrib['src'] = './?' . str_replace('_frame=', ($ctype_primary == 'text' ? '_show=' : '_preload='), $_SERVER['QUERY_STRING']);
+
+        $this->rc->output->add_gui_object('attachmentframe', $attrib['id']);
+
+        return html::iframe($attrib);
+    }
+
+    /**
+     *
+     */
+    public function attachment_header($attrib = array())
+    {
+        $rcmail = rcmail::get_instance();
+        $dl_link = strtolower($attrib['downloadlink']) == 'true';
+        $dl_url = $this->rc->url(array('_frame' => null, '_download' => 1) + $_GET);
+
+        $table = new html_table(array('cols' => $dl_link ? 3 : 2));
+
+        if (!empty($this->attachment['name'])) {
+            $table->add('title', rcube::Q($this->rc->gettext('filename')));
+            $table->add('header', rcube::Q($this->attachment['name']));
+            if ($dl_link) {
+                $table->add('download-link', html::a($dl_url, rcube::Q($this->rc->gettext('download'))));
+            }
+        }
+
+        if (!empty($this->attachment['mimetype'])) {
+            $table->add('title', rcube::Q($this->rc->gettext('type')));
+            $table->add('header', rcube::Q($this->attachment['mimetype']));
+        }
+
+        if (!empty($this->attachment['size'])) {
+            $table->add('title', rcube::Q($this->rc->gettext('filesize')));
+            $table->add('header', rcube::Q($this->rc->show_bytes($this->attachment['size'])));
+        }
+
+        $this->rc->output->set_env('attachment_download_url', $dl_url);
+
+        return $table->show($attrib);
+    }
+
+
+    /*********  iTip message detection  *********/
+
+    /**
+     * Check mail message structure of there are .ics files attached
+     */
+    public function mail_message_load($p)
+    {
+        $this->ical_message = $p['object'];
+        $itip_part          = null;
+
+        // check all message parts for .ics files
+        foreach ((array)$this->ical_message->mime_parts as $part) {
+            if (self::part_is_vcalendar($part, $this->ical_message)) {
+                if ($part->ctype_parameters['method'])
+                    $itip_part = $part->mime_id;
+                else
+                    $this->ical_parts[] = $part->mime_id;
+            }
+        }
+
+        // priorize part with method parameter
+        if ($itip_part) {
+            $this->ical_parts = array($itip_part);
+        }
+    }
+
+    /**
+     * Getter for the parsed iCal objects attached to the current email message
+     *
+     * @return object libvcalendar parser instance with the parsed objects
+     */
+    public function get_mail_ical_objects()
+    {
+        // create parser and load ical objects
+        if (!$this->mail_ical_parser) {
+            $this->mail_ical_parser = $this->get_ical();
+
+            foreach ($this->ical_parts as $mime_id) {
+                $part    = $this->ical_message->mime_parts[$mime_id];
+                $charset = $part->ctype_parameters['charset'] ?: RCUBE_CHARSET;
+                $this->mail_ical_parser->import($this->ical_message->get_part_body($mime_id, true), $charset);
+
+                // check if the parsed object is an instance of a recurring event/task
+                array_walk($this->mail_ical_parser->objects, 'libcalendaring::identify_recurrence_instance');
+
+                // stop on the part that has an iTip method specified
+                if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) {
+                    $this->mail_ical_parser->message_date = $this->ical_message->headers->date;
+                    $this->mail_ical_parser->mime_id = $mime_id;
+
+                    // store the message's sender address for comparisons
+                    $from = rcube_mime::decode_address_list($this->ical_message->headers->from, 1, true, null, true);
+                    $this->mail_ical_parser->sender = !empty($from) ? $from[1] : '';
+
+                    if (!empty($this->mail_ical_parser->sender)) {
+                        foreach ($this->mail_ical_parser->objects as $i => $object) {
+                            $this->mail_ical_parser->objects[$i]['_sender'] = $this->mail_ical_parser->sender;
+                            $this->mail_ical_parser->objects[$i]['_sender_utf'] = rcube_utils::idn_to_utf8($this->mail_ical_parser->sender);
+                        }
+                    }
+
+                    break;
+                }
+            }
+        }
+
+        return $this->mail_ical_parser;
+    }
+
+    /**
+     * Read the given mime message from IMAP and parse ical data
+     *
+     * @param string Mailbox name
+     * @param string Message UID
+     * @param string Message part ID and object index (e.g. '1.2:0')
+     * @param string Object type filter (optional)
+     *
+     * @return array Hash array with the parsed iCal 
+     */
+    public function mail_get_itip_object($mbox, $uid, $mime_id, $type = null)
+    {
+        $charset = RCUBE_CHARSET;
+
+        // establish imap connection
+        $imap = $this->rc->get_storage();
+        $imap->set_folder($mbox);
+
+        if ($uid && $mime_id) {
+            list($mime_id, $index) = explode(':', $mime_id);
+
+            $part    = $imap->get_message_part($uid, $mime_id);
+            $headers = $imap->get_message_headers($uid);
+            $parser  = $this->get_ical();
+
+            if ($part->ctype_parameters['charset']) {
+                $charset = $part->ctype_parameters['charset'];
+            }
+
+            if ($part) {
+                $objects = $parser->import($part, $charset);
+            }
+        }
+
+        // successfully parsed events/tasks?
+        if (!empty($objects) && ($object = $objects[$index]) && (!$type || $object['_type'] == $type)) {
+            if ($parser->method)
+                $object['_method'] = $parser->method;
+
+            // store the message's sender address for comparisons
+            $from = rcube_mime::decode_address_list($headers->from, 1, true, null, true);
+            $object['_sender'] = !empty($from) ? $from[1] : '';
+            $object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']);
+
+            // check if this is an instance of a recurring event/task
+            self::identify_recurrence_instance($object);
+
+            return $object;
+        }
+
+        return null;
+    }
+
+    /**
+     * Checks if specified message part is a vcalendar data
+     *
+     * @param rcube_message_part Part object
+     * @param rcube_message      Message object
+     *
+     * @return boolean True if part is of type vcard
+     */
+    public static function part_is_vcalendar($part, $message = null)
+    {
+        // First check if the message is "valid" (i.e. not multipart/report)
+        if ($message) {
+            $level = explode('.', $part->mime_id);
+
+            while (array_pop($level) !== null) {
+                $parent = $message->mime_parts[join('.', $level) ?: 0];
+                if ($parent->mimetype == 'multipart/report') {
+                    return false;
+                }
+            }
+        }
+
+        return (
+            in_array($part->mimetype, array('text/calendar', 'text/x-vcalendar', 'application/ics')) ||
+            // Apple sends files as application/x-any (!?)
+            ($part->mimetype == 'application/x-any' && $part->filename && preg_match('/\.ics$/i', $part->filename))
+        );
+    }
+
+    /**
+     * Single occourrences of recurring events are identified by their RECURRENCE-ID property
+     * in iCal which is represented as 'recurrence_date' in our internal data structure.
+     *
+     * Check if such a property exists and derive the '_instance' identifier and '_savemode'
+     * attributes which are used in the storage backend to identify the nested exception item.
+     */
+    public static function identify_recurrence_instance(&$object)
+    {
+        // for savemode=all, remove recurrence instance identifiers
+        if (!empty($object['_savemode']) && $object['_savemode'] == 'all' && $object['recurrence']) {
+            unset($object['_instance'], $object['recurrence_date']);
+        }
+        // set instance and 'savemode' according to recurrence-id
+        else if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) {
+            $object['_instance'] = self::recurrence_instance_identifier($object);
+            $object['_savemode'] = $object['thisandfuture'] ? 'future' : 'current';
+        }
+        else if (!empty($object['recurrence_id']) && !empty($object['_instance'])) {
+            if (strlen($object['_instance']) > 4) {
+                $object['recurrence_date'] = rcube_utils::anytodatetime($object['_instance'], $object['start']->getTimezone());
+            }
+            else {
+                $object['recurrence_date'] = clone $object['start'];
+            }
+        }
+    }
+
+    /**
+     * Return a date() format string to render identifiers for recurrence instances
+     *
+     * @param array Hash array with event properties
+     * @return string Format string
+     */
+    public static function recurrence_id_format($event)
+    {
+        return $event['allday'] ? 'Ymd' : 'Ymd\THis';
+    }
+
+    /**
+     * Return the identifer for the given instance of a recurring event
+     *
+     * @param array Hash array with event properties
+     * @param bool  All-day flag from the main event
+     *
+     * @return mixed Format string or null if identifier cannot be generated
+     */
+    public static function recurrence_instance_identifier($event, $allday = null)
+    {
+        $instance_date = $event['recurrence_date'] ?: $event['start'];
+
+        if ($instance_date && is_a($instance_date, 'DateTime')) {
+            // According to RFC5545 (3.8.4.4) RECURRENCE-ID format should
+            // be date/date-time depending on the main event type, not the exception
+            if ($allday === null) {
+                $allday = $event['allday'];
+            }
+
+            return $instance_date->format($allday ? 'Ymd' : 'Ymd\THis');
+        }
+    }
+
+
+    /*********  Attendee handling functions  *********/
+
+    /**
+     * Handler for attendee group expansion requests
+     */
+    public function expand_attendee_group()
+    {
+        $id     = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST);
+        $data   = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true);
+        $result = array('id' => $id, 'members' => array());
+        $maxnum = 500;
+
+        // iterate over all autocomplete address books (we don't know the source of the group)
+        foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $abook_id) {
+            if (($abook = $this->rc->get_address_book($abook_id)) && $abook->groups) {
+                foreach ($abook->list_groups($data['name'], 1) as $group) {
+                    // this is the matching group to expand
+                    if (in_array($data['email'], (array)$group['email'])) {
+                        $abook->set_pagesize($maxnum);
+                        $abook->set_group($group['ID']);
+
+                        // get all members
+                        $res = $abook->list_records($this->rc->config->get('contactlist_fields'));
+
+                        // handle errors (e.g. sizelimit, timelimit)
+                        if ($abook->get_error()) {
+                            $result['error'] = $this->rc->gettext('expandattendeegrouperror', 'libcalendaring');
+                            $res = false;
+                        }
+                        // check for maximum number of members (we don't wanna bloat the UI too much)
+                        else if ($res->count > $maxnum) {
+                            $result['error'] = $this->rc->gettext('expandattendeegroupsizelimit', 'libcalendaring');
+                            $res = false;
+                        }
+
+                        while ($res && ($member = $res->iterate())) {
+                            $emails = (array)$abook->get_col_values('email', $member, true);
+                            if (!empty($emails) && ($email = array_shift($emails))) {
+                                $result['members'][] = array(
+                                    'email' => $email,
+                                    'name' => rcube_addressbook::compose_list_name($member),
+                                );
+                            }
+                        }
+
+                        break 2;
+                    }
+                }
+            }
+        }
+
+        $this->rc->output->command('plugin.expand_attendee_callback', $result);
+    }
+
+    /**
+     * Merge attendees of the old and new event version
+     * with keeping current user and his delegatees status
+     *
+     * @param array &$new   New object data
+     * @param array $old    Old object data
+     * @param bool  $status New status of the current user
+     */
+    public function merge_attendees(&$new, $old, $status = null)
+    {
+        if (empty($status)) {
+            $emails    = $this->get_user_emails();
+            $delegates = array();
+            $attendees = array();
+
+            // keep attendee status of the current user
+            foreach ((array) $new['attendees'] as $i => $attendee) {
+                if (empty($attendee['email'])) {
+                    continue;
+                }
+
+                $attendees[] = $email = strtolower($attendee['email']);
+
+                if (in_array($email, $emails)) {
+                    foreach ($old['attendees'] as $_attendee) {
+                        if ($attendee['email'] == $_attendee['email']) {
+                            $new['attendees'][$i] = $_attendee;
+                            if ($_attendee['status'] == 'DELEGATED' && ($email = $_attendee['delegated-to'])) {
+                                $delegates[] = strtolower($email);
+                            }
+
+                            break;
+                        }
+                    }
+                }
+            }
+
+            // make sure delegated attendee is not lost
+            foreach ($delegates as $delegatee) {
+                if (!in_array($delegatee, $attendees)) {
+                    foreach ((array) $old['attendees'] as $attendee) {
+                        if ($attendee['email'] && ($email = strtolower($attendee['email'])) && $email == $delegatee) {
+                            $new['attendees'][] = $attendee;
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+
+        // We also make sure that status of any attendee
+        // is not overriden by NEEDS-ACTION if it was already set
+        // which could happen if you work with shared events
+        foreach ((array) $new['attendees'] as $i => $attendee) {
+            if ($attendee['email'] && $attendee['status'] == 'NEEDS-ACTION') {
+                foreach ($old['attendees'] as $_attendee) {
+                    if ($attendee['email'] == $_attendee['email']) {
+                        $new['attendees'][$i]['status'] = $_attendee['status'];
+                        unset($new['attendees'][$i]['rsvp']);
+                        break;
+                    }
+                }
+            }
+        }
+    }
+
+
+    /*********  Static utility functions  *********/
+
+    /**
+     * Convert the internal structured data into a vcalendar rrule 2.0 string
+     */
+    public static function to_rrule($recurrence, $allday = false)
+    {
+        if (is_string($recurrence))
+            return $recurrence;
+
+        $rrule = '';
+        foreach ((array)$recurrence as $k => $val) {
+            $k = strtoupper($k);
+            switch ($k) {
+            case 'UNTIL':
+                // convert to UTC according to RFC 5545
+                if (is_a($val, 'DateTime')) {
+                    if (!$allday && !$val->_dateonly) {
+                        $until = clone $val;
+                        $until->setTimezone(new DateTimeZone('UTC'));
+                        $val = $until->format('Ymd\THis\Z');
+                    }
+                    else {
+                        $val = $val->format('Ymd');
+                    }
+                }
+                break;
+            case 'RDATE':
+            case 'EXDATE':
+                foreach ((array)$val as $i => $ex) {
+                    if (is_a($ex, 'DateTime'))
+                        $val[$i] = $ex->format('Ymd\THis');
+                }
+                $val = join(',', (array)$val);
+                break;
+            case 'EXCEPTIONS':
+                continue 2;
+            }
+
+            if (strlen($val))
+                $rrule .= $k . '=' . $val . ';';
+        }
+
+        return rtrim($rrule, ';');
+    }
+
+    /**
+     * Convert from fullcalendar date format to PHP date() format string
+     */
+    public static function to_php_date_format($from)
+    {
+        // "dd.MM.yyyy HH:mm:ss" => "d.m.Y H:i:s"
+        return strtr(strtr($from, array(
+            'YYYY' => 'Y',
+            'YY'   => 'y',
+            'yyyy' => 'Y',
+            'yy'   => 'y',
+            'MMMM' => 'F',
+            'MMM'  => 'M',
+            'MM'   => 'm',
+            'M'    => 'n',
+            'dddd' => 'l',
+            'ddd'  => 'D',
+            'dd'   => 'd',
+            'd'    => 'j',
+            'HH'   => '**',
+            'hh'   => '%%',
+            'H'    => 'G',
+            'h'    => 'g',
+            'mm'   => 'i',
+            'ss'   => 's',
+            'TT'   => 'A',
+            'tt'   => 'a',
+            'T'    => 'A',
+            't'    => 'a',
+            'u'    => 'c',
+        )), array(
+            '**'   => 'H',
+            '%%'   => 'h',
+        ));
+    }
+
+    /**
+     * Convert from PHP date() format to fullcalendar format string
+     */
+    public static function from_php_date_format($from)
+    {
+        // "d.m.Y H:i:s" => "dd.MM.yyyy HH:mm:ss"
+        return strtr($from, array(
+            'y' => 'yy',
+            'Y' => 'yyyy',
+            'M' => 'MMM',
+            'F' => 'MMMM',
+            'm' => 'MM',
+            'n' => 'M',
+            'j' => 'd',
+            'd' => 'dd',
+            'D' => 'ddd',
+            'l' => 'dddd',
+            'H' => 'HH',
+            'h' => 'hh',
+            'G' => 'H',
+            'g' => 'h',
+            'i' => 'mm',
+            's' => 'ss',
+            'A' => 'TT',
+            'a' => 'tt',
+            'c' => 'u',
+        ));
+    }
+
+}