Include VTIMEZONE definitions when exporting event data or invitations as iCal (#3199)

This commit is contained in:
Thomas Bruederli 2014-09-22 11:03:11 +02:00
parent 3b65094053
commit f9b19b9f27
2 changed files with 189 additions and 23 deletions

View file

@ -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 ***/

View file

@ -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;