From f9b19b9f279178257a1a98511be5ecd360cede01 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Mon, 22 Sep 2014 11:03:11 +0200 Subject: [PATCH] Include VTIMEZONE definitions when exporting event data or invitations as iCal (#3199) --- plugins/libcalendaring/libvcalendar.php | 151 ++++++++++++++++-- plugins/libcalendaring/tests/libvcalendar.php | 61 ++++++- 2 files changed, 189 insertions(+), 23 deletions(-) diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 3cb7826f..939cc76f 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -50,12 +50,14 @@ class libvcalendar implements Iterator private $forward_exceptions; private $vhead; private $fp; + private $vtimezones = array(); public $method; public $agent = ''; public $objects = array(); public $freebusy = array(); + /** * Default constructor */ @@ -106,6 +108,7 @@ class libvcalendar implements Iterator $this->method = ''; $this->objects = array(); $this->freebusy = array(); + $this->vtimezones = array(); $this->iteratorkey = 0; if ($this->fp) { @@ -791,13 +794,26 @@ class libvcalendar implements Iterator * @param string Property name * @param object DateTime */ - public static function datetime_prop($name, $dt, $utc = false, $dateonly = null) + public function datetime_prop($name, $dt, $utc = false, $dateonly = null) { $is_utc = $utc || (($tz = $dt->getTimezone()) && in_array($tz->getName(), array('UTC','GMT','Z'))); $is_dateonly = $dateonly === null ? (bool)$dt->_dateonly : (bool)$dateonly; $vdt = new VObject\Property\DateTime($name); $vdt->setDateTime($dt, $is_dateonly ? VObject\Property\DateTime::DATE : ($is_utc ? VObject\Property\DateTime::UTC : VObject\Property\DateTime::LOCALTZ)); + + // 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; } @@ -834,9 +850,10 @@ class libvcalendar implements Iterator * @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, $recurrence_id = null) + 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; @@ -851,8 +868,6 @@ class libvcalendar implements Iterator $vcal->METHOD = $method; } - // TODO: include timezone information - // write vcalendar header if ($write) { echo preg_replace('/END:VCALENDAR[\r\n]*$/m', '', $vcal->serialize()); @@ -862,6 +877,23 @@ class libvcalendar implements Iterator $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]); + 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; @@ -887,7 +919,7 @@ class libvcalendar implements Iterator // set DTSTAMP according to RFC 5545, 3.8.7.2. $dtstamp = !empty($event['changed']) && !empty($this->method) ? $event['changed'] : new DateTime(); - $ve->add(self::datetime_prop('DTSTAMP', $dtstamp, true)); + $ve->add($this->datetime_prop('DTSTAMP', $dtstamp, true)); // all-day events end the next day if ($event['allday'] && !empty($event['end'])) { @@ -896,15 +928,15 @@ class libvcalendar implements Iterator $event['end']->_dateonly = true; } if (!empty($event['created'])) - $ve->add(self::datetime_prop('CREATED', $event['created'], true)); + $ve->add($this->datetime_prop('CREATED', $event['created'], true)); if (!empty($event['changed'])) - $ve->add(self::datetime_prop('LAST-MODIFIED', $event['changed'], true)); + $ve->add($this->datetime_prop('LAST-MODIFIED', $event['changed'], true)); if (!empty($event['start'])) - $ve->add(self::datetime_prop('DTSTART', $event['start'], false, (bool)$event['allday'])); + $ve->add($this->datetime_prop('DTSTART', $event['start'], false, (bool)$event['allday'])); if (!empty($event['end'])) - $ve->add(self::datetime_prop('DTEND', $event['end'], false, (bool)$event['allday'])); + $ve->add($this->datetime_prop('DTEND', $event['end'], false, (bool)$event['allday'])); if (!empty($event['due'])) - $ve->add(self::datetime_prop('DUE', $event['due'], false)); + $ve->add($this->datetime_prop('DUE', $event['due'], false)); if ($recurrence_id) $ve->add($recurrence_id); @@ -944,7 +976,7 @@ class libvcalendar implements Iterator } // add RDATEs if (!empty($rdates)) { - $sample = self::datetime_prop('RDATE', $rdates[0]); + $sample = $this->datetime_prop('RDATE', $rdates[0]); $rdprop = new VObject\Property\MultiDateTime('RDATE', null); $rdprop->setDateTimes($rdates, $sample->getDateType()); $ve->add($rdprop); @@ -985,7 +1017,7 @@ class libvcalendar implements Iterator $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(self::datetime_prop('COMPLETED', $event['changed'] ?: new DateTime('now - 1 hour'), true)); + $ve->add($this->datetime_prop('COMPLETED', $event['changed'] ?: new DateTime('now - 1 hour'), true)); } if ($event['valarms']) { @@ -993,7 +1025,7 @@ class libvcalendar implements Iterator $va = VObject\Component::create('VALARM'); $va->action = $alarm['action']; if ($alarm['trigger'] instanceof DateTime) { - $va->add(self::datetime_prop('TRIGGER', $alarm['trigger'], true)); + $va->add($this->datetime_prop('TRIGGER', $alarm['trigger'], true)); } else { $va->add('TRIGGER', $alarm['trigger']); @@ -1028,7 +1060,7 @@ class libvcalendar implements Iterator if ($val[3]) $va->add('TRIGGER', $val[3]); else if ($val[0] instanceof DateTime) - $va->add(self::datetime_prop('TRIGGER', $val[0])); + $va->add($this->datetime_prop('TRIGGER', $val[0])); $ve->add($va); } @@ -1111,7 +1143,7 @@ class libvcalendar implements Iterator foreach ($event['recurrence']['EXCEPTIONS'] as $ex) { $exdate = clone $event['start']; $exdate->setDate($ex['start']->format('Y'), $ex['start']->format('n'), $ex['start']->format('j')); - $recurrence_id = self::datetime_prop('RECURRENCE-ID', $exdate, true); + $recurrence_id = $this->datetime_prop('RECURRENCE-ID', $exdate, true); // if ($ex['thisandfuture']) // not supported by any client :-( // $recurrence_id->add('RANGE', 'THISANDFUTURE'); $this->_to_ical($ex, $vcal, $get_attachment, $recurrence_id); @@ -1119,6 +1151,95 @@ class libvcalendar implements Iterator } } + /** + * 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) + { + if (!$from) $from = time(); + if (!$to) $to = $from; + + 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 = new VObject\Component('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 = new VObject\Component('DAYLIGHT'); + $cmp = $dst; + } + else { + $t_std = $trans['ts']; + $std = new VObject\Component('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), 0); + $cmp->TZOFFSETTO = sprintf('%s%02d%02d', $offset >= 0 ? '+' : '', floor($offset), 0); + + 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 ***/ diff --git a/plugins/libcalendaring/tests/libvcalendar.php b/plugins/libcalendaring/tests/libvcalendar.php index aa2c5680..d3a0ffea 100644 --- a/plugins/libcalendaring/tests/libvcalendar.php +++ b/plugins/libcalendaring/tests/libvcalendar.php @@ -327,7 +327,7 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase /** * Test for iCal export from internal hash array representation * - * @depends test_extended + * */ function test_export() { @@ -343,12 +343,20 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase $event['attachments'][0]['id'] = '1'; $event['description'] = '*Exported by libvcalendar*'; - $ics = $ical->export(array($event), 'REQUEST', false, array($this, 'get_attachment_data')); + $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('METHOD:REQUEST', $ics, "iTip method"); - $this->assertContains('BEGIN:VEVENT', $ics, "VEVENT 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"); @@ -471,10 +479,11 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase function test_datetime() { - $localtime = libvcalendar::datetime_prop('DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('Europe/Berlin'))); - $localdate = libvcalendar::datetime_prop('DTSTART', new DateTime('2013-09-01', new DateTimeZone('Europe/Berlin')), false, true); - $utctime = libvcalendar::datetime_prop('DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('UTC'))); - $asutctime = libvcalendar::datetime_prop('DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('Europe/Berlin')), true); + $ical = new libvcalendar(); + $localtime = $ical->datetime_prop('DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('Europe/Berlin'))); + $localdate = $ical->datetime_prop('DTSTART', new DateTime('2013-09-01', new DateTimeZone('Europe/Berlin')), false, true); + $utctime = $ical->datetime_prop('DTSTART', new DateTime('2013-09-01 12:00:00', new DateTimeZone('UTC'))); + $asutctime = $ical->datetime_prop('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()); @@ -482,6 +491,42 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase $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('Europe/Istanbul')); + $this->assertInstanceOf('\Sabre\VObject\Component', $vtz); + } + function get_attachment_data($id, $event) { return $this->attachment_data;