diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 3331b4e8..0965064e 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -1,1334 +1,1334 @@ * * 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 . */ 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//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'); 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//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(); if ($vobject->name == 'VCALENDAR') { $this->method = strval($vobject->METHOD); $this->agent = strval($vobject->PRODID); foreach ($vobject->getBaseComponents() ?: $vobject->getComponents() as $ve) { if ($ve->name == 'VEVENT' || $ve->name == 'VTODO') { // convert to hash array representation $object = $this->_to_array($ve); if (!$seen[$object['uid']]++) { // parse recurrence exceptions if ($object['recurrence']) { foreach ($vobject->children as $i => $component) { if ($component->name == 'VEVENT' && isset($component->{'RECURRENCE-ID'})) { $object['recurrence']['EXCEPTIONS'][] = $this->_to_array($component); } } } $this->objects[] = $object; } } else if ($ve->name == 'VFREEBUSY') { $this->objects[] = $this->_parse_freebusy($ve); } } } 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}) { $event[$field] = $ve->{$attr}->getDateTime(); } } catch (Exception $e) {} } // map other attributes to internal fields $_attendees = array(); 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 'PRIORITY': if (is_numeric($value)) $event['priority'] = $value; break; case 'RRULE': $params = is_array($event['recurrence']) ? $event['recurrence'] : array(); // parse recurrence rule attributes foreach ($prop->getParts() as $k => $v) { $params[strtoupper($k)] = $v; } if ($params['UNTIL']) $params['UNTIL'] = date_create($params['UNTIL']); if (!$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); } 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); } break; case 'RECURRENCE-ID': $event['recurrence_date'] = self::convert_datetime($prop); break; case 'RELATED-TO': if ($prop->offsetGet('RELTYPE') == 'PARENT') { $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()); break; case 'CLASS': case 'X-CALENDARSERVER-ACCESS': $event['sensitivity'] = strtolower($value); break; case 'X-MICROSOFT-CDO-BUSYSTATUS': if ($value == 'OOF') $event['free_busy'] = 'outofoffice'; else if (in_array($value, array('FREE', 'BUSY', 'TENTATIVE'))) $event['free_busy'] = strtolower($value); break; case 'ATTENDEE': case 'ORGANIZER': $params = array(); 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); + $attendee['email'] = preg_replace('!^mailto:!i', '', $value); if ($prop->name == 'ORGANIZER') { $attendee['role'] = 'ORGANIZER'; $attendee['status'] = 'ACCEPTED'; $event['organizer'] = $attendee; } 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')); $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') { // check for all-day dates if ($event['start']->_dateonly) { $event['allday'] = true; } // all-day events may lack the DTEND property if ($event['allday'] && 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 $pname => $pvalue) { - if ($pname == 'VALUE' && $pvalue == 'DATE-TIME') { - $trigger = '@' . $prop->getDateTime()->format('U'); - $alarm['trigger'] = $prop->getDateTime(); - } + if ($prop['VALUE'] == 'DATE-TIME') { + $trigger = '@' . $prop->getDateTime()->format('U'); + $alarm['trigger'] = $prop->getDateTime(); } if (!$trigger && ($values = libcalendaring::parse_alaram_value($value))) { $trigger = $values[2]; } if (!$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') $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); + $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 $event['alarms'] = $trigger . ':' . $action; if ($alarm['trigger']) $event['valarms'][] = $alarm; } } // assign current timezone to event start/end if ($event['start'] instanceof DateTime) { if ($this->timezone) $event['start']->setTimezone($this->timezone); } else { unset($event['start']); } if ($event['end'] instanceof DateTime) { if ($this->timezone) $event['end']->setTimezone($this->timezone); } else { unset($event['end']); } // 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; } /** * 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'); $this->freebusy[$propmap[$prop->name]] = self::convert_datetime($prop); break; case 'ORGANIZER': - $this->freebusy['organizer'] = preg_replace('/^mailto:/i', '', $value); + $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]++) continue; 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) + 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; - $vdt = $cal->createProperty($name); - $vdt->setValue($dt); + $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])) { $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)] = $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) { $memory_limit = parse_bytes(ini_get('memory_limit')); $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 ($vcal) { $vcal->add($vt); } else { echo $vt->serialize(); } } } 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'; $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 = $dtstamp; // all-day events end the next day if ($event['allday'] && !empty($event['end'])) { $event['end'] = clone $event['end']; $event['end']->add(new \DateInterval('P1D')); $event['end']->_dateonly = true; } if (!empty($event['created'])) $ve->add($this->datetime_prop($cal, 'CREATED', $event['created'], true)); 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 ($recurrence_id) { $ve->add($this->datetime_prop($cal, 'RECURRENCE-ID', $recurrence_id, true)); } $ve->add('SUMMARY', $event['title']); if ($event['location']) $ve->add($this->is_apple() ? new vobject_location_property($cal, 'LOCATION', $event['location']) : $cal->create('LOCATION', $event['location'])); if ($event['description']) $ve->add('DESCRIPTION', strtr($event['description'], array("\r\n" => "\n", "\r" => "\n"))); // normalize line endings if (isset($event['sequence'])) $ve->add('SEQUENCE', $event['sequence']); if ($event['recurrence'] && !$recurrence_id) { if ($exdates = $event['recurrence']['EXDATE']) { unset($event['recurrence']['EXDATE']); // don't serialize EXDATEs into RRULE value } if ($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'])); } // add EXDATEs each one per line (for Thunderbird Lightning) if ($exdates) { foreach ($exdates as $ex) { if ($ex instanceof \DateTime) { $exd = clone $event['start']; $exd->setDate($ex->format('Y'), $ex->format('n'), $ex->format('j')); $exd->setTimeZone(new \DateTimeZone('UTC')); $ve->add($this->datetime_prop($cal, 'EXDATE', $exd, true)); } } } // add RDATEs if (!empty($rdates)) { foreach ((array)$rdates as $rdate) { $ve->add($this->datetime_prop($cal, 'RDATE', $rdate)); } } } if ($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 ($event['cancelled']) $ve->add('STATUS', 'CANCELLED'); else if ($event['free_busy'] == 'tentative') $ve->add('STATUS', 'TENTATIVE'); else if ($event['complete'] == 100) $ve->add('STATUS', 'COMPLETED'); 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 required the COMPLETED date to be set in order to consider a task complete if ($event['complete'] == 100) $ve->add($this->datetime_prop($cal, 'COMPLETED', $event['changed'] ?: new DateTime('now - 1 hour'), true)); } if ($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)); + $va->add($this->datetime_prop($cal, 'TRIGGER', $alarm['trigger'], true, null, true)); } else { $va->add('TRIGGER', $alarm['trigger']); } if ($alarm['action'] == 'EMAIL') { foreach ((array)$alarm['attendees'] as $attendee) { $va->add('ATTENDEE', 'mailto:' . $attendee); } } if ($alarm['description']) { $va->add('DESCRIPTION', $alarm['description'] ?: $event['title']); } if ($alarm['summary']) { $va->add('SUMMARY', $alarm['summary']); } if ($alarm['duration']) { $va->add('DURATION', $alarm['duration']); $va->add('REPEAT', intval($alarm['repeat'])); } if ($alarm['uri']) { $va->add('ATTACH', $alarm['uri'], array('VALUE' => 'URI')); } $ve->add($va); } } // legacy support else if ($event['alarms']) { $va = $cal->createComponent('VALARM'); list($trigger, $va->action) = explode(':', $event['alarms']); $val = libcalendaring::parse_alaram_value($trigger); if ($val[3]) $va->add('TRIGGER', $val[3]); else if ($val[0] instanceof DateTime) - $va->add($this->datetime_prop($cal, 'TRIGGER', $val[0])); + $va->add($this->datetime_prop($cal, 'TRIGGER', $val[0], true, null, true)); $ve->add($va); } 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' : 'FALSE'; $ve->add('ATTENDEE', 'mailto:' . $attendee['email'], array_filter(self::map_keys($attendee, $this->attendee_keymap))); } } if ($event['organizer']) { $ve->add('ORGANIZER', 'mailto:' . $event['organizer']['email'], self::map_keys($event['organizer'], array('name' => 'CN'))); } 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']) $ve->add('COMMENT', $event['comment']); // 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 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); } // add custom properties 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']) { foreach ($event['recurrence']['EXCEPTIONS'] as $ex) { $exdate = clone $event['start']; $exdate->setDate($ex['start']->format('Y'), $ex['start']->format('n'), $ex['start']->format('j')); // if ($ex['thisandfuture']) // not supported by any client :-( // $recurrence_id->add('RANGE', 'THISANDFUTURE'); $this->_to_ical($ex, $vcal, $get_attachment, $exdate); } } } /** * 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 * * @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) { 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')) { return false; } $year = 86400 * 360; $transitions = $tz->getTransitions($from - $year, $to + $year); $vt = $cal->createComponent('VTIMEZONE'); $vt->TZID = $tz->getName(); $std = null; $dst = null; foreach ($transitions as $i => $trans) { $cmp = null; if ($i == 0) { $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('%s%02d%02d', $tzfrom >= 0 ? '+' : '', floor($tzfrom), ($tzfrom - floor($tzfrom)) * 60); $cmp->TZOFFSETTO = sprintf('%s%02d%02d', $offset >= 0 ? '+' : '', 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) { $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/libvcalendar.php b/plugins/libcalendaring/tests/libvcalendar.php index eff827c5..80752c09 100644 --- a/plugins/libcalendaring/tests/libvcalendar.php +++ b/plugins/libcalendaring/tests/libvcalendar.php @@ -1,560 +1,562 @@ * * 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 { 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"); } function test_invalid_vevent() { $this->setExpectedException('\Sabre\VObject\ParseException'); $ical = new libvcalendar(); $events = $ical->import_from_file(__DIR__ . '/resources/invalid-event.ics', 'UTF-8', true); } /** * Test some extended ical properties such as attendees, recurrence rules, alarms and attachments * * @depends test_import_from_file */ 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 reccuence 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"); } /** * */ 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_alaram_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)"); // 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_alaram_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('This is the first event reminder', $event['valarms'][0]['description'], "First alarm text"); - $this->assertEquals(2, count($event['valarms']), "List all VALARM blocks"); + $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_alaram_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'])); } /** * 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('Europe/Berlin')); $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('END:VTIMEZONE', $ics, "VTIMEZONE encapsulation END"); $this->assertContains('BEGIN:VEVENT', $ics, "VEVENT encapsulation BEGIN"); $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=20140718/', $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:-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:20130814', $ics, "Recurrence-ID (1) being the exception date"); $this->assertContains('RECURRENCE-ID:20131113', $ics, "Recurrence-ID (2) being the exception date"); $this->assertContains('SUMMARY:'.$exception2['title'], $ics, "Exception title"); } /** * */ 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')); $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')); $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()); } function get_attachment_data($id, $event) { return $this->attachment_data; } } diff --git a/plugins/libcalendaring/tests/resources/alarms.ics b/plugins/libcalendaring/tests/resources/alarms.ics index d6f9d547..9d588543 100644 --- a/plugins/libcalendaring/tests/resources/alarms.ics +++ b/plugins/libcalendaring/tests/resources/alarms.ics @@ -1,51 +1,56 @@ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Apple Inc.//iCal 5.0.3//EN CALSCALE:GREGORIAN BEGIN:VTIMEZONE TZID:Europe/Zurich BEGIN:DAYLIGHT TZOFFSETFROM:+0100 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU DTSTART:19810329T020000 TZNAME:CEST TZOFFSETTO:+0200 END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU DTSTART:19961027T030000 TZNAME:CET TZOFFSETTO:+0100 END:STANDARD END:VTIMEZONE BEGIN:VEVENT UID:1dq52u617gkfqrr4uo1i2uh70 CREATED:20130924T221822Z DESCRIPTION: DTSTART:20130818T230000Z DTEND:20130819T010000Z DTSTAMP:20130824T235608Z LAST-MODIFIED:20130924T222118Z LOCATION: SEQUENCE:2 STATUS:CONFIRMED SUMMARY:Alarms test TRANSP:OPAQUE BEGIN:VALARM ACTION:DISPLAY DESCRIPTION:This is the first event reminder TRIGGER:-P0DT0H30M0S END:VALARM BEGIN:VALARM ACTION:EMAIL DESCRIPTION:This is an event reminder TRIGGER:-P1D ATTENDEE:mailto:reminder-recipient@example.org SUMMARY:This is the reminder message DESCRIPTION:This is the second event reminder END:VALARM +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:An absolute reminder +TRIGGER;VALUE=DATE-TIME:20130812T160000Z +END:VALARM END:VEVENT END:VCALENDAR