view plugins/libcalendaring/libcalendaring.php @ 38:ac106d4c8961 default tip

flip /etc/roundcube to point here
author Charlie Root
date Sat, 29 Dec 2018 05:39:53 -0500
parents 888e774ee983
children
line wrap: on
line source

<?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',
        ));
    }

}