diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index 6a02b9e3..379bd945 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1,1532 +1,1551 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ 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' => "Y-m-d", 'calendar_date_short' => "M-j", 'calendar_date_long' => "F j Y", 'calendar_date_agenda' => "l M-d", 'calendar_time_format' => "H:m", 'calendar_first_day' => 1, 'calendar_first_hour' => 6, 'calendar_date_format_sets' => array( 'Y-m-d' => array('d M Y', 'm-d', 'l m-d'), 'Y/m/d' => array('d M Y', 'm/d', 'l m/d'), 'Y.m.d' => array('d M Y', 'm.d', 'l m.d'), 'd-m-Y' => array('d M Y', 'd-m', 'l d-m'), 'd/m/Y' => array('d M Y', 'd/m', 'l d/m'), 'd.m.Y' => array('d M Y', 'd.m', 'l d.m'), 'j.n.Y' => array('d M Y', 'd.m', 'l d.m'), 'm/d/Y' => array('M d Y', 'm/d', 'l m/d'), ), ); 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', 'showmore' ); } 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', 'date_agenda'); foreach ($keys as $key) { $settings[$key] = (string)$this->rc->config->get('calendar_' . $key, $this->defaults['calendar_' . $key]); $settings[$key] = self::from_php_date_format($settings[$key]); } $settings['dates_long'] = $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'); 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', $this->rc->config->get('date_format')); $defaults['time_format'] = $this->rc->config->get('calendar_time_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, false); if (($todate = $this->rc->format_date($event['end'], $date_format, false)) != $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 form-control', 'size' => 3)); $input_date = new html_inputfield(array('name' => 'alarmdate[]', 'class' => 'edit-alarm-date form-control', 'size' => 10)); $input_time = new html_inputfield(array('name' => 'alarmtime[]', 'class' => 'edit-alarm-time form-control', 'size' => 6)); $select_type = new html_select(array('name' => 'alarmtype[]', 'class' => 'edit-alarm-type form-control', 'id' => $attrib['id'])); $select_offset = new html_select(array('name' => 'alarmoffset[]', 'class' => 'edit-alarm-offset form-control')); $select_related = new html_select(array('name' => 'alarmrelated[]', 'class' => 'edit-alarm-related form-control')); $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'); return html::span('edit-alarm-set', $select_type->show($this->rc->config->get('calendar_default_alarm_type', '')) . ' ' . html::span(array('class' => 'edit-alarm-values input-group', 'style' => 'display:none'), $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]) . ' ' . $select_related->show() . ' ' . $input_date->show('', $hidden) . ' ' . $input_time->show('', $hidden) ) ); } /** * 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 * Unsets 'rsvp' flag too. * * @param array &$event Event data * @param string $status The PARTSTAT value to set * @param bool $recursive Recurive call * * @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); unset($event['attendees'][$i]['rsvp']); $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') + if ( + (empty($rec['valarms']) && empty($rec['alarms'])) + || !empty($rec['cancelled']) + || (!empty($rec['status']) && $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 (!empty($rec['startdate'])) { + $time = !empty($rec['starttime']) ? $rec['starttime'] : '12:00'; + $rec['start'] = new DateTime($rec['startdate'] . ' ' . $time, $timezone); + } + if (!empty($rec['date'])) { + $time = !empty($rec['time']) ? $rec['time'] : '12:00'; + $rec[!empty($rec['start']) ? 'end' : 'start'] = new DateTime($rec['date'] . ' ' . $time, $timezone); + } } - if (!$rec['end']) + if (empty($rec['end'])) { $rec['end'] = $rec['start']; + } // support legacy format - if (!$rec['valarms']) { + if (empty($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 + // alarm ID eq. record ID by default to keep backwards compatibility + $alarm_id = isset($rec['id']) ? $rec['id'] : null; + $alarm_prop = null; + $expires = new DateTime('now - 12 hours'); + $notify_at = null; // 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']; + $refdate = !empty($alarm['related']) && $alarm['related'] == 'END' ? $rec['end'] : $rec['start']; // abort if no reference date is available to compute notification time - if (!is_a($refdate, 'DateTime')) + 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']; + $action = isset($alarm['action']) ? $alarm['action'] : null; $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'); + $rec_id = substr(md5(isset($rec['id']) ? $rec['id'] : 'none'), 0, 16); + $alarm_id = $rec_id . '-' . $notify_at->format('Ymd\THis'); } } } return !$notify_at ? null : array( 'time' => $notify_at->format('U'), - 'action' => $action ? strtoupper($action) : 'DISPLAY', + 'action' => !empty($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']) + if (!empty($plugin['success'])) { $this->rc->output->show_message('successfullysaved', 'confirmation'); - else + } + 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, + 'start' => !empty($alarm['start']) ? $this->adjust_timezone($alarm['start'])->format('c') : '', + 'end' => !empty($alarm['end'])? $this->adjust_timezone($alarm['end'])->format('c') : '', + 'allDay' => !empty($alarm['allday']), '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 menu'), 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', 'class' => 'form-control')); $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(array('for' => 'edit-recurrence-frequency', 'class' => 'col-form-label col-sm-2'), $this->gettext('frequency')) . html::div('col-sm-10', $select->show('')); break; // daily recurrence case 'daily': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-daily')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-daily', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $this->gettext('days'))))); break; // weekly recurrence form case 'weekly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-weekly')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-weekly', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $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(array('class' => 'col-form-label col-sm-2'), $this->gettext('bydays')) . html::div('col-sm-10 form-control-plaintext', $weekdays)); break; // monthly recurrence form case 'monthly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-monthly')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-monthly', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $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('every'))); $table->add('recurrence-onevery', $this->rrule_selectors($attrib['part'])); $html .= html::div($attrib, html::label(array('class' => 'col-form-label col-sm-2'), $this->gettext('bydays')) . html::div('col-sm-10 form-control-plaintext', $table->show())); break; // annually recurrence form case 'yearly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-yearly')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-yearly', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $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, html::label(array('class' => 'col-form-label col-sm-2'), $this->gettext('bymonths')) . html::div('col-sm-10 form-control-plaintext', html::div(array('id' => 'edit-recurrence-yearly-bymonthblock'), $months) . html::div('recurrence-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', 'class' => 'form-control')); $input = new html_inputfield(array('name' => 'untildate', 'id' => 'edit-recurrence-enddate', 'size' => '10', 'class' => 'form-control datepicker')); $html = html::div('line first', $radio->show('', array('value' => '', 'id' => 'edit-recurrence-repeat-forever')) . ' ' . html::label('edit-recurrence-repeat-forever', $this->gettext('forever')) ); $label = $this->gettext('ntimes'); if (strpos($label, '$') === 0) { $label = str_replace('$n', '', $label); $group = $select->show(1) . html::span('input-group-append', html::span('input-group-text', rcube::Q($label))); } else { $label = str_replace('$n', '', $label); $group = html::span('input-group-prepend', html::span('input-group-text', rcube::Q($label))) . $select->show(1); } $html .= html::div('line', $radio->show('', array('value' => 'count', 'id' => 'edit-recurrence-repeat-count')) . ' ' . html::label('edit-recurrence-repeat-count', $this->gettext('for')) . ' ' . html::span('input-group', $group) ); $html .= html::div('line', $radio->show('', array('value' => 'until', 'id' => 'edit-recurrence-repeat-until', 'aria-label' => $this->gettext('untilenddate'))) . ' ' . html::label('edit-recurrence-repeat-until', $this->gettext('untildate')) . ' ' . $input->show('', array('aria-label' => $this->gettext('untilenddate'))) ); $html = html::div($attrib, html::label(array('class' => 'col-form-label col-sm-2'), ucfirst($this->gettext('recurrencend'))) . html::div('col-sm-10', $html)); break; case 'rdate': $ul = html::tag('ul', array('id' => 'edit-recurrence-rdates', 'class' => 'recurrence-rdates'), ''); $input = new html_inputfield(array('name' => 'rdate', 'id' => 'edit-recurrence-rdate-input', 'size' => "10", 'class' => 'form-control datepicker')); $button = new html_inputfield(array('type' => 'button', 'class' => 'button add', 'value' => $this->gettext('addrdate'))); $html = html::div($attrib, html::label(array('class' => 'col-form-label col-sm-2', 'for' => 'edit-recurrence-rdate-input'), $this->gettext('bydates')) . html::div('col-sm-10', $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", 'class' => 'form-control')); 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", 'class' => 'form-control')); 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() . ' ' . $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; } /********* 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)) + 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) { + if (!$allday && empty($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')) + if (is_a($ex, 'DateTime')) { $val[$i] = $ex->format('Ymd\THis'); + } } $val = join(',', (array)$val); break; case 'EXCEPTIONS': continue 2; } - if (strlen($val)) + 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 (MomentJS) 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', 'c' => '', )); } } diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 164d1841..3dda65d4 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -1,1468 +1,1532 @@ * * Copyright (C) 2013-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ use \Sabre\VObject; use \Sabre\VObject\DateTimeParser; /** * Class to parse and build vCalendar (iCalendar) files * * Uses the Sabre VObject library, version 3.x. * */ class libvcalendar implements Iterator { private $timezone; private $attach_uri = null; private $prodid = '-//Roundcube libcalendaring//Sabre//Sabre VObject//EN'; private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO'); private $attendee_keymap = array( 'name' => 'CN', 'status' => 'PARTSTAT', 'role' => 'ROLE', 'cutype' => 'CUTYPE', 'rsvp' => 'RSVP', 'delegated-from' => 'DELEGATED-FROM', 'delegated-to' => 'DELEGATED-TO', 'schedule-status' => 'SCHEDULE-STATUS', 'schedule-agent' => 'SCHEDULE-AGENT', 'sent-by' => 'SENT-BY', ); private $organizer_keymap = array( 'name' => 'CN', 'schedule-status' => 'SCHEDULE-STATUS', 'schedule-agent' => 'SCHEDULE-AGENT', 'sent-by' => 'SENT-BY', ); private $iteratorkey = 0; private $charset; private $forward_exceptions; private $vhead; private $fp; private $vtimezones = array(); public $method; public $agent = ''; public $objects = array(); public $freebusy = array(); /** * Default constructor */ function __construct($tz = null) { $this->timezone = $tz; $this->prodid = '-//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; } /** * Setter for timezone information */ public function set_timezone($tz) { $this->timezone = $tz; } /** * Setter for URI template for attachment links */ public function set_attach_uri($uri) { $this->attach_uri = $uri; } /** * Setter for a custom PRODID attribute */ public function set_prodid($prodid) { $this->prodid = $prodid; } /** * Setter for a user-agent string to tweak input/output accordingly */ public function set_agent($agent) { $this->agent = $agent; } /** * Free resources by clearing member vars */ public function reset() { $this->vhead = ''; $this->method = ''; $this->objects = array(); $this->freebusy = array(); $this->vtimezones = array(); $this->iteratorkey = 0; if ($this->fp) { fclose($this->fp); $this->fp = null; } } /** * Import events from iCalendar format * * @param string vCalendar input * @param string Input charset (from envelope) * @param boolean True if parsing exceptions should be forwarded to the caller * @return array List of events extracted from the input */ public function import($vcal, $charset = 'UTF-8', $forward_exceptions = false, $memcheck = true) { // TODO: convert charset to UTF-8 if other try { // estimate the memory usage and try to avoid fatal errors when allowed memory gets exhausted if ($memcheck) { $count = substr_count($vcal, 'BEGIN:VEVENT') + substr_count($vcal, 'BEGIN:VTODO'); $expected_memory = $count * 70*1024; // assume ~ 70K per event (empirically determined) if (!rcube_utils::mem_check($expected_memory)) { throw new Exception("iCal file too big"); } } $vobject = VObject\Reader::read($vcal, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); if ($vobject) return $this->import_from_vobject($vobject); } catch (Exception $e) { if ($forward_exceptions) { throw $e; } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "iCal data parse error: " . $e->getMessage()), true, false); } } return array(); } /** * Read iCalendar events from a file * * @param string File path to read from * @param string Input charset (from envelope) * @param boolean True if parsing exceptions should be forwarded to the caller * @return array List of events extracted from the file */ public function import_from_file($filepath, $charset = 'UTF-8', $forward_exceptions = false) { if ($this->fopen($filepath, $charset, $forward_exceptions)) { while ($this->_parse_next(false)) { // nop } fclose($this->fp); $this->fp = null; } return $this->objects; } /** * Open a file to read iCalendar events sequentially * * @param string File path to read from * @param string Input charset (from envelope) * @param boolean True if parsing exceptions should be forwarded to the caller * @return boolean True if file contents are considered valid */ public function fopen($filepath, $charset = 'UTF-8', $forward_exceptions = false) { $this->reset(); // just to be sure... @ini_set('auto_detect_line_endings', true); $this->charset = $charset; $this->forward_exceptions = $forward_exceptions; $this->fp = fopen($filepath, 'r'); // check file content first $begin = fread($this->fp, 1024); if (!preg_match('/BEGIN:VCALENDAR/i', $begin)) { return false; } fseek($this->fp, 0); return $this->_parse_next(); } /** * Parse the next event/todo/freebusy object from the input file */ private function _parse_next($reset = true) { if ($reset) { $this->iteratorkey = 0; $this->objects = array(); $this->freebusy = array(); } $next = $this->_next_component(); $buffer = $next; // load the next component(s) too, as they could contain recurrence exceptions while (preg_match('/(RRULE|RECURRENCE-ID)[:;]/i', $next)) { $next = $this->_next_component(); $buffer .= $next; } // parse the vevent block surrounded with the vcalendar heading if (strlen($buffer) && preg_match('/BEGIN:(VEVENT|VTODO|VFREEBUSY)/i', $buffer)) { try { $this->import($this->vhead . $buffer . "END:VCALENDAR", $this->charset, true, false); } catch (Exception $e) { if ($this->forward_exceptions) { throw new VObject\ParseException($e->getMessage() . " in\n" . $buffer); } else { // write the failing section to error log rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => $e->getMessage() . " in\n" . $buffer), true, false); } // advance to next return $this->_parse_next($reset); } return count($this->objects) > 0; } return false; } /** * Helper method to read the next calendar component from the file */ private function _next_component() { $buffer = ''; $vcalendar_head = false; while (($line = fgets($this->fp, 1024)) !== false) { // ignore END:VCALENDAR lines if (preg_match('/END:VCALENDAR/i', $line)) { continue; } // read vcalendar header (with timezone defintion) if (preg_match('/BEGIN:VCALENDAR/i', $line)) { $this->vhead = ''; $vcalendar_head = true; } // end of VCALENDAR header part if ($vcalendar_head && preg_match('/BEGIN:(VEVENT|VTODO|VFREEBUSY)/i', $line)) { $vcalendar_head = false; } if ($vcalendar_head) { $this->vhead .= $line; } else { $buffer .= $line; if (preg_match('/END:(VEVENT|VTODO|VFREEBUSY)/i', $line)) { break; } } } return $buffer; } /** * Import objects from an already parsed Sabre\VObject\Component object * * @param object Sabre\VObject\Component to read from * @return array List of events extracted from the file */ public function import_from_vobject($vobject) { $seen = array(); $exceptions = array(); if ($vobject->name == 'VCALENDAR') { $this->method = strval($vobject->METHOD); $this->agent = strval($vobject->PRODID); foreach ($vobject->getComponents() as $ve) { if ($ve->name == 'VEVENT' || $ve->name == 'VTODO') { // convert to hash array representation $object = $this->_to_array($ve); // temporarily store this as exception - if ($object['recurrence_date']) { + if (!empty($object['recurrence_date'])) { $exceptions[] = $object; } - else if (!$seen[$object['uid']]++) { + else if (empty($seen[$object['uid']])) { + $seen[$object['uid']] = true; $this->objects[] = $object; } } else if ($ve->name == 'VFREEBUSY') { $this->objects[] = $this->_parse_freebusy($ve); } } // add exceptions to the according master events foreach ($exceptions as $exception) { $uid = $exception['uid']; // make this exception the master - if (!$seen[$uid]++) { + if (empty($seen[$uid])) { + $seen[$uid] = true; $this->objects[] = $exception; } else { foreach ($this->objects as $i => $object) { // add as exception to existing entry with a matching UID if ($object['uid'] == $uid) { $this->objects[$i]['exceptions'][] = $exception; if (!empty($object['recurrence'])) { $this->objects[$i]['recurrence']['EXCEPTIONS'] = &$this->objects[$i]['exceptions']; } break; } } } } } return $this->objects; } /** * Getter for free-busy periods */ public function get_busy_periods() { $out = array(); foreach ((array)$this->freebusy['periods'] as $period) { if ($period[2] != 'FREE') { $out[] = $period; } } return $out; } /** * Helper method to determine whether the connected client is an Apple device */ private function is_apple() { return stripos($this->agent, 'Apple') !== false || stripos($this->agent, 'Mac OS X') !== false || stripos($this->agent, 'iOS/') !== false; } /** * Convert the given VEvent object to a libkolab compatible array representation * * @param object Vevent object to convert * @return array Hash array with object properties */ private function _to_array($ve) { $event = array( 'uid' => self::convert_string($ve->UID), 'title' => self::convert_string($ve->SUMMARY), '_type' => $ve->name == 'VTODO' ? 'task' : 'event', // set defaults 'priority' => 0, 'attendees' => array(), 'x-custom' => array(), ); // Catch possible exceptions when date is invalid (Bug #2144) // We can skip these fields, they aren't critical foreach (array('CREATED' => 'created', 'LAST-MODIFIED' => 'changed', 'DTSTAMP' => 'changed') as $attr => $field) { try { - if (!$event[$field] && $ve->{$attr}) { + if (empty($event[$field]) && !empty($ve->{$attr})) { $event[$field] = $ve->{$attr}->getDateTime(); } } catch (Exception $e) {} } // map other attributes to internal fields foreach ($ve->children as $prop) { if (!($prop instanceof VObject\Property)) continue; $value = strval($prop); switch ($prop->name) { case 'DTSTART': case 'DTEND': case 'DUE': $propmap = array('DTSTART' => 'start', 'DTEND' => 'end', 'DUE' => 'due'); $event[$propmap[$prop->name]] = self::convert_datetime($prop); break; case 'TRANSP': $event['free_busy'] = strval($prop) == 'TRANSPARENT' ? 'free' : 'busy'; break; case 'STATUS': if ($value == 'TENTATIVE') $event['free_busy'] = 'tentative'; else if ($value == 'CANCELLED') $event['cancelled'] = true; else if ($value == 'COMPLETED') $event['complete'] = 100; $event['status'] = $value; break; case 'COMPLETED': if (self::convert_datetime($prop)) { $event['status'] = 'COMPLETED'; $event['complete'] = 100; } break; case 'PRIORITY': if (is_numeric($value)) $event['priority'] = $value; break; case 'RRULE': - $params = is_array($event['recurrence']) ? $event['recurrence'] : array(); + $params = !empty($event['recurrence']) && is_array($event['recurrence']) ? $event['recurrence'] : array(); // parse recurrence rule attributes foreach ($prop->getParts() as $k => $v) { $params[strtoupper($k)] = is_array($v) ? implode(',', $v) : $v; } - if ($params['UNTIL']) + if (!empty($params['UNTIL'])) { $params['UNTIL'] = date_create($params['UNTIL']); - if (!$params['INTERVAL']) + } + if (empty($params['INTERVAL'])) { $params['INTERVAL'] = 1; + } $event['recurrence'] = array_filter($params); break; case 'EXDATE': if (!empty($value)) { $exdates = array_map(function($_) { return is_array($_) ? $_[0] : $_; }, self::convert_datetime($prop, true)); - $event['recurrence']['EXDATE'] = array_merge((array)$event['recurrence']['EXDATE'], $exdates); + if (!empty($event['recurrence']['EXDATE'])) { + $event['recurrence']['EXDATE'] = array_merge($event['recurrence']['EXDATE'], $exdates); + } + else { + $event['recurrence']['EXDATE'] = $exdates; + } } break; case 'RDATE': if (!empty($value)) { $rdates = array_map(function($_) { return is_array($_) ? $_[0] : $_; }, self::convert_datetime($prop, true)); - $event['recurrence']['RDATE'] = array_merge((array)$event['recurrence']['RDATE'], $rdates); + if (!empty($event['recurrence']['RDATE'])) { + $event['recurrence']['RDATE'] = array_merge($event['recurrence']['RDATE'], $rdates); + } + else { + $event['recurrence']['RDATE'] = $rdates; + } } break; case 'RECURRENCE-ID': $event['recurrence_date'] = self::convert_datetime($prop); if ($prop->offsetGet('RANGE') == 'THISANDFUTURE' || $prop->offsetGet('THISANDFUTURE') !== null) { $event['thisandfuture'] = true; } break; case 'RELATED-TO': $reltype = $prop->offsetGet('RELTYPE'); if ($reltype == 'PARENT' || $reltype === null) { $event['parent_id'] = $value; } break; case 'SEQUENCE': $event['sequence'] = intval($value); break; case 'PERCENT-COMPLETE': $event['complete'] = intval($value); break; case 'LOCATION': case 'DESCRIPTION': case 'URL': case 'COMMENT': $event[strtolower($prop->name)] = self::convert_string($prop); break; case 'CATEGORY': case 'CATEGORIES': - $event['categories'] = array_merge((array)$event['categories'], $prop->getParts()); + if (!empty($event['categories'])) { + $event['categories'] = array_merge((array) $event['categories'], $prop->getParts()); + } + else { + $event['categories'] = $prop->getParts(); + } break; case 'CLASS': case 'X-CALENDARSERVER-ACCESS': $event['sensitivity'] = strtolower($value); break; case 'X-MICROSOFT-CDO-BUSYSTATUS': - if ($value == 'OOF') + if ($value == 'OOF') { $event['free_busy'] = 'outofoffice'; - else if (in_array($value, array('FREE', 'BUSY', 'TENTATIVE'))) + } + else if (in_array($value, array('FREE', 'BUSY', 'TENTATIVE'))) { $event['free_busy'] = strtolower($value); + } break; case 'ATTENDEE': case 'ORGANIZER': $params = array('RSVP' => false); foreach ($prop->parameters() as $pname => $pvalue) { switch ($pname) { case 'RSVP': $params[$pname] = strtolower($pvalue) == 'true'; break; case 'CN': $params[$pname] = self::unescape($pvalue); break; default: $params[$pname] = strval($pvalue); break; } } $attendee = self::map_keys($params, array_flip($this->attendee_keymap)); $attendee['email'] = preg_replace('!^mailto:!i', '', $value); if ($prop->name == 'ORGANIZER') { $attendee['role'] = 'ORGANIZER'; $attendee['status'] = 'ACCEPTED'; $event['organizer'] = $attendee; if (array_key_exists('schedule-agent', $attendee)) { $schedule_agent = $attendee['schedule-agent']; } } else if ($attendee['email'] != $event['organizer']['email']) { $event['attendees'][] = $attendee; } break; case 'ATTACH': $params = self::parameters_array($prop); if (substr($value, 0, 4) == 'http' && !strpos($value, ':attachment:')) { $event['links'][] = $value; } else if (strlen($value) && strtoupper($params['VALUE']) == 'BINARY') { $attachment = self::map_keys($params, array('FMTTYPE' => 'mimetype', 'X-LABEL' => 'name', 'X-APPLE-FILENAME' => 'name')); $attachment['data'] = $value; $attachment['size'] = strlen($value); $event['attachments'][] = $attachment; } break; default: if (substr($prop->name, 0, 2) == 'X-') $event['x-custom'][] = array($prop->name, strval($value)); break; } } // check DURATION property if no end date is set if (empty($event['end']) && $ve->DURATION) { try { $duration = new DateInterval(strval($ve->DURATION)); $end = clone $event['start']; $end->add($duration); $event['end'] = $end; } catch (\Exception $e) { trigger_error(strval($e), E_USER_WARNING); } } // validate event dates if ($event['_type'] == 'event') { - $event['allday'] = false; - - // check for all-day dates - if ($event['start']->_dateonly) { - $event['allday'] = true; - } + $event['allday'] = !empty($event['start']->_dateonly); // events may lack the DTEND property, set it to DTSTART (RFC5545 3.6.1) if (empty($event['end'])) { $event['end'] = clone $event['start']; } // shift end-date by one day (except Thunderbird) else if ($event['allday'] && is_object($event['end'])) { $event['end']->sub(new \DateInterval('PT23H')); } // sanity-check and fix end date if (!empty($event['end']) && $event['end'] < $event['start']) { $event['end'] = clone $event['start']; } } // make organizer part of the attendees list for compatibility reasons if (!empty($event['organizer']) && is_array($event['attendees']) && $event['_type'] == 'event') { array_unshift($event['attendees'], $event['organizer']); } // find alarms foreach ($ve->select('VALARM') as $valarm) { $action = 'DISPLAY'; $trigger = null; $alarm = array(); foreach ($valarm->children as $prop) { $value = strval($prop); switch ($prop->name) { case 'TRIGGER': foreach ($prop->parameters as $param) { if ($param->name == 'VALUE' && $param->getValue() == 'DATE-TIME') { $trigger = '@' . $prop->getDateTime()->format('U'); $alarm['trigger'] = $prop->getDateTime(); } else if ($param->name == 'RELATED') { $alarm['related'] = $param->getValue(); } } if (!$trigger && ($values = libcalendaring::parse_alarm_value($value))) { $trigger = $values[2]; } - if (!$alarm['trigger']) { + if (empty($alarm['trigger'])) { $alarm['trigger'] = rtrim(preg_replace('/([A-Z])0[WDHMS]/', '\\1', $value), 'T'); // if all 0-values have been stripped, assume 'at time' - if ($alarm['trigger'] == 'P') + if ($alarm['trigger'] == 'P') { $alarm['trigger'] = 'PT0S'; + } } break; case 'ACTION': $action = $alarm['action'] = strtoupper($value); break; case 'SUMMARY': case 'DESCRIPTION': case 'DURATION': $alarm[strtolower($prop->name)] = self::convert_string($prop); break; case 'REPEAT': $alarm['repeat'] = intval($value); break; case 'ATTENDEE': $alarm['attendees'][] = preg_replace('!^mailto:!i', '', $value); break; case 'ATTACH': $params = self::parameters_array($prop); if (strlen($value) && (preg_match('/^[a-z]+:/', $value) || strtoupper($params['VALUE']) == 'URI')) { // we only support URI-type of attachments here $alarm['uri'] = $value; } break; } } if ($action != 'NONE') { - if ($trigger && !$event['alarms']) // store first alarm in legacy property + // store first alarm in legacy property + if ($trigger && empty($event['alarms'])) { $event['alarms'] = $trigger . ':' . $action; + } - if ($alarm['trigger']) + if (!empty($alarm['trigger'])) { $event['valarms'][] = $alarm; + } } } // assign current timezone to event start/end - if ($event['start'] instanceof DateTime) { + if (!empty($event['start']) && $event['start'] instanceof DateTime) { $this->_apply_timezone($event['start']); } else { unset($event['start']); } - if ($event['end'] instanceof DateTime) { + if (!empty($event['end']) && $event['end'] instanceof DateTime) { $this->_apply_timezone($event['end']); } else { unset($event['end']); } // some iTip CANCEL messages only contain the start date - if (!$event['end'] && $event['start'] && $this->method == 'CANCEL') { + if (empty($event['end']) && !empty($event['start']) && $this->method == 'CANCEL') { $event['end'] = clone $event['start']; } // T2531: Remember SCHEDULE-AGENT in custom property to properly // support event updates via CalDAV when SCHEDULE-AGENT=CLIENT is used if (isset($schedule_agent)) { $event['x-custom'][] = array('SCHEDULE-AGENT', $schedule_agent); } // minimal validation if (empty($event['uid']) || ($event['_type'] == 'event' && empty($event['start']) != empty($event['end']))) { throw new VObject\ParseException('Object validation failed: missing mandatory object properties'); } return $event; } /** * Apply user timezone to DateTime object */ private function _apply_timezone(&$date) { if (empty($this->timezone)) { return; } // For date-only we'll keep the date and time intact if ($date->_dateonly) { $dt = new DateTime(null, $this->timezone); $dt->setDate($date->format('Y'), $date->format('n'), $date->format('j')); $dt->setTime($date->format('G'), $date->format('i'), 0); $date = $dt; } else { $date->setTimezone($this->timezone); } } /** * Parse the given vfreebusy component into an array representation */ private function _parse_freebusy($ve) { $this->freebusy = array('_type' => 'freebusy', 'periods' => array()); $seen = array(); foreach ($ve->children as $prop) { if (!($prop instanceof VObject\Property)) continue; $value = strval($prop); switch ($prop->name) { case 'CREATED': case 'LAST-MODIFIED': case 'DTSTAMP': case 'DTSTART': case 'DTEND': - $propmap = array('DTSTART' => 'start', 'DTEND' => 'end', 'CREATED' => 'created', 'LAST-MODIFIED' => 'changed', 'DTSTAMP' => 'changed'); + $propmap = array( + 'DTSTART' => 'start', + 'DTEND' => 'end', + 'CREATED' => 'created', + 'LAST-MODIFIED' => 'changed', + 'DTSTAMP' => 'changed' + ); $this->freebusy[$propmap[$prop->name]] = self::convert_datetime($prop); break; case 'ORGANIZER': $this->freebusy['organizer'] = preg_replace('!^mailto:!i', '', $value); break; case 'FREEBUSY': // The freebusy component can hold more than 1 value, separated by commas. $periods = explode(',', $value); $fbtype = strval($prop['FBTYPE']) ?: 'BUSY'; // skip dupes - if ($seen[$value.':'.$fbtype]++) + if (!empty($seen[$value.':'.$fbtype])) { break; + } + + $seen[$value.':'.$fbtype] = true; foreach ($periods as $period) { // Every period is formatted as [start]/[end]. The start is an // absolute UTC time, the end may be an absolute UTC time, or // duration (relative) value. list($busyStart, $busyEnd) = explode('/', $period); $busyStart = DateTimeParser::parse($busyStart); $busyEnd = DateTimeParser::parse($busyEnd); if ($busyEnd instanceof \DateInterval) { $tmp = clone $busyStart; $tmp->add($busyEnd); $busyEnd = $tmp; } if ($busyEnd && $busyEnd > $busyStart) $this->freebusy['periods'][] = array($busyStart, $busyEnd, $fbtype); } break; case 'COMMENT': $this->freebusy['comment'] = $value; } } return $this->freebusy; } /** * */ public static function convert_string($prop) { return strval($prop); } /** * */ public static function unescape($prop) { return str_replace('\,', ',', strval($prop)); } /** * Helper method to correctly interpret an all-day date value */ public static function convert_datetime($prop, $as_array = false) { if (empty($prop)) { return $as_array ? array() : null; } else if ($prop instanceof VObject\Property\iCalendar\DateTime) { if (count($prop->getDateTimes()) > 1) { $dt = array(); $dateonly = !$prop->hasTime(); foreach ($prop->getDateTimes() as $item) { $item->_dateonly = $dateonly; $dt[] = $item; } } else { $dt = $prop->getDateTime(); if (!$prop->hasTime()) { $dt->_dateonly = true; } } } else if ($prop instanceof VObject\Property\iCalendar\Period) { $dt = array(); foreach ($prop->getParts() as $val) { try { list($start, $end) = explode('/', $val); $start = DateTimeParser::parseDateTime($start); // This is a duration value. if ($end[0] === 'P') { $dur = DateTimeParser::parseDuration($end); $end = clone $start; $end->add($dur); } else { $end = DateTimeParser::parseDateTime($end); } $dt[] = array($start, $end); } catch (Exception $e) { // ignore single date parse errors } } } else if ($prop instanceof \DateTime) { $dt = $prop; } // force return value to array if requested if ($as_array && !is_array($dt)) { $dt = empty($dt) ? array() : array($dt); } return $dt; } /** * Create a Sabre\VObject\Property instance from a PHP DateTime object * * @param object VObject\Document parent node to create property for * @param string Property name * @param object DateTime * @param boolean Set as UTC date * @param boolean Set as VALUE=DATE property */ public function datetime_prop($cal, $name, $dt, $utc = false, $dateonly = null, $set_type = false) { if ($utc) { $dt->setTimeZone(new \DateTimeZone('UTC')); $is_utc = true; } else { $is_utc = ($tz = $dt->getTimezone()) && in_array($tz->getName(), array('UTC','GMT','Z')); } - $is_dateonly = $dateonly === null ? (bool)$dt->_dateonly : (bool)$dateonly; + $is_dateonly = $dateonly === null ? !empty($dt->_dateonly) : (bool) $dateonly; $vdt = $cal->createProperty($name, $dt, null, $is_dateonly ? 'DATE' : 'DATE-TIME'); if ($is_dateonly) { $vdt['VALUE'] = 'DATE'; } else if ($set_type) { $vdt['VALUE'] = 'DATE-TIME'; } // register timezone for VTIMEZONE block if (!$is_utc && !$dateonly && $tz && ($tzname = $tz->getName())) { $ts = $dt->format('U'); - if (is_array($this->vtimezones[$tzname])) { + if (!empty($this->vtimezones[$tzname])) { $this->vtimezones[$tzname][0] = min($this->vtimezones[$tzname][0], $ts); $this->vtimezones[$tzname][1] = max($this->vtimezones[$tzname][1], $ts); } else { $this->vtimezones[$tzname] = array($ts, $ts); } } return $vdt; } /** * Copy values from one hash array to another using a key-map */ public static function map_keys($values, $map) { $out = array(); foreach ($map as $from => $to) { if (isset($values[$from])) $out[$to] = is_array($values[$from]) ? join(',', $values[$from]) : $values[$from]; } return $out; } /** * */ private static function parameters_array($prop) { $params = array(); foreach ($prop->parameters() as $name => $value) { $params[strtoupper($name)] = strval($value); } return $params; } /** * Export events to iCalendar format * * @param array Events as array * @param string VCalendar method to advertise * @param boolean Directly send data to stdout instead of returning * @param callable Callback function to fetch attachment contents, false if no attachment export * @param boolean Add VTIMEZONE block with timezone definitions for the included events * @return string Events in iCalendar format (http://tools.ietf.org/html/rfc5545) */ public function export($objects, $method = null, $write = false, $get_attachment = false, $with_timezones = true) { $this->method = $method; // encapsulate in VCALENDAR container $vcal = new VObject\Component\VCalendar(); $vcal->VERSION = '2.0'; $vcal->PRODID = $this->prodid; $vcal->CALSCALE = 'GREGORIAN'; if (!empty($method)) { $vcal->METHOD = $method; } // write vcalendar header if ($write) { echo preg_replace('/END:VCALENDAR[\r\n]*$/m', '', $vcal->serialize()); } foreach ($objects as $object) { $this->_to_ical($object, !$write?$vcal:false, $get_attachment); } // include timezone information if ($with_timezones || !empty($method)) { foreach ($this->vtimezones as $tzid => $range) { $vt = self::get_vtimezone($tzid, $range[0], $range[1], $vcal); if (empty($vt)) { continue; // no timezone information found } if ($write) { echo $vt->serialize(); } else { $vcal->add($vt); } } } if ($write) { echo "END:VCALENDAR\r\n"; return true; } else { return $vcal->serialize(); } } /** * Build a valid iCal format block from the given event * * @param array Hash array with event/task properties from libkolab * @param object VCalendar object to append event to or false for directly sending data to stdout * @param callable Callback function to fetch attachment contents, false if no attachment export * @param object RECURRENCE-ID property when serializing a recurrence exception */ private function _to_ical($event, $vcal, $get_attachment, $recurrence_id = null) { - $type = $event['_type'] ?: 'event'; + $type = !empty($event['_type']) ? $event['_type'] : 'event'; $cal = $vcal ?: new VObject\Component\VCalendar(); $ve = $cal->create($this->type_component_map[$type]); $ve->UID = $event['uid']; // set DTSTAMP according to RFC 5545, 3.8.7.2. $dtstamp = !empty($event['changed']) && empty($this->method) ? $event['changed'] : new DateTime('now', new \DateTimeZone('UTC')); $ve->DTSTAMP = $this->datetime_prop($cal, 'DTSTAMP', $dtstamp, true); // all-day events end the next day - if ($event['allday'] && !empty($event['end'])) { + if (!empty($event['allday']) && !empty($event['end'])) { $event['end'] = clone $event['end']; $event['end']->add(new \DateInterval('P1D')); $event['end']->_dateonly = true; } - if (!empty($event['created'])) + if (!empty($event['created'])) { $ve->add($this->datetime_prop($cal, 'CREATED', $event['created'], true)); - if (!empty($event['changed'])) + } + if (!empty($event['changed'])) { $ve->add($this->datetime_prop($cal, 'LAST-MODIFIED', $event['changed'], true)); - if (!empty($event['start'])) - $ve->add($this->datetime_prop($cal, 'DTSTART', $event['start'], false, (bool)$event['allday'])); - if (!empty($event['end'])) - $ve->add($this->datetime_prop($cal, 'DTEND', $event['end'], false, (bool)$event['allday'])); - if (!empty($event['due'])) - $ve->add($this->datetime_prop($cal, 'DUE', $event['due'], false)); + } + if (!empty($event['start'])) { + $ve->add($this->datetime_prop($cal, 'DTSTART', $event['start'], false, !empty($event['allday']))); + } + if (!empty($event['end'])) { + $ve->add($this->datetime_prop($cal, 'DTEND', $event['end'], false, !empty($event['allday']))); + } + if (!empty($event['due'])) { + $ve->add($this->datetime_prop($cal, 'DUE', $event['due'], false)); + } // we're exporting a recurrence instance only - if (!$recurrence_id && $event['recurrence_date'] && $event['recurrence_date'] instanceof DateTime) { - $recurrence_id = $this->datetime_prop($cal, 'RECURRENCE-ID', $event['recurrence_date'], false, (bool)$event['allday']); - if ($event['thisandfuture']) + if (!$recurrence_id && !empty($event['recurrence_date']) && $event['recurrence_date'] instanceof DateTime) { + $recurrence_id = $this->datetime_prop($cal, 'RECURRENCE-ID', $event['recurrence_date'], false, !empty($event['allday'])); + if (!empty($event['thisandfuture'])) { $recurrence_id->add('RANGE', 'THISANDFUTURE'); + } } if ($recurrence_id) { $ve->add($recurrence_id); } $ve->add('SUMMARY', $event['title']); - if ($event['location']) + if (!empty($event['location'])) { $ve->add($this->is_apple() ? new vobject_location_property($cal, 'LOCATION', $event['location']) : $cal->create('LOCATION', $event['location'])); - if ($event['description']) + } + if (!empty($event['description'])) { $ve->add('DESCRIPTION', strtr($event['description'], array("\r\n" => "\n", "\r" => "\n"))); // normalize line endings + } - if (isset($event['sequence'])) + if (isset($event['sequence'])) { $ve->add('SEQUENCE', $event['sequence']); + } - if ($event['recurrence'] && !$recurrence_id) { + if (!empty($event['recurrence']) && !$recurrence_id) { $exdates = $rdates = null; if (isset($event['recurrence']['EXDATE'])) { $exdates = $event['recurrence']['EXDATE']; unset($event['recurrence']['EXDATE']); // don't serialize EXDATEs into RRULE value } if (isset($event['recurrence']['RDATE'])) { $rdates = $event['recurrence']['RDATE']; unset($event['recurrence']['RDATE']); // don't serialize RDATEs into RRULE value } - if ($event['recurrence']['FREQ']) { - $ve->add('RRULE', libcalendaring::to_rrule($event['recurrence'], (bool)$event['allday'])); + if (!empty($event['recurrence']['FREQ'])) { + $ve->add('RRULE', libcalendaring::to_rrule($event['recurrence'], !empty($event['allday']))); } // add EXDATEs each one per line (for Thunderbird Lightning) if (is_array($exdates)) { foreach ($exdates as $exdate) { if ($exdate instanceof DateTime) { $ve->add($this->datetime_prop($cal, 'EXDATE', $exdate)); } } } // add RDATEs if (is_array($rdates)) { foreach ($rdates as $rdate) { $ve->add($this->datetime_prop($cal, 'RDATE', $rdate)); } } } - if ($event['categories']) { + if (!empty($event['categories'])) { $cat = $cal->create('CATEGORIES'); $cat->setParts((array)$event['categories']); $ve->add($cat); } if (!empty($event['free_busy'])) { $ve->add('TRANSP', $event['free_busy'] == 'free' ? 'TRANSPARENT' : 'OPAQUE'); // for Outlook clients we provide the X-MICROSOFT-CDO-BUSYSTATUS property if (stripos($this->agent, 'outlook') !== false) { $ve->add('X-MICROSOFT-CDO-BUSYSTATUS', $event['free_busy'] == 'outofoffice' ? 'OOF' : strtoupper($event['free_busy'])); } } - if ($event['priority']) - $ve->add('PRIORITY', $event['priority']); + if (!empty($event['priority'])) { + $ve->add('PRIORITY', $event['priority']); + } - if ($event['cancelled']) + if (!empty($event['cancelled'])) { $ve->add('STATUS', 'CANCELLED'); - else if ($event['free_busy'] == 'tentative') + } + else if (!empty($event['free_busy']) && $event['free_busy'] == 'tentative') { $ve->add('STATUS', 'TENTATIVE'); - else if ($event['complete'] == 100) + } + else if (!empty($event['complete']) && $event['complete'] == 100) { $ve->add('STATUS', 'COMPLETED'); - else if (!empty($event['status'])) + } + else if (!empty($event['status'])) { $ve->add('STATUS', $event['status']); + } if (!empty($event['sensitivity'])) $ve->add('CLASS', strtoupper($event['sensitivity'])); if (!empty($event['complete'])) { $ve->add('PERCENT-COMPLETE', intval($event['complete'])); } // Apple iCal and BusyCal required the COMPLETED date to be set in order to consider a task complete - if ($event['status'] == 'COMPLETED' || $event['complete'] == 100) { - $ve->add($this->datetime_prop($cal, 'COMPLETED', $event['changed'] ?: new DateTime('now - 1 hour'), true)); + if ( + (!empty($event['status']) && $event['status'] == 'COMPLETED') + || (!empty($event['complete']) && $event['complete'] == 100) + ) { + $completed = !empty($event['changed']) ? $event['changed'] : new DateTime('now - 1 hour'); + $ve->add($this->datetime_prop($cal, 'COMPLETED', $completed, true)); } - if ($event['valarms']) { + if (!empty($event['valarms'])) { foreach ($event['valarms'] as $alarm) { $va = $cal->createComponent('VALARM'); $va->action = $alarm['action']; if ($alarm['trigger'] instanceof DateTime) { $va->add($this->datetime_prop($cal, 'TRIGGER', $alarm['trigger'], true, null, true)); } else { $alarm_props = array(); - if (strtoupper($alarm['related']) == 'END') { + if (!empty($alarm['related']) && strtoupper($alarm['related']) == 'END') { $alarm_props['RELATED'] = 'END'; } $va->add('TRIGGER', $alarm['trigger'], $alarm_props); } - if ($alarm['action'] == 'EMAIL') { - foreach ((array)$alarm['attendees'] as $attendee) { - $va->add('ATTENDEE', 'mailto:' . $attendee); + if (!empty($alarm['action']) && $alarm['action'] == 'EMAIL') { + if (!empty($alarm['attendees'])) { + foreach ((array) $alarm['attendees'] as $attendee) { + $va->add('ATTENDEE', 'mailto:' . $attendee); + } } } - if ($alarm['description']) { - $va->add('DESCRIPTION', $alarm['description'] ?: $event['title']); + if (!empty($alarm['description'])) { + $va->add('DESCRIPTION', $alarm['description']); } - if ($alarm['summary']) { + if (!empty($alarm['summary'])) { $va->add('SUMMARY', $alarm['summary']); } - if ($alarm['duration']) { + if (!empty($alarm['duration'])) { $va->add('DURATION', $alarm['duration']); - $va->add('REPEAT', intval($alarm['repeat'])); + $va->add('REPEAT', !empty($alarm['repeat']) ? intval($alarm['repeat']) : 0); } - if ($alarm['uri']) { + if (!empty($alarm['uri'])) { $va->add('ATTACH', $alarm['uri'], array('VALUE' => 'URI')); } $ve->add($va); } } // legacy support - else if ($event['alarms']) { + else if (!empty($event['alarms'])) { $va = $cal->createComponent('VALARM'); list($trigger, $va->action) = explode(':', $event['alarms']); $val = libcalendaring::parse_alarm_value($trigger); - if ($val[3]) + if (!empty($val[3])) { $va->add('TRIGGER', $val[3]); - else if ($val[0] instanceof DateTime) + } + else if ($val[0] instanceof DateTime) { $va->add($this->datetime_prop($cal, 'TRIGGER', $val[0], true, null, true)); + } $ve->add($va); } // Find SCHEDULE-AGENT - foreach ((array)$event['x-custom'] as $prop) { - if ($prop[0] === 'SCHEDULE-AGENT') { - $schedule_agent = $prop[1]; + if (!empty($event['x-custom'])) { + foreach ((array) $event['x-custom'] as $prop) { + if ($prop[0] === 'SCHEDULE-AGENT') { + $schedule_agent = $prop[1]; + } } } - foreach ((array)$event['attendees'] as $attendee) { - if ($attendee['role'] == 'ORGANIZER') { - if (empty($event['organizer'])) - $event['organizer'] = $attendee; - } - else if (!empty($attendee['email'])) { - if (isset($attendee['rsvp'])) - $attendee['rsvp'] = $attendee['rsvp'] ? 'TRUE' : null; + if (!empty($event['attendees'])) { + foreach ((array) $event['attendees'] as $attendee) { + if ($attendee['role'] == 'ORGANIZER') { + if (empty($event['organizer'])) + $event['organizer'] = $attendee; + } + else if (!empty($attendee['email'])) { + if (isset($attendee['rsvp'])) { + $attendee['rsvp'] = $attendee['rsvp'] ? 'TRUE' : null; + } - $mailto = $attendee['email']; - $attendee = array_filter(self::map_keys($attendee, $this->attendee_keymap)); + $mailto = $attendee['email']; + $attendee = array_filter(self::map_keys($attendee, $this->attendee_keymap)); - if ($schedule_agent !== null && !isset($attendee['SCHEDULE-AGENT'])) { - $attendee['SCHEDULE-AGENT'] = $schedule_agent; - } + if (isset($schedule_agent) && !isset($attendee['SCHEDULE-AGENT'])) { + $attendee['SCHEDULE-AGENT'] = $schedule_agent; + } - $ve->add('ATTENDEE', 'mailto:' . $mailto, $attendee); + $ve->add('ATTENDEE', 'mailto:' . $mailto, $attendee); + } } } - if ($event['organizer']) { + if (!empty($event['organizer'])) { $organizer = array_filter(self::map_keys($event['organizer'], $this->organizer_keymap)); - if ($schedule_agent !== null && !isset($organizer['SCHEDULE-AGENT'])) { + if (isset($schedule_agent) && !isset($organizer['SCHEDULE-AGENT'])) { $organizer['SCHEDULE-AGENT'] = $schedule_agent; } $ve->add('ORGANIZER', 'mailto:' . $event['organizer']['email'], $organizer); } - foreach ((array)$event['url'] as $url) { - if (!empty($url)) { - $ve->add('URL', $url); + if (!empty($event['url'])) { + foreach ((array) $event['url'] as $url) { + if (!empty($url)) { + $ve->add('URL', $url); + } } } if (!empty($event['parent_id'])) { $ve->add('RELATED-TO', $event['parent_id'], array('RELTYPE' => 'PARENT')); } - if ($event['comment']) + if (!empty($event['comment'])) { $ve->add('COMMENT', $event['comment']); + } $memory_limit = parse_bytes(ini_get('memory_limit')); // export attachments if (!empty($event['attachments'])) { foreach ((array)$event['attachments'] as $attach) { // check available memory and skip attachment export if we can't buffer it // @todo: use rcube_utils::mem_check() if (is_callable($get_attachment) && $memory_limit > 0 && ($memory_used = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024) && $attach['size'] && $memory_used + $attach['size'] * 3 > $memory_limit) { continue; } // embed attachments using the given callback function if (is_callable($get_attachment) && ($data = call_user_func($get_attachment, $attach['id'], $event))) { // embed attachments for iCal $ve->add('ATTACH', $data, array_filter(array('VALUE' => 'BINARY', 'ENCODING' => 'BASE64', 'FMTTYPE' => $attach['mimetype'], 'X-LABEL' => $attach['name']))); unset($data); // attempt to free memory } // list attachments as absolute URIs else if (!empty($this->attach_uri)) { $ve->add('ATTACH', strtr($this->attach_uri, array( '{{id}}' => urlencode($attach['id']), '{{name}}' => urlencode($attach['name']), '{{mimetype}}' => urlencode($attach['mimetype']), )), array('FMTTYPE' => $attach['mimetype'], 'VALUE' => 'URI')); } } } - foreach ((array)$event['links'] as $uri) { - $ve->add('ATTACH', $uri); + if (!empty($event['links'])) { + foreach ((array) $event['links'] as $uri) { + $ve->add('ATTACH', $uri); + } } // add custom properties - foreach ((array)$event['x-custom'] as $prop) { - $ve->add($prop[0], $prop[1]); + if (!empty($event['x-custom'])) { + foreach ((array) $event['x-custom'] as $prop) { + $ve->add($prop[0], $prop[1]); + } } // append to vcalendar container if ($vcal) { $vcal->add($ve); } else { // serialize and send to stdout echo $ve->serialize(); } // append recurrence exceptions - if (is_array($event['recurrence']) && $event['recurrence']['EXCEPTIONS']) { + if (!empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS'])) { foreach ($event['recurrence']['EXCEPTIONS'] as $ex) { - $exdate = $ex['recurrence_date'] ?: $ex['start']; - $recurrence_id = $this->datetime_prop($cal, 'RECURRENCE-ID', $exdate, false, (bool)$event['allday']); - if ($ex['thisandfuture']) + $exdate = !empty($ex['recurrence_date']) ? $ex['recurrence_date'] : $ex['start']; + $recurrence_id = $this->datetime_prop($cal, 'RECURRENCE-ID', $exdate, false, !empty($event['allday'])); + if (!empty($ex['thisandfuture'])) { $recurrence_id->add('RANGE', 'THISANDFUTURE'); + } $this->_to_ical($ex, $vcal, $get_attachment, $recurrence_id); } } } /** * Returns a VTIMEZONE component for a Olson timezone identifier * with daylight transitions covering the given date range. * * @param string Timezone ID as used in PHP's Date functions * @param integer Unix timestamp with first date/time in this timezone * @param integer Unix timestap with last date/time in this timezone * @param VObject\Component\VCalendar Optional VCalendar component * * @return mixed A Sabre\VObject\Component object representing a VTIMEZONE definition * or false if no timezone information is available */ public static function get_vtimezone($tzid, $from = 0, $to = 0, $cal = null) { // TODO: Consider using tzurl.org database for better interoperability e.g. with Outlook if (!$from) $from = time(); if (!$to) $to = $from; if (!$cal) $cal = new VObject\Component\VCalendar(); if (is_string($tzid)) { try { $tz = new \DateTimeZone($tzid); } catch (\Exception $e) { return false; } } else if (is_a($tzid, '\\DateTimeZone')) { $tz = $tzid; } - if (!is_a($tz, '\\DateTimeZone')) { + if (empty($tz) || !is_a($tz, '\\DateTimeZone')) { return false; } $year = 86400 * 360; $transitions = $tz->getTransitions($from - $year, $to + $year); // Make sure VTIMEZONE contains at least one STANDARD/DAYLIGHT component // when there's only one transition in specified time period (T5626) if (count($transitions) == 1) { // Get more transitions and use OFFSET from the previous to last $more_transitions = $tz->getTransitions(0, $to + $year); if (count($more_transitions) > 1) { $index = count($more_transitions) - 2; $tzfrom = $more_transitions[$index]['offset'] / 3600; } } $vt = $cal->createComponent('VTIMEZONE'); $vt->TZID = $tz->getName(); $std = null; $dst = null; foreach ($transitions as $i => $trans) { $cmp = null; if (!isset($tzfrom)) { $tzfrom = $trans['offset'] / 3600; continue; } if ($trans['isdst']) { $t_dst = $trans['ts']; $dst = $cal->createComponent('DAYLIGHT'); $cmp = $dst; } else { $t_std = $trans['ts']; $std = $cal->createComponent('STANDARD'); $cmp = $std; } if ($cmp) { $dt = new DateTime($trans['time']); $offset = $trans['offset'] / 3600; $cmp->DTSTART = $dt->format('Ymd\THis'); $cmp->TZOFFSETFROM = sprintf('%+03d%02d', floor($tzfrom), ($tzfrom - floor($tzfrom)) * 60); $cmp->TZOFFSETTO = sprintf('%+03d%02d', floor($offset), ($offset - floor($offset)) * 60); if (!empty($trans['abbr'])) { $cmp->TZNAME = $trans['abbr']; } $tzfrom = $offset; $vt->add($cmp); } // we covered the entire date range if ($std && $dst && min($t_std, $t_dst) < $from && max($t_std, $t_dst) > $to) { break; } } // add X-MICROSOFT-CDO-TZID if available $microsoftExchangeMap = array_flip(VObject\TimeZoneUtil::$microsoftExchangeMap); if (array_key_exists($tz->getName(), $microsoftExchangeMap)) { $vt->add('X-MICROSOFT-CDO-TZID', $microsoftExchangeMap[$tz->getName()]); } return $vt; } /*** Implement PHP 5 Iterator interface to make foreach work ***/ function current() { return $this->objects[$this->iteratorkey]; } function key() { return $this->iteratorkey; } function next() { $this->iteratorkey++; // read next chunk if we're reading from a file - if (!$this->objects[$this->iteratorkey] && $this->fp) { + if (empty($this->objects[$this->iteratorkey]) && $this->fp) { $this->_parse_next(true); } return $this->valid(); } function rewind() { $this->iteratorkey = 0; } function valid() { return !empty($this->objects[$this->iteratorkey]); } } /** * Override Sabre\VObject\Property\Text that quotes commas in the location property * because Apple clients treat that property as list. */ class vobject_location_property extends VObject\Property\Text { /** * List of properties that are considered 'structured'. * * @var array */ protected $structuredValues = array( // vCard 'N', 'ADR', 'ORG', 'GENDER', 'LOCATION', // iCalendar 'REQUEST-STATUS', ); } diff --git a/plugins/libcalendaring/tests/libcalendaring.php b/plugins/libcalendaring/tests/libcalendaring.php index 311d25e2..e93e0b32 100644 --- a/plugins/libcalendaring/tests/libcalendaring.php +++ b/plugins/libcalendaring/tests/libcalendaring.php @@ -1,184 +1,184 @@ * * Copyright (C) 2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -class libcalendaring_test extends PHPUnit_Framework_TestCase +class libcalendaring_test extends PHPUnit\Framework\TestCase { function setUp() { require_once __DIR__ . '/../libcalendaring.php'; } /** * libcalendaring::parse_alarm_value() */ function test_parse_alarm_value() { $alarm = libcalendaring::parse_alarm_value('-15M'); $this->assertEquals('15', $alarm[0]); $this->assertEquals('-M', $alarm[1]); $this->assertEquals('-PT15M', $alarm[3]); $alarm = libcalendaring::parse_alarm_value('-PT5H'); $this->assertEquals('5', $alarm[0]); $this->assertEquals('-H', $alarm[1]); $alarm = libcalendaring::parse_alarm_value('P0DT1H0M0S'); $this->assertEquals('1', $alarm[0]); $this->assertEquals('+H', $alarm[1]); // FIXME: this should return something like (1140 + 120 + 30)M $alarm = libcalendaring::parse_alarm_value('-P1DT2H30M'); // $this->assertEquals('1590', $alarm[0]); // $this->assertEquals('-M', $alarm[1]); $alarm = libcalendaring::parse_alarm_value('@1420722000'); $this->assertInstanceOf('DateTime', $alarm[0]); } /** * libcalendaring::get_next_alarm() */ function test_get_next_alarm() { // alarm 10 minutes before event $date = date('Ymd', strtotime('today + 2 days')); $event = array( 'start' => new DateTime($date . 'T160000Z'), 'end' => new DateTime($date . 'T200000Z'), 'valarms' => array( array( 'trigger' => '-PT10M', 'action' => 'DISPLAY', ), ), ); $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals($event['valarms'][0]['action'], $alarm['action']); $this->assertEquals(strtotime($date . 'T155000Z'), $alarm['time']); // alarm 1 hour after event start $event['valarms'] = array( array( 'trigger' => '+PT1H', ), ); $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals('DISPLAY', $alarm['action']); $this->assertEquals(strtotime($date . 'T170000Z'), $alarm['time']); // alarm 1 hour before event end $event['valarms'] = array( array( 'trigger' => '-PT1H', 'related' => 'END', ), ); $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals('DISPLAY', $alarm['action']); $this->assertEquals(strtotime($date . 'T190000Z'), $alarm['time']); // alarm 1 hour after event end $event['valarms'] = array( array( 'trigger' => 'PT1H', 'related' => 'END', ), ); $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals('DISPLAY', $alarm['action']); $this->assertEquals(strtotime($date . 'T210000Z'), $alarm['time']); // ignore past alarms $event['start'] = new DateTime('today 22:00:00'); $event['end'] = new DateTime('today 23:00:00'); $event['valarms'] = array( array( 'trigger' => '-P2D', 'action' => 'EMAIL', ), array( 'trigger' => '-PT30M', 'action' => 'DISPLAY', ), ); $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals('DISPLAY', $alarm['action']); $this->assertEquals(strtotime('today 21:30:00'), $alarm['time']); // absolute alarm date/time $event['valarms'] = array( array('trigger' => new DateTime('today 20:00:00')) ); $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals($event['valarms'][0]['trigger']->format('U'), $alarm['time']); // no alarms for cancelled events $event['status'] = 'CANCELLED'; $alarm = libcalendaring::get_next_alarm($event); $this->assertEquals(null, $alarm); } /** * libcalendaring::part_is_vcalendar() */ function test_part_is_vcalendar() { $part = new StdClass; $part->mimetype = 'text/plain'; $part->filename = 'event.ics'; $this->assertFalse(libcalendaring::part_is_vcalendar($part)); $part->mimetype = 'text/calendar'; $this->assertTrue(libcalendaring::part_is_vcalendar($part)); $part->mimetype = 'text/x-vcalendar'; $this->assertTrue(libcalendaring::part_is_vcalendar($part)); $part->mimetype = 'application/ics'; $this->assertTrue(libcalendaring::part_is_vcalendar($part)); $part->mimetype = 'application/x-any'; $this->assertTrue(libcalendaring::part_is_vcalendar($part)); } /** * libcalendaring::to_rrule() */ function test_to_rrule() { $rrule = array( 'FREQ' => 'MONTHLY', 'BYDAY' => '2WE', 'INTERVAL' => 2, 'UNTIL' => new DateTime('2025-05-01 18:00:00 CEST'), ); $s = libcalendaring::to_rrule($rrule); $this->assertRegExp('/FREQ='.$rrule['FREQ'].'/', $s, "Recurrence Frequence"); $this->assertRegExp('/INTERVAL='.$rrule['INTERVAL'].'/', $s, "Recurrence Interval"); $this->assertRegExp('/BYDAY='.$rrule['BYDAY'].'/', $s, "Recurrence BYDAY"); $this->assertRegExp('/UNTIL=20250501T160000Z/', $s, "Recurrence End date (in UTC)"); } } diff --git a/plugins/libcalendaring/tests/libvcalendar.php b/plugins/libcalendaring/tests/libvcalendar.php index 29f73520..5e2503ed 100644 --- a/plugins/libcalendaring/tests/libvcalendar.php +++ b/plugins/libcalendaring/tests/libvcalendar.php @@ -1,609 +1,610 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -class libvcalendar_test extends PHPUnit_Framework_TestCase +class libvcalendar_test extends PHPUnit\Framework\TestCase { function setUp() { require_once __DIR__ . '/../libvcalendar.php'; require_once __DIR__ . '/../libcalendaring.php'; } /** * Simple iCal parsing test */ function test_import() { $ical = new libvcalendar(); $ics = file_get_contents(__DIR__ . '/resources/snd.ics'); $events = $ical->import($ics, 'UTF-8'); $this->assertEquals(1, count($events)); $event = $events[0]; $this->assertInstanceOf('DateTime', $event['created'], "'created' property is DateTime object"); $this->assertInstanceOf('DateTime', $event['changed'], "'changed' property is DateTime object"); $this->assertEquals('UTC', $event['created']->getTimezone()->getName(), "'created' date is in UTC"); $this->assertInstanceOf('DateTime', $event['start'], "'start' property is DateTime object"); $this->assertInstanceOf('DateTime', $event['end'], "'end' property is DateTime object"); $this->assertEquals('08-01', $event['start']->format('m-d'), "Start date is August 1st"); $this->assertTrue($event['allday'], "All-day event flag"); $this->assertEquals('B968B885-08FB-40E5-B89E-6DA05F26AA79', $event['uid'], "Event UID"); $this->assertEquals('Swiss National Day', $event['title'], "Event title"); $this->assertEquals('http://en.wikipedia.org/wiki/Swiss_National_Day', $event['url'], "URL property"); $this->assertEquals(2, $event['sequence'], "Sequence number"); $desclines = explode("\n", $event['description']); $this->assertEquals(4, count($desclines), "Multiline description"); $this->assertEquals("French: Fête nationale Suisse", rtrim($desclines[1]), "UTF-8 encoding"); } /** * Test parsing from files */ function test_import_from_file() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple.ics', 'UTF-8'); $this->assertEquals(2, count($events)); $events = $ical->import_from_file(__DIR__ . '/resources/invalid.txt', 'UTF-8'); $this->assertEmpty($events); } /** * Test parsing from files with multiple VCALENDAR blocks (#2884) */ function test_import_from_file_multiple() { $ical = new libvcalendar(); $ical->fopen(__DIR__ . '/resources/multiple-rdate.ics', 'UTF-8'); $events = array(); foreach ($ical as $event) { $events[] = $event; } $this->assertEquals(2, count($events)); $this->assertEquals("AAAA6A8C3CCE4EE2C1257B5C00FFFFFF-Lotus_Notes_Generated", $events[0]['uid']); $this->assertEquals("AAAA1C572093EC3FC125799C004AFFFF-Lotus_Notes_Generated", $events[1]['uid']); } function test_invalid_dates() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/invalid-dates.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals(1, count($events), "Import event data"); $this->assertInstanceOf('DateTime', $event['created'], "Created date field"); $this->assertFalse(array_key_exists('changed', $event), "No changed date field"); } /** * Test some extended ical properties such as attendees, recurrence rules, alarms and attachments */ function test_extended() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/itip.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals('REQUEST', $ical->method, "iTip method"); // attendees $this->assertEquals(3, count($event['attendees']), "Attendees list (including organizer)"); $organizer = $event['attendees'][0]; $this->assertEquals('ORGANIZER', $organizer['role'], 'Organizer ROLE'); $this->assertEquals('Rolf Test', $organizer['name'], 'Organizer name'); $attendee = $event['attendees'][1]; $this->assertEquals('REQ-PARTICIPANT', $attendee['role'], 'Attendee ROLE'); $this->assertEquals('NEEDS-ACTION', $attendee['status'], 'Attendee STATUS'); $this->assertEquals('rolf2@mykolab.com', $attendee['email'], 'Attendee mailto:'); $this->assertEquals('carl@mykolab.com', $attendee['delegated-from'], 'Attendee delegated-from'); $this->assertTrue($attendee['rsvp'], 'Attendee RSVP'); $delegator = $event['attendees'][2]; $this->assertEquals('NON-PARTICIPANT', $delegator['role'], 'Delegator ROLE'); $this->assertEquals('DELEGATED', $delegator['status'], 'Delegator STATUS'); $this->assertEquals('INDIVIDUAL', $delegator['cutype'], 'Delegator CUTYPE'); $this->assertEquals('carl@mykolab.com', $delegator['email'], 'Delegator mailto:'); $this->assertEquals('rolf2@mykolab.com', $delegator['delegated-to'], 'Delegator delegated-to'); $this->assertFalse($delegator['rsvp'], 'Delegator RSVP'); // attachments $this->assertEquals(1, count($event['attachments']), "Embedded attachments"); $attachment = $event['attachments'][0]; $this->assertEquals('text/html', $attachment['mimetype'], "Attachment mimetype attribute"); $this->assertEquals('calendar.html', $attachment['name'], "Attachment filename (X-LABEL) attribute"); $this->assertContains('Kalender', $attachment['data'], "Attachment content (decoded)"); // recurrence rules $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); $event = $events[0]; $this->assertTrue(is_array($event['recurrence']), 'Recurrences rule as hash array'); $rrule = $event['recurrence']; $this->assertEquals('MONTHLY', $rrule['FREQ'], "Recurrence frequency"); $this->assertEquals('1', $rrule['INTERVAL'], "Recurrence interval"); $this->assertEquals('3WE', $rrule['BYDAY'], "Recurrence frequency"); $this->assertInstanceOf('DateTime', $rrule['UNTIL'], "Recurrence end date"); $this->assertEquals(2, count($rrule['EXDATE']), "Recurrence EXDATEs"); $this->assertInstanceOf('DateTime', $rrule['EXDATE'][0], "Recurrence EXDATE as DateTime"); $this->assertTrue(is_array($rrule['EXCEPTIONS'])); $this->assertEquals(1, count($rrule['EXCEPTIONS']), "Recurrence Exceptions"); $exception = $rrule['EXCEPTIONS'][0]; $this->assertEquals($event['uid'], $event['uid'], "Exception UID"); $this->assertEquals('Recurring Test (Exception)', $exception['title'], "Exception title"); $this->assertInstanceOf('DateTime', $exception['start'], "Exception start"); // categories, class $this->assertEquals('libcalendaring tests', join(',', (array)$event['categories']), "Event categories"); $this->assertEquals('confidential', $event['sensitivity'], "Class/sensitivity = confidential"); // parse a recurrence chain instance $events = $ical->import_from_file(__DIR__ . '/resources/recurrence-id.ics', 'UTF-8'); $this->assertEquals(1, count($events), "Fall back to Component::getComponents() when getBaseComponents() is empty"); $this->assertInstanceOf('DateTime', $events[0]['recurrence_date'], "Recurrence-ID as date"); $this->assertTrue($events[0]['thisandfuture'], "Range=THISANDFUTURE"); $this->assertEquals(count($events[0]['exceptions']), 1, "Second VEVENT as exception"); $this->assertEquals($events[0]['exceptions'][0]['uid'], $events[0]['uid'], "Exception UID match"); $this->assertEquals($events[0]['exceptions'][0]['sequence'], '2', "Exception sequence"); } /** * */ function test_alarms() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals('-12H:DISPLAY', $event['alarms'], "Serialized alarms string"); $alarm = libcalendaring::parse_alarm_value($event['alarms']); $this->assertEquals('12', $alarm[0], "Alarm value"); $this->assertEquals('-H', $alarm[1], "Alarm unit"); $this->assertEquals('DISPLAY', $event['valarms'][0]['action'], "Full alarm item (action)"); $this->assertEquals('-PT12H', $event['valarms'][0]['trigger'], "Full alarm item (trigger)"); $this->assertEquals('END', $event['valarms'][0]['related'], "Full alarm item (related)"); // alarm trigger with 0 values $events = $ical->import_from_file(__DIR__ . '/resources/alarms.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals('-30M:DISPLAY', $event['alarms'], "Stripped alarm string"); $alarm = libcalendaring::parse_alarm_value($event['alarms']); $this->assertEquals('30', $alarm[0], "Alarm value"); $this->assertEquals('-M', $alarm[1], "Alarm unit"); $this->assertEquals('-30M', $alarm[2], "Alarm string"); $this->assertEquals('-PT30M', $alarm[3], "Unified alarm string (stripped zero-values)"); $this->assertEquals('DISPLAY', $event['valarms'][0]['action'], "First alarm action"); - $this->assertEquals('', $event['valarms'][0]['related'], "First alarm related property"); + $this->assertTrue(empty($event['valarms'][0]['related']), "First alarm related property"); $this->assertEquals('This is the first event reminder', $event['valarms'][0]['description'], "First alarm text"); $this->assertEquals(3, count($event['valarms']), "List all VALARM blocks"); $valarm = $event['valarms'][1]; $this->assertEquals(1, count($valarm['attendees']), "Email alarm attendees"); $this->assertEquals('EMAIL', $valarm['action'], "Second alarm item (action)"); $this->assertEquals('-P1D', $valarm['trigger'], "Second alarm item (trigger)"); $this->assertEquals('This is the reminder message', $valarm['summary'], "Email alarm text"); $this->assertInstanceOf('DateTime', $event['valarms'][2]['trigger'], "Absolute trigger date/time"); // test alarms export $ics = $ical->export(array($event)); $this->assertContains('ACTION:DISPLAY', $ics, "Display alarm block"); $this->assertContains('ACTION:EMAIL', $ics, "Email alarm block"); $this->assertContains('DESCRIPTION:This is the first event reminder', $ics, "Alarm description"); $this->assertContains('SUMMARY:This is the reminder message', $ics, "Email alarm summary"); $this->assertContains('ATTENDEE:mailto:reminder-recipient@example.org', $ics, "Email alarm recipient"); $this->assertContains('TRIGGER;VALUE=DATE-TIME:20130812', $ics, "Date-Time trigger"); } /** * @depends test_import_from_file */ function test_attachment() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/attachment.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals(2, count($events)); $this->assertEquals(1, count($event['attachments'])); $this->assertEquals('image/png', $event['attachments'][0]['mimetype']); $this->assertEquals('500px-Opensource.svg.png', $event['attachments'][0]['name']); } /** * @depends test_import */ function test_apple_alarms() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/apple-alarms.ics', 'UTF-8'); $event = $events[0]; // alarms $this->assertEquals('-45M:AUDIO', $event['alarms'], "Relative alarm string"); $alarm = libcalendaring::parse_alarm_value($event['alarms']); $this->assertEquals('45', $alarm[0], "Alarm value"); $this->assertEquals('-M', $alarm[1], "Alarm unit"); $this->assertEquals(1, count($event['valarms']), "Ignore invalid alarm blocks"); $this->assertEquals('AUDIO', $event['valarms'][0]['action'], "Full alarm item (action)"); $this->assertEquals('-PT45M', $event['valarms'][0]['trigger'], "Full alarm item (trigger)"); $this->assertEquals('Basso', $event['valarms'][0]['uri'], "Full alarm item (attachment)"); } /** * */ function test_escaped_values() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/escaped.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals("House, Street, Zip Place", $event['location'], "Decode escaped commas in location value"); $this->assertEquals("Me, meets Them\nThem, meet Me", $event['description'], "Decode description value"); $this->assertEquals("Kolab, Thomas", $event['attendees'][3]['name'], "Unescaped"); $ics = $ical->export($events); $this->assertContains('ATTENDEE;CN="Kolab, Thomas";PARTSTAT=', $ics, "Quoted attendee parameters"); } /** * Parse RDATE properties (#2885) */ function test_rdate() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple-rdate.ics', 'UTF-8'); $event = $events[0]; $this->assertEquals(9, count($event['recurrence']['RDATE'])); $this->assertInstanceOf('DateTime', $event['recurrence']['RDATE'][0]); $this->assertInstanceOf('DateTime', $event['recurrence']['RDATE'][1]); } /** * @depends test_import */ function test_freebusy() { $ical = new libvcalendar(); $ical->import_from_file(__DIR__ . '/resources/freebusy.ifb', 'UTF-8'); $freebusy = $ical->freebusy; $this->assertInstanceOf('DateTime', $freebusy['start'], "'start' property is DateTime object"); $this->assertInstanceOf('DateTime', $freebusy['end'], "'end' property is DateTime object"); $this->assertEquals(11, count($freebusy['periods']), "Number of freebusy periods defined"); $periods = $ical->get_busy_periods(); $this->assertEquals(9, count($periods), "Number of busy periods found"); $this->assertEquals('BUSY-TENTATIVE', $periods[8][2], "FBTYPE=BUSY-TENTATIVE"); } /** * @depends test_import */ function test_freebusy_dummy() { $ical = new libvcalendar(); $ical->import_from_file(__DIR__ . '/resources/dummy.ifb', 'UTF-8'); $freebusy = $ical->freebusy; $this->assertEquals(0, count($freebusy['periods']), "Ignore 0-length freebudy periods"); $this->assertContains('dummy', $freebusy['comment'], "Parse comment"); } function test_vtodo() { $ical = new libvcalendar(); $tasks = $ical->import_from_file(__DIR__ . '/resources/vtodo.ics', 'UTF-8', true); $task = $tasks[0]; $this->assertInstanceOf('DateTime', $task['start'], "'start' property is DateTime object"); $this->assertInstanceOf('DateTime', $task['due'], "'due' property is DateTime object"); $this->assertEquals('-1D:DISPLAY', $task['alarms'], "Taks alarm value"); $this->assertEquals('IN-PROCESS', $task['status'], "Task status property"); $this->assertEquals(1, count($task['x-custom']), "Custom properties"); $this->assertEquals(4, count($task['categories'])); $this->assertEquals('1234567890-12345678-PARENT', $task['parent_id'], "Parent Relation"); $completed = $tasks[1]; $this->assertEquals('COMPLETED', $completed['status'], "Task status=completed when COMPLETED property is present"); $this->assertEquals(100, $completed['complete'], "Task percent complete value"); $ics = $ical->export(array($completed)); $this->assertRegExp('/COMPLETED(;VALUE=DATE-TIME)?:[0-9TZ]+/', $ics, "Export COMPLETED property"); } /** * Test for iCal export from internal hash array representation * * */ function test_export() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/itip.ics', 'UTF-8'); $event = $events[0]; $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); $event += $events[0]; $this->attachment_data = $event['attachments'][0]['data']; unset($event['attachments'][0]['data']); $event['attachments'][0]['id'] = '1'; $event['description'] = '*Exported by libvcalendar*'; $event['start']->setTimezone(new DateTimezone('America/Montreal')); $event['end']->setTimezone(new DateTimezone('Europe/Berlin')); $ics = $ical->export(array($event), 'REQUEST', false, array($this, 'get_attachment_data'), true); $this->assertContains('BEGIN:VCALENDAR', $ics, "VCALENDAR encapsulation BEGIN"); $this->assertContains('BEGIN:VTIMEZONE', $ics, "VTIMEZONE encapsulation BEGIN"); $this->assertContains('TZID:Europe/Berlin', $ics, "Timezone ID"); $this->assertContains('TZOFFSETFROM:+0100', $ics, "Timzone transition FROM"); $this->assertContains('TZOFFSETTO:+0200', $ics, "Timzone transition TO"); $this->assertContains('TZOFFSETFROM:-0400', $ics, "TZOFFSETFROM with negative offset (Bug T428)"); $this->assertContains('TZOFFSETTO:-0500', $ics, "TZOFFSETTO with negative offset (Bug T428)"); $this->assertContains('END:VTIMEZONE', $ics, "VTIMEZONE encapsulation END"); $this->assertContains('BEGIN:VEVENT', $ics, "VEVENT encapsulation BEGIN"); $this->assertSame(2, substr_count($ics, 'DTSTAMP'), "Duplicate DTSTAMP (T1148)"); $this->assertContains('UID:ac6b0aee-2519-4e5c-9a25-48c57064c9f0', $ics, "Event UID"); $this->assertContains('SEQUENCE:' . $event['sequence'], $ics, "Export Sequence number"); $this->assertContains('CLASS:CONFIDENTIAL', $ics, "Sensitivity => Class"); $this->assertContains('DESCRIPTION:*Exported by', $ics, "Export Description"); $this->assertContains('ORGANIZER;CN=Rolf Test:mailto:rolf@', $ics, "Export organizer"); $this->assertRegExp('/ATTENDEE.*;ROLE=REQ-PARTICIPANT/', $ics, "Export Attendee ROLE"); $this->assertRegExp('/ATTENDEE.*;PARTSTAT=NEEDS-ACTION/', $ics, "Export Attendee Status"); $this->assertRegExp('/ATTENDEE.*;RSVP=TRUE/', $ics, "Export Attendee RSVP"); $this->assertRegExp('/:mailto:rolf2@/', $ics, "Export Attendee mailto:"); $rrule = $event['recurrence']; $this->assertRegExp('/RRULE:.*FREQ='.$rrule['FREQ'].'/', $ics, "Export Recurrence Frequence"); $this->assertRegExp('/RRULE:.*INTERVAL='.$rrule['INTERVAL'].'/', $ics, "Export Recurrence Interval"); $this->assertRegExp('/RRULE:.*UNTIL=20140718T215959Z/', $ics, "Export Recurrence End date"); $this->assertRegExp('/RRULE:.*BYDAY='.$rrule['BYDAY'].'/', $ics, "Export Recurrence BYDAY"); $this->assertRegExp('/EXDATE.*:20131218/', $ics, "Export Recurrence EXDATE"); $this->assertContains('BEGIN:VALARM', $ics, "Export VALARM"); $this->assertContains('TRIGGER;RELATED=END:-PT12H', $ics, "Export Alarm trigger"); $this->assertRegExp('/ATTACH.*;VALUE=BINARY/', $ics, "Embed attachment"); $this->assertRegExp('/ATTACH.*;ENCODING=BASE64/', $ics, "Attachment B64 encoding"); $this->assertRegExp('!ATTACH.*;FMTTYPE=text/html!', $ics, "Attachment mimetype"); $this->assertRegExp('!ATTACH.*;X-LABEL=calendar.html!', $ics, "Attachment filename with X-LABEL"); $this->assertContains('END:VEVENT', $ics, "VEVENT encapsulation END"); $this->assertContains('END:VCALENDAR', $ics, "VCALENDAR encapsulation END"); } /** * @depends test_extended * @depends test_export */ function test_export_multiple() { $ical = new libvcalendar(); $events = array_merge( $ical->import_from_file(__DIR__ . '/resources/snd.ics', 'UTF-8'), $ical->import_from_file(__DIR__ . '/resources/multiple.ics', 'UTF-8') ); $num = count($events); $ics = $ical->export($events, null, false); $this->assertContains('BEGIN:VCALENDAR', $ics, "VCALENDAR encapsulation BEGIN"); $this->assertContains('END:VCALENDAR', $ics, "VCALENDAR encapsulation END"); $this->assertEquals($num, substr_count($ics, 'BEGIN:VEVENT'), "VEVENT encapsulation BEGIN"); $this->assertEquals($num, substr_count($ics, 'END:VEVENT'), "VEVENT encapsulation END"); } /** * @depends test_export */ function test_export_recurrence_exceptions() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/recurring.ics', 'UTF-8'); // add exceptions $event = $events[0]; unset($event['recurrence']['EXCEPTIONS']); $exception1 = $event; $exception1['start'] = clone $event['start']; $exception1['start']->setDate(2013, 8, 14); $exception1['end'] = clone $event['end']; $exception1['end']->setDate(2013, 8, 14); $exception2 = $event; $exception2['start'] = clone $event['start']; $exception2['start']->setDate(2013, 11, 13); $exception2['end'] = clone $event['end']; $exception2['end']->setDate(2013, 11, 13); $exception2['title'] = 'Recurring Exception'; $events[0]['recurrence']['EXCEPTIONS'] = array($exception1, $exception2); $ics = $ical->export($events, null, false); $num = count($events[0]['recurrence']['EXCEPTIONS']) + 1; $this->assertEquals($num, substr_count($ics, 'BEGIN:VEVENT'), "VEVENT encapsulation BEGIN"); $this->assertEquals($num, substr_count($ics, 'UID:'.$event['uid']), "Recurrence Exceptions with same UID"); $this->assertEquals($num, substr_count($ics, 'END:VEVENT'), "VEVENT encapsulation END"); $this->assertContains('RECURRENCE-ID;TZID=Europe/Zurich:20130814', $ics, "Recurrence-ID (1) being the exception date"); $this->assertContains('RECURRENCE-ID;TZID=Europe/Zurich:20131113', $ics, "Recurrence-ID (2) being the exception date"); $this->assertContains('SUMMARY:'.$exception2['title'], $ics, "Exception title"); } function test_export_valid_rrules() { $event = array( 'uid' => '1234567890', 'start' => new DateTime('now'), 'end' => new DateTime('now + 30min'), 'title' => 'test_export_valid_rrules', 'recurrence' => array( 'FREQ' => 'DAILY', 'COUNT' => 5, 'EXDATE' => array(), 'RDATE' => array(), ), ); $ical = new libvcalendar(); $ics = $ical->export(array($event), null, false, null, false); $this->assertNotContains('EXDATE=', $ics); $this->assertNotContains('RDATE=', $ics); } /** * */ function test_export_rdate() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple-rdate.ics', 'UTF-8'); $ics = $ical->export($events, null, false); $this->assertContains('RDATE:20140520T020000Z', $ics, "VALUE=PERIOD is translated into single DATE-TIME values"); } /** * @depends test_export */ function test_export_direct() { $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/multiple.ics', 'UTF-8'); $num = count($events); ob_start(); $return = $ical->export($events, null, true); $output = ob_get_contents(); ob_end_clean(); $this->assertTrue($return, "Return true on successful writing"); $this->assertContains('BEGIN:VCALENDAR', $output, "VCALENDAR encapsulation BEGIN"); $this->assertContains('END:VCALENDAR', $output, "VCALENDAR encapsulation END"); $this->assertEquals($num, substr_count($output, 'BEGIN:VEVENT'), "VEVENT encapsulation BEGIN"); $this->assertEquals($num, substr_count($output, 'END:VEVENT'), "VEVENT encapsulation END"); } function test_datetime() { $ical = new libvcalendar(); $cal = new \Sabre\VObject\Component\VCalendar(); $localtime = $ical->datetime_prop($cal, 'DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('Europe/Berlin'))); $localdate = $ical->datetime_prop($cal, 'DTSTART', new DateTime('2013-09-01', new DateTimeZone('Europe/Berlin')), false, true); $utctime = $ical->datetime_prop($cal, 'DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('UTC'))); $asutctime = $ical->datetime_prop($cal, 'DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('Europe/Berlin')), true); $this->assertContains('TZID=Europe/Berlin', $localtime->serialize()); $this->assertContains('VALUE=DATE', $localdate->serialize()); $this->assertContains('20130901T120000Z', $utctime->serialize()); $this->assertContains('20130901T100000Z', $asutctime->serialize()); } function test_get_vtimezone() { $vtz = libvcalendar::get_vtimezone('Europe/Berlin', strtotime('2014-08-22T15:00:00+02:00')); $this->assertInstanceOf('\Sabre\VObject\Component', $vtz, "VTIMEZONE is a Component object"); $this->assertEquals('Europe/Berlin', $vtz->TZID); $this->assertEquals('4', $vtz->{'X-MICROSOFT-CDO-TZID'}); // check for transition to daylight saving time which is BEFORE the given date - $dst = reset($vtz->select('DAYLIGHT')); + $dst = array_first($vtz->select('DAYLIGHT')); $this->assertEquals('DAYLIGHT', $dst->name); $this->assertEquals('20140330T010000', $dst->DTSTART); $this->assertEquals('+0100', $dst->TZOFFSETFROM); $this->assertEquals('+0200', $dst->TZOFFSETTO); $this->assertEquals('CEST', $dst->TZNAME); // check (last) transition to standard time which is AFTER the given date - $std = end($vtz->select('STANDARD')); + $std = $vtz->select('STANDARD'); + $std = end($std); $this->assertEquals('STANDARD', $std->name); $this->assertEquals('20141026T010000', $std->DTSTART); $this->assertEquals('+0200', $std->TZOFFSETFROM); $this->assertEquals('+0100', $std->TZOFFSETTO); $this->assertEquals('CET', $std->TZNAME); // unknown timezone $vtz = libvcalendar::get_vtimezone('America/Foo Bar'); $this->assertEquals(false, $vtz); // invalid input data $vtz = libvcalendar::get_vtimezone(new DateTime()); $this->assertEquals(false, $vtz); // DateTimezone as input data $vtz = libvcalendar::get_vtimezone(new DateTimezone('Pacific/Chatham')); $this->assertInstanceOf('\Sabre\VObject\Component', $vtz); $this->assertContains('TZOFFSETFROM:+1245', $vtz->serialize()); $this->assertContains('TZOFFSETTO:+1345', $vtz->serialize()); // Making sure VTIMEZOONE contains at least one STANDARD/DAYLIGHT component // when there's only one transition in specified time period (T5626) $vtz = libvcalendar::get_vtimezone('Europe/Istanbul', strtotime('2019-10-04T15:00:00')); $this->assertInstanceOf('\Sabre\VObject\Component', $vtz); $dst = $vtz->select('DAYLIGHT'); $std = $vtz->select('STANDARD'); $this->assertEmpty($dst); $this->assertCount(1, $std); $std = end($std); $this->assertEquals('STANDARD', $std->name); $this->assertEquals('20181009T150000', $std->DTSTART); $this->assertEquals('+0300', $std->TZOFFSETFROM); $this->assertEquals('+0300', $std->TZOFFSETTO); $this->assertEquals('+03', $std->TZNAME); } function get_attachment_data($id, $event) { return $this->attachment_data; } } diff --git a/plugins/libkolab/libkolab.php b/plugins/libkolab/libkolab.php index 8ad0838b..db120aa2 100644 --- a/plugins/libkolab/libkolab.php +++ b/plugins/libkolab/libkolab.php @@ -1,378 +1,378 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libkolab extends rcube_plugin { static $http_requests = array(); static $bonnie_api = false; /** * Required startup method of a Roundcube plugin */ public function init() { // load local config $this->load_config(); // extend include path to load bundled lib classes $include_path = $this->home . '/lib' . PATH_SEPARATOR . ini_get('include_path'); set_include_path($include_path); $this->add_hook('storage_init', array($this, 'storage_init')); $this->add_hook('storage_connect', array($this, 'storage_connect')); $this->add_hook('user_delete', array('kolab_storage', 'delete_user_folders')); // For Chwala $this->add_hook('folder_mod', array('kolab_storage', 'folder_mod')); $rcmail = rcube::get_instance(); try { kolab_format::$timezone = new DateTimeZone($rcmail->config->get('timezone', 'GMT')); } catch (Exception $e) { rcube::raise_error($e, true); kolab_format::$timezone = new DateTimeZone('GMT'); } $this->add_texts('localization/', false); - if ($rcmail->output->type == 'html') { + if (!empty($rcmail->output->type) && $rcmail->output->type == 'html') { $rcmail->output->add_handler('libkolab.folder_search_form', array($this, 'folder_search_form')); $this->include_stylesheet($this->local_skin_path() . '/libkolab.css'); } // embed scripts and templates for email message audit trail if ($rcmail->task == 'mail' && self::get_bonnie_api()) { if ($rcmail->output->type == 'html') { $this->add_hook('render_page', array($this, 'bonnie_render_page')); $this->include_script('libkolab.js'); // add 'Show history' item to message menu $this->api->add_content(html::tag('li', array('role' => 'menuitem'), $this->api->output->button(array( 'command' => 'kolab-mail-history', 'label' => 'libkolab.showhistory', 'type' => 'link', 'classact' => 'icon history active', 'class' => 'icon history disabled', 'innerclass' => 'icon history', ))), 'messagemenu'); } $this->register_action('plugin.message-changelog', array($this, 'message_changelog')); } } /** * Hook into IMAP FETCH HEADER.FIELDS command and request Kolab-specific headers */ function storage_init($p) { $p['fetch_headers'] = trim($p['fetch_headers'] .' X-KOLAB-TYPE X-KOLAB-MIME-VERSION MESSAGE-ID'); return $p; } /** * Hook into IMAP connection to replace client identity */ function storage_connect($p) { $client_name = 'Roundcube/Kolab'; if (empty($p['ident'])) { $p['ident'] = array( 'name' => $client_name, 'version' => RCUBE_VERSION, /* 'php' => PHP_VERSION, 'os' => PHP_OS, 'command' => $_SERVER['REQUEST_URI'], */ ); } else { $p['ident']['name'] = $client_name; } return $p; } /** * Getter for a singleton instance of the Bonnie API * * @return mixed kolab_bonnie_api instance if configured, false otherwise */ public static function get_bonnie_api() { // get configuration for the Bonnie API if (!self::$bonnie_api && ($bonnie_config = rcube::get_instance()->config->get('kolab_bonnie_api', false))) { self::$bonnie_api = new kolab_bonnie_api($bonnie_config); } return self::$bonnie_api; } /** * Hook to append the message history dialog template to the mail view */ function bonnie_render_page($p) { if (($p['template'] === 'mail' || $p['template'] === 'message') && !$p['kolab-audittrail']) { // append a template for the audit trail dialog $this->api->output->add_footer( html::div(array('id' => 'mailmessagehistory', 'class' => 'uidialog', 'aria-hidden' => 'true', 'style' => 'display:none'), self::object_changelog_table(array('class' => 'records-table changelog-table')) ) ); $this->api->output->set_env('kolab_audit_trail', true); $p['kolab-audittrail'] = true; } return $p; } /** * Handler for message audit trail changelog requests */ public function message_changelog() { if (!self::$bonnie_api) { return false; } $rcmail = rcube::get_instance(); $msguid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST, true); $mailbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $result = $msguid && $mailbox ? self::$bonnie_api->changelog('mail', null, $mailbox, $msguid) : null; if (is_array($result)) { if (is_array($result['changes'])) { $dtformat = $rcmail->config->get('date_format') . ' ' . $rcmail->config->get('time_format'); array_walk($result['changes'], function(&$change) use ($dtformat, $rcmail) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTime) { $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); } $this->api->output->command('plugin.message_render_changelog', $result['changes']); } else { $this->api->output->command('plugin.message_render_changelog', false); } $this->api->output->send(); } /** * Wrapper function to load and initalize the HTTP_Request2 Object * * @param string|Net_Url2 Request URL * @param string Request method ('OPTIONS','GET','HEAD','POST','PUT','DELETE','TRACE','CONNECT') * @param array Configuration for this Request instance, that will be merged * with default configuration * * @return HTTP_Request2 Request object */ public static function http_request($url = '', $method = 'GET', $config = array()) { $rcube = rcube::get_instance(); $http_config = (array) $rcube->config->get('kolab_http_request'); // deprecated configuration options if (empty($http_config)) { foreach (array('ssl_verify_peer', 'ssl_verify_host') as $option) { $value = $rcube->config->get('kolab_' . $option, true); if (is_bool($value)) { $http_config[$option] = $value; } } } if (!empty($config)) { $http_config = array_merge($http_config, $config); } // force CURL adapter, this allows to handle correctly // compressed responses with SplObserver registered (kolab_files) (#4507) $http_config['adapter'] = 'HTTP_Request2_Adapter_Curl'; $key = md5(serialize($http_config)); if (!($request = self::$http_requests[$key])) { // load HTTP_Request2 require_once 'HTTP/Request2.php'; try { $request = new HTTP_Request2(); $request->setConfig($http_config); } catch (Exception $e) { rcube::raise_error($e, true, true); } // proxy User-Agent string $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); self::$http_requests[$key] = $request; } // cleanup try { $request->setBody(''); $request->setUrl($url); $request->setMethod($method); } catch (Exception $e) { rcube::raise_error($e, true, true); } return $request; } /** * Table oultine for object changelog display */ public static function object_changelog_table($attrib = array()) { $rcube = rcube::get_instance(); $attrib += array('domain' => 'libkolab'); $table = new html_table(array('cols' => 5, 'border' => 0, 'cellspacing' => 0)); $table->add_header('diff', ''); $table->add_header('revision', $rcube->gettext('revision', $attrib['domain'])); $table->add_header('date', $rcube->gettext('date', $attrib['domain'])); $table->add_header('user', $rcube->gettext('user', $attrib['domain'])); $table->add_header('operation', $rcube->gettext('operation', $attrib['domain'])); $table->add_header('actions', ' '); $rcube->output->add_label( 'libkolab.showrevision', 'libkolab.actionreceive', 'libkolab.actionappend', 'libkolab.actionmove', 'libkolab.actiondelete', 'libkolab.actionread', 'libkolab.actionflagset', 'libkolab.actionflagclear', 'libkolab.objectchangelog', 'libkolab.objectchangelognotavailable', 'close' ); return $table->show($attrib); } /** * Wrapper function for generating a html diff using the FineDiff class by Raymond Hill */ public static function html_diff($from, $to, $is_html = null) { // auto-detect text/html format if ($is_html === null) { $from_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $from, $m) && strpos($from, '') > 0); $to_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $to, $m) && strpos($to, '') > 0); $is_html = $from_html || $to_html; // ensure both parts are of the same format if ($is_html && !$from_html) { $converter = new rcube_text2html($from, false, array('wrap' => true)); $from = $converter->get_html(); } if ($is_html && !$to_html) { $converter = new rcube_text2html($to, false, array('wrap' => true)); $to = $converter->get_html(); } } // compute diff from HTML if ($is_html) { include_once __dir__ . '/vendor/Caxy/HtmlDiff/Match.php'; include_once __dir__ . '/vendor/Caxy/HtmlDiff/Operation.php'; include_once __dir__ . '/vendor/Caxy/HtmlDiff/HtmlDiff.php'; // replace data: urls with a transparent image to avoid memory problems $from = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $from); $to = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $to); $diff = new Caxy\HtmlDiff\HtmlDiff($from, $to); $diffhtml = $diff->build(); // remove empty inserts (from tables) return preg_replace('!\s*!Uims', '', $diffhtml); } else { include_once __dir__ . '/vendor/finediff.php'; $diff = new FineDiff($from, $to, FineDiff::$wordGranularity); return $diff->renderDiffToHTML(); } } /** * 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'; } /** * Returns HTML code for folder search widget * * @param array $attrib Named parameters * * @return string HTML code for the gui object */ public function folder_search_form($attrib) { $rcmail = rcube::get_instance(); $attrib += array( 'gui-object' => false, 'wrapper' => true, 'form-name' => 'foldersearchform', 'command' => 'non-extsing-command', 'reset-command' => 'non-existing-command', ); if ($attrib['label-domain'] && !strpos($attrib['buttontitle'], '.')) { $attrib['buttontitle'] = $attrib['label-domain'] . '.' . $attrib['buttontitle']; } if ($attrib['buttontitle']) { $attrib['placeholder'] = $rcmail->gettext($attrib['buttontitle']); } return $rcmail->output->search_form($attrib); } } diff --git a/plugins/libkolab/tests/kolab_date_recurrence.php b/plugins/libkolab/tests/kolab_date_recurrence.php index ad0f3216..ed3e3bd3 100644 --- a/plugins/libkolab/tests/kolab_date_recurrence.php +++ b/plugins/libkolab/tests/kolab_date_recurrence.php @@ -1,270 +1,270 @@ * * Copyright (C) 2017, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -class kolab_date_recurrence_test extends PHPUnit_Framework_TestCase +class kolab_date_recurrence_test extends PHPUnit\Framework\TestCase { function setUp() { $rcube = rcmail::get_instance(); $rcube->plugins->load_plugin('libkolab', true, true); } /** * kolab_date_recurrence::first_occurrence() * * @dataProvider data_first_occurrence */ function test_first_occurrence($recurrence_data, $start, $expected) { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $start = new DateTime($start); if (!empty($recurrence_data['UNTIL'])) { $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']); } $event = array('start' => $start, 'recurrence' => $recurrence_data); $object = kolab_format::factory('event', 3.0); $object->set($event); $recurrence = new kolab_date_recurrence($object); $first = $recurrence->first_occurrence(); $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); } /** * Data for test_first_occurrence() */ function data_first_occurrence() { // TODO: BYYEARDAY, BYWEEKNO, BYSETPOS, WKST return array( // non-recurring array( array(), // recurrence data '2017-08-31 11:00:00', // start date '2017-08-31 11:00:00', // expected result ), // daily array( array('FREQ' => 'DAILY', 'INTERVAL' => '1'), // recurrence data '2017-08-31 11:00:00', // start date '2017-08-31 11:00:00', // expected result ), // TODO: this one is not supported by the Calendar UI array( array('FREQ' => 'DAILY', 'INTERVAL' => '1', 'BYMONTH' => 1), '2017-08-31 11:00:00', '2018-01-01 11:00:00', ), // weekly array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1'), '2017-08-31 11:00:00', // Thursday '2017-08-31 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE'), '2017-08-31 11:00:00', // Thursday '2017-09-06 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'TH'), '2017-08-31 11:00:00', // Thursday '2017-08-31 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'FR'), '2017-08-31 11:00:00', // Thursday '2017-09-01 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '2'), '2017-08-31 11:00:00', // Thursday '2017-08-31 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '3', 'BYDAY' => 'WE'), '2017-08-31 11:00:00', // Thursday '2017-09-20 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'COUNT' => 1), '2017-08-31 11:00:00', // Thursday '2017-09-06 11:00:00', ), array( array('FREQ' => 'WEEKLY', 'INTERVAL' => '1', 'BYDAY' => 'WE', 'UNTIL' => '2017-09-01'), '2017-08-31 11:00:00', // Thursday '', ), // monthly array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '1'), '2017-09-08 11:00:00', '2017-09-08 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'), '2017-08-31 11:00:00', '2017-09-08 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'), '2017-09-08 11:00:00', '2017-09-08 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '1WE'), '2017-08-16 11:00:00', '2017-09-06 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYDAY' => '-1WE'), '2017-08-16 11:00:00', '2017-08-30 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '2'), '2017-09-08 11:00:00', '2017-09-08 11:00:00', ), array( array('FREQ' => 'MONTHLY', 'INTERVAL' => '2', 'BYMONTHDAY' => '8'), '2017-08-31 11:00:00', '2017-09-08 11:00:00', // ?????? ), // yearly array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1'), '2017-08-16 12:00:00', '2017-08-16 12:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8'), '2017-08-16 12:00:00', '2017-08-16 12:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYDAY' => '-1MO'), '2017-08-16 11:00:00', '2017-12-25 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '8', 'BYDAY' => '-1MO'), '2017-08-16 11:00:00', '2017-08-28 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1', 'BYDAY' => '1MO'), '2017-08-16 11:00:00', '2018-01-01 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1,9', 'BYDAY' => '1MO'), '2017-08-16 11:00:00', '2017-09-04 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '2'), '2017-08-16 11:00:00', '2017-08-16 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYMONTH' => '8'), '2017-08-16 11:00:00', '2017-08-16 11:00:00', ), array( array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYDAY' => '-1MO'), '2017-08-16 11:00:00', '2017-12-25 11:00:00', ), // on dates (FIXME: do we really expect the first occurrence to be on the start date?) array( array('RDATE' => array (new DateTime('2017-08-10 11:00:00 Europe/Warsaw'))), '2017-08-01 11:00:00', '2017-08-01 11:00:00', ), ); } /** * kolab_date_recurrence::first_occurrence() for all-day events * * @dataProvider data_first_occurrence */ function test_first_occurrence_allday($recurrence_data, $start, $expected) { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $start = new DateTime($start); if (!empty($recurrence_data['UNTIL'])) { $recurrence_data['UNTIL'] = new DateTime($recurrence_data['UNTIL']); } $event = array('start' => $start, 'recurrence' => $recurrence_data, 'allday' => true); $object = kolab_format::factory('event', 3.0); $object->set($event); $recurrence = new kolab_date_recurrence($object); $first = $recurrence->first_occurrence(); $this->assertEquals($expected, $first ? $first->format('Y-m-d H:i:s') : ''); } /** * kolab_date_recurrence::next_instance() */ function test_next_instance() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } date_default_timezone_set('America/New_York'); $start = new DateTime('2017-08-31 11:00:00', new DateTimeZone('Europe/Berlin')); $event = array( 'start' => $start, 'recurrence' => array('FREQ' => 'WEEKLY', 'INTERVAL' => '1'), 'allday' => true, ); $object = kolab_format::factory('event', 3.0); $object->set($event); $recurrence = new kolab_date_recurrence($object); $next = $recurrence->next_instance(); $this->assertEquals($start->format('2017-09-07 H:i:s'), $next['start']->format('Y-m-d H:i:s'), 'Same time'); $this->assertEquals($start->getTimezone()->getName(), $next['start']->getTimezone()->getName(), 'Same timezone'); $this->assertSame($next['start']->_dateonly, true, '_dateonly flag'); } } diff --git a/plugins/libkolab/tests/kolab_storage_config.php b/plugins/libkolab/tests/kolab_storage_config.php index 50a894f2..d0c0ba3f 100644 --- a/plugins/libkolab/tests/kolab_storage_config.php +++ b/plugins/libkolab/tests/kolab_storage_config.php @@ -1,238 +1,238 @@ 'Archive', 'uid' => '9', 'message-id' => '<1225270@example.org>', 'date' => 'Mon, 20 Apr 2015 15:30:30 UTC', 'subject' => 'Archived', ); private $url_personal = 'imap:///user/$user/Archive/9?message-id=%3C1225270%40example.org%3E&date=Mon%2C+20+Apr+2015+15%3A30%3A30+UTC&subject=Archived'; private $params_shared = array( 'folder' => 'Shared Folders/shared/Collected', 'uid' => '4', 'message-id' => '<5270122@example.org>', 'date' => 'Mon, 20 Apr 2015 16:33:03 +0200', 'subject' => 'Shared', ); private $url_shared = 'imap:///shared/Collected/4?message-id=%3C5270122%40example.org%3E&date=Mon%2C+20+Apr+2015+16%3A33%3A03+%2B0200&subject=Shared'; private $params_other = array( 'folder' => 'Other Users/lucy.white/Mailings', 'uid' => '378', 'message-id' => '<22448899@example.org>', 'date' => 'Tue, 14 Apr 2015 14:14:30 +0200', 'subject' => 'Happy Holidays', ); private $url_other = 'imap:///user/lucy.white%40example.org/Mailings/378?message-id=%3C22448899%40example.org%3E&date=Tue%2C+14+Apr+2015+14%3A14%3A30+%2B0200&subject=Happy+Holidays'; public static function setUpBeforeClass() { $rcube = rcmail::get_instance(); $rcube->plugins->load_plugin('libkolab', true, true); if (!kolab_format::supports(3)) { return; } if ($rcube->config->get('tests_username')) { $authenticated = $rcube->login( $rcube->config->get('tests_username'), $rcube->config->get('tests_password'), $rcube->config->get('default_host'), false ); if (!$authenticated) { throw new Exception('IMAP login failed for user ' . $rcube->config->get('tests_username')); } // check for defult groupware folders and clear them $imap = $rcube->get_storage(); $folders = $imap->list_folders('', '*'); foreach (array('Configuration') as $folder) { if (in_array($folder, $folders)) { if (!$imap->clear_folder($folder)) { throw new Exception("Failed to clear folder '$folder'"); } } else { throw new Exception("Default folder '$folder' doesn't exits in test user account"); } } } else { throw new Exception('Missing test account username/password in config-test.inc.php'); } kolab_storage::setup(); } function test_001_build_member_url() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $rcube = rcube::get_instance(); $email = $rcube->get_user_email(); $personal = str_replace('$user', urlencode($email), $this->url_personal); // personal namespace $url = kolab_storage_config::build_member_url($this->params_personal); $this->assertEquals($personal, $url); // shared namespace $url = kolab_storage_config::build_member_url($this->params_shared); $this->assertEquals($this->url_shared, $url); // other users namespace $url = kolab_storage_config::build_member_url($this->params_other); $this->assertEquals($this->url_other, $url); } function test_002_parse_member_url() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $rcube = rcube::get_instance(); $email = $rcube->get_user_email(); $personal = str_replace('$user', urlencode($email), $this->url_personal); // personal namespace $params = kolab_storage_config::parse_member_url($personal); $this->assertEquals($this->params_personal['uid'], $params['uid']); $this->assertEquals($this->params_personal['folder'], $params['folder']); $this->assertEquals($this->params_personal['subject'], $params['params']['subject']); $this->assertEquals($this->params_personal['message-id'], $params['params']['message-id']); // shared namespace $params = kolab_storage_config::parse_member_url($this->url_shared); $this->assertEquals($this->params_shared['uid'], $params['uid']); $this->assertEquals($this->params_shared['folder'], $params['folder']); // other users namespace $params = kolab_storage_config::parse_member_url($this->url_other); $this->assertEquals($this->params_other['uid'], $params['uid']); $this->assertEquals($this->params_other['folder'], $params['folder']); } function test_003_build_parse_member_url() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } // personal namespace $params = $this->params_personal; $params_ = kolab_storage_config::parse_member_url(kolab_storage_config::build_member_url($params)); $this->assertEquals($params['uid'], $params_['uid']); $this->assertEquals($params['folder'], $params_['folder']); // shared namespace $params = $this->params_shared; $params_ = kolab_storage_config::parse_member_url(kolab_storage_config::build_member_url($params)); $this->assertEquals($params['uid'], $params_['uid']); $this->assertEquals($params['folder'], $params_['folder']); // other users namespace $params = $this->params_other; $params_ = kolab_storage_config::parse_member_url(kolab_storage_config::build_member_url($params)); $this->assertEquals($params['uid'], $params_['uid']); $this->assertEquals($params['folder'], $params_['folder']); } /** * Test relation/tag objects creation * These objects will be used by following tests */ function test_save() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $config = kolab_storage_config::get_instance(); $tags = array( array( 'category' => 'tag', 'name' => 'test1', ), array( 'category' => 'tag', 'name' => 'test2', ), array( 'category' => 'tag', 'name' => 'test3', ), array( 'category' => 'tag', 'name' => 'test4', ), ); foreach ($tags as $tag) { $result = $config->save($tag, 'relation'); $this->assertTrue(!empty($result)); $this->assertTrue(!empty($tag['uid'])); } } /** * Tests "race condition" in tags handling (T133) */ function test_T133() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $config = kolab_storage_config::get_instance(); // get tags $tags = $config->get_tags(); $this->assertCount(4, $tags); // create a tag $tag = array( 'category' => 'tag', 'name' => 'new', ); $result = $config->save($tag, 'relation'); $this->assertTrue(!empty($result)); // get tags again, make sure it contains the new tag $tags = $config->get_tags(); $this->assertCount(5, $tags); // update a tag $tag['name'] = 'new-tag'; $result = $config->save($tag, 'relation'); $this->assertTrue(!empty($result)); // get tags again, make sure it contains the new tag $tags = $config->get_tags(); $this->assertCount(5, $tags); $this->assertSame('new-tag', $tags[4]['name']); // remove a tag $result = $config->delete($tag['uid']); $this->assertTrue(!empty($result)); // get tags again, make sure it contains the new tag $tags = $config->get_tags(); $this->assertCount(4, $tags); foreach ($tags as $_tag) { $this->assertTrue($_tag['uid'] != $tag['uid']); } } } diff --git a/plugins/libkolab/tests/kolab_storage_folder.php b/plugins/libkolab/tests/kolab_storage_folder.php index 273ece26..c242b979 100644 --- a/plugins/libkolab/tests/kolab_storage_folder.php +++ b/plugins/libkolab/tests/kolab_storage_folder.php @@ -1,255 +1,255 @@ * * Copyright (C) 2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -class kolab_storage_folder_test extends PHPUnit_Framework_TestCase +class kolab_storage_folder_test extends PHPUnit\Framework\TestCase { public static function setUpBeforeClass() { // load libkolab plugin $rcmail = rcmail::get_instance(); $rcmail->plugins->load_plugin('libkolab', true, true); if (!kolab_format::supports(3)) { return; } if ($rcmail->config->get('tests_username')) { $authenticated = $rcmail->login( $rcmail->config->get('tests_username'), $rcmail->config->get('tests_password'), $rcmail->config->get('default_host'), false ); if (!$authenticated) { throw new Exception('IMAP login failed for user ' . $rcmail->config->get('tests_username')); } // check for defult groupware folders and clear them $imap = $rcmail->get_storage(); $folders = $imap->list_folders('', '*'); foreach (array('Calendar','Contacts','Files','Tasks','Notes') as $folder) { if (in_array($folder, $folders)) { if (!$imap->clear_folder($folder)) { throw new Exception("Failed to clear folder '$folder'"); } } else { throw new Exception("Default folder '$folder' doesn't exits in test user account"); } } } else { throw new Exception('Missing test account username/password in config-test.inc.php'); } kolab_storage::setup(); } function test_001_folder_type_check() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $folder = new kolab_storage_folder('Calendar', 'event', 'event.default'); $this->assertTrue($folder->valid); $this->assertEquals($folder->get_error(), 0); $folder = new kolab_storage_folder('Calendar', 'event', 'mail'); $this->assertFalse($folder->valid); $this->assertEquals($folder->get_error(), kolab_storage::ERROR_INVALID_FOLDER); $folder = new kolab_storage_folder('INBOX'); $this->assertFalse($folder->valid); $this->assertEquals($folder->get_error(), kolab_storage::ERROR_INVALID_FOLDER); } function test_002_get_owner() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $rcmail = rcmail::get_instance(); $folder = new kolab_storage_folder('Calendar', 'event', 'event'); $this->assertEquals($folder->get_owner(), $rcmail->config->get('tests_username')); $domain = preg_replace('/^.+@/', '@', $rcmail->config->get('tests_username')); $shared_ns = kolab_storage::namespace_root('shared'); $folder = new kolab_storage_folder($shared_ns . 'A-shared-folder', 'event', 'event'); $this->assertEquals($folder->get_owner(true), 'anonymous' . $domain); $other_ns = kolab_storage::namespace_root('other'); $folder = new kolab_storage_folder($other_ns . 'major.tom/Calendar', 'event', 'event'); $this->assertEquals($folder->get_owner(true), 'major.tom' . $domain); } function test_003_get_resource_uri() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $rcmail = rcmail::get_instance(); $foldername = 'Calendar'; $uri = parse_url($rcmail->config->get('default_host')); $hostname = $uri['host']; $folder = new kolab_storage_folder($foldername, 'event', 'event.default'); $this->assertEquals($folder->get_resource_uri(), sprintf('imap://%s@%s/%s', urlencode($rcmail->config->get('tests_username')), $hostname, $foldername )); } function test_004_get_uid() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $rcmail = rcmail::get_instance(); $folder = new kolab_storage_folder('Doesnt-Exist', 'event', 'event'); // generate UID from folder name if IMAP operations fail $uid1 = $folder->get_uid(); $this->assertEquals($folder->get_uid(), $uid1); $this->assertEquals($folder->get_error(), kolab_storage::ERROR_IMAP_CONN); } function test_005_subscribe() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $folder = new kolab_storage_folder('Contacts', 'contact'); $this->assertTrue($folder->subscribe(true)); $this->assertTrue($folder->is_subscribed()); $this->assertTrue($folder->subscribe(false)); $this->assertFalse($folder->is_subscribed()); $folder->subscribe(true); } function test_006_activate() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $folder = new kolab_storage_folder('Calendar', 'event'); $this->assertTrue($folder->activate(true)); $this->assertTrue($folder->is_active()); $this->assertTrue($folder->activate(false)); $this->assertFalse($folder->is_active()); } function test_010_write_contacts() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $folder = new kolab_storage_folder('Contacts', 'contact'); $saved = $folder->save(null, 'contact'); $this->assertFalse($saved); $contact = array( 'name' => 'FN', 'surname' => 'Last', 'firstname' => 'First', 'email' => array( array('type' => 'home', 'address' => 'first.last@example.org'), ), 'organization' => 'Company A.G.' ); $saved = $folder->save($contact, 'contact'); $this->assertTrue((bool)$saved); } /** * @depends test_010_write_contacts */ function test_011_list_contacts() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $folder = new kolab_storage_folder('Contacts', 'contact'); $this->assertEquals($folder->count(), 1); } function test_T491_get_uid() { if (!kolab_format::supports(3)) { $this->markTestSkipped('No Kolab support'); } $rcmail = rcmail::get_instance(); $imap = $rcmail->get_storage(); $db = $rcmail->get_dbh(); // clear cache //$imap->clear_cache('mailboxes.metadata', true); // get folder UID $folder = new kolab_storage_folder('Calendar', 'event', 'event'); $uid = $folder->get_uid(); // now get folder uniqueid annotations $annotations = array( 'cyrus' => kolab_storage::UID_KEY_CYRUS, 'shared' => kolab_storage::UID_KEY_SHARED, 'private' => '/private/vendor/kolab/uniqueid', ); foreach ($annotations as $key => $annotation) { $meta = $imap->get_metadata('Calendar', $annotation); $annotations[$key] = $meta['Calendar'][$annotation]; } // compare results if ($annotations['shared']) { $this->assertSame($annotations['shared'], $uid); } else if ($annotations['cyrus']) { $this->assertSame($annotations['cyrus'], $uid); } else { // never use private namespace $this->assertTrue($annotations['private'] != $uid); } // @TODO: check if the cache contains valid entries, not so simple with memcache // as the cache key name is quite internal to the rcube_imap class. } }