Fix handling of Recurrence-ID properties for recurrence exceptions to comply with RFC 5545 (#4385)

This commit is contained in:
Thomas Bruederli 2015-02-12 10:08:22 +01:00
parent 49280a6f62
commit ad55fc706d
6 changed files with 140 additions and 69 deletions

View file

@ -191,7 +191,7 @@ class kolab_calendar extends kolab_storage_folder_api
// event not found, maybe a recurring instance is requested
if (!$this->events[$id]) {
$master_id = preg_replace('/-\d+$/', '', $id);
$master_id = preg_replace('/-\d+(T\d{6})?$/', '', $id);
if ($master_id != $id && ($record = $this->storage->get_object($master_id)))
$this->events[$master_id] = $this->_to_rcube_event($record);
@ -455,7 +455,7 @@ class kolab_calendar extends kolab_storage_folder_api
$this->save_links($event['uid'], $links);
$updated = true;
$this->events[$event['id']] = $this->_to_rcube_event($object);
$this->events = array($event['id'] => $this->_to_rcube_event($object));
// refresh local cache with recurring instances
if ($exception_id) {
@ -564,34 +564,39 @@ class kolab_calendar extends kolab_storage_folder_api
$end->add(new DateInterval($intvl));
}
// add recurrence exceptions to output
$i = 0;
$events = array();
$exdates = array();
$futuredata = array();
if (is_array($event['recurrence']['EXCEPTIONS'])) {
// copy the recurrence rule from the master event (to be used in the UI)
$recurrence_rule = $event['recurrence'];
unset($recurrence_rule['EXCEPTIONS'], $recurrence_rule['EXDATE']);
// copy the recurrence rule from the master event (to be used in the UI)
$recurrence_rule = $event['recurrence'];
unset($recurrence_rule['EXCEPTIONS'], $recurrence_rule['EXDATE']);
// read recurrence exceptions first
$events = array();
$exdata = array();
$futuredata = array();
$recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis';
if (is_array($event['recurrence']['EXCEPTIONS'])) {
foreach ($event['recurrence']['EXCEPTIONS'] as $exception) {
if (!$exception['_instance'] && is_a($exception['recurrence_date'], 'DateTime'))
$exception['_instance'] = $exception['recurrence_date']->format($recurrence_id_format);
else if (!$exception['_instance'] && is_a($exception['start'], 'DateTime'))
$exception['_instance'] = $exception['start']->format($recurrence_id_format);
$rec_event = $this->_to_rcube_event($exception);
$rec_event['id'] = $event['uid'] . '-' . ++$i;
$rec_event['recurrence_id'] = $event['uid'];
$rec_event['recurrence'] = $recurrence_rule;
$rec_event['_instance'] = $i;
$rec_event['id'] = $event['uid'] . '-' . $exception['_instance'];
$rec_event['isexception'] = 1;
$events[] = $rec_event;
// found the specifically requested instance, exiting...
if ($rec_event['id'] == $event_id) {
$rec_event['recurrence'] = $recurrence_rule;
$rec_event['recurrence_id'] = $event['uid'];
$events[] = $rec_event;
$this->events[$rec_event['id']] = $rec_event;
return $events;
}
// remember this exception's date
$exdate = $rec_event['start']->format('Y-m-d');
$exdates[$exdate] = $rec_event['id'];
$exdate = substr($exception['_instance'], 0, 8);
$exdata[$exdate] = $rec_event;
if ($rec_event['thisandfuture']) {
$futuredata[$exdate] = $rec_event;
}
@ -608,27 +613,27 @@ class kolab_calendar extends kolab_storage_folder_api
$recurrence = new calendar_recurrence($this->cal, $event);
}
$i = 0;
while ($next_event = $recurrence->next_instance()) {
// skip if there's an exception at this date
$datestr = $next_event['start']->format('Y-m-d');
if ($exdates[$datestr]) {
// use this event data for future recurring instances
if ($futuredata[$datestr])
$overlay_data = $futuredata[$datestr];
continue;
}
$datestr = $next_event['start']->format('Ymd');
$instance_id = $next_event['start']->format($recurrence_id_format);
// use this event data for future recurring instances
if ($futuredata[$datestr])
$overlay_data = $futuredata[$datestr];
// add to output if in range
$rec_id = $event['uid'] . '-' . ++$i;
$rec_id = $event['uid'] . '-' . $instance_id;
if (($next_event['start'] <= $end && $next_event['end'] >= $start) || ($event_id && $rec_id == $event_id)) {
$rec_event = $this->_to_rcube_event($next_event);
$rec_event['_instance'] = $instance_id;
if ($overlay_data) // copy data from a 'this-and-future' exception
$this->_merge_event_data($rec_event, $overlay_data);
if ($overlay_data || $exdata[$datestr]) // copy data from exception
$this->_merge_event_data($rec_event, $exdata[$datestr] ?: $overlay_data);
$rec_event['id'] = $rec_id;
$rec_event['recurrence_id'] = $event['uid'];
$rec_event['_instance'] = $i;
$rec_event['recurrence'] = $recurrence_rule;
unset($rec_event['_attendees']);
$events[] = $rec_event;
@ -641,7 +646,7 @@ class kolab_calendar extends kolab_storage_folder_api
break;
// avoid endless recursion loops
if ($i > 1000)
if (++$i > 1000)
break;
}
@ -661,8 +666,13 @@ class kolab_calendar extends kolab_storage_folder_api
foreach ($overlay as $prop => $value) {
// adjust time of the recurring event instance
if ($prop == 'start' || $prop == 'end') {
if (is_object($event[$prop]) && is_a($event[$prop], 'DateTime'))
if (is_object($event[$prop]) && is_a($event[$prop], 'DateTime')) {
$event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s')));
// set date value if overlay is an exception of the current instance
if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) {
$event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j')));
}
}
}
else if ($prop[0] != '_' && !in_array($prop, $forbidden))
$event[$prop] = $value;

View file

@ -692,9 +692,14 @@ class kolab_driver extends calendar_driver
// removing an exception instance
if ($event['recurrence_id']) {
$i = $event['_instance'] - 1;
if (!empty($master['recurrence']['EXCEPTIONS'][$i])) {
unset($master['recurrence']['EXCEPTIONS'][$i]);
foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
if ($exception['_instance'] == $event['_instance']) {
unset($master['recurrence']['EXCEPTIONS'][$i]);
// set event date back to the actual occurrence
if ($exception['recurrence_date'])
$event['start'] = $exception['recurrence_date'];
break;
}
}
}
@ -879,9 +884,11 @@ class kolab_driver extends calendar_driver
}
// keep saved exceptions (not submitted by the client)
if ($old['recurrence']['EXDATE'])
if ($old['recurrence']['EXDATE'] && !isset($event['recurrence']['EXDATE']))
$event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE'];
if ($old['recurrence']['EXCEPTIONS'])
if (isset($event['recurrence']['EXCEPTIONS']))
$with_exceptions = true; // exceptions already provided (e.g. from iCal import)
else if ($old['recurrence']['EXCEPTIONS'])
$event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS'];
switch ($savemode) {
@ -907,17 +914,22 @@ class kolab_driver extends calendar_driver
$event['recurrence'] = array();
$event['thisandfuture'] = $savemode == 'future';
// TODO: increment sequence if scheduling is affected
// remove some internal properties which should not be saved
unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_notify']);
unset($event['id'], $event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_notify']);
// save properties to a recurrence exception instance
if ($old['recurrence_id']) {
$i = $old['_instance'] - 1;
if (!empty($master['recurrence']['EXCEPTIONS'][$i])) {
$master['recurrence']['EXCEPTIONS'][$i] = $event;
$success = $storage->update_event($master, $old['id']);
break;
if ($old['recurrence_id'] && is_array($master['recurrence']['EXCEPTIONS'])) {
foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
if ($exception['_instance'] == $old['_instance']) {
$event['_instance'] = $old['_instance'];
$event['recurrence_date'] = $old['recurrence_date'];
$master['recurrence']['EXCEPTIONS'][$i] = $event;
$success = $storage->update_event($master, $old['id']);
break 2;
}
}
}
$add_exception = true;
@ -936,6 +948,7 @@ class kolab_driver extends calendar_driver
// save as new exception to master event
if ($add_exception) {
$event['_instance'] = $old['_instance'];
$master['recurrence']['EXCEPTIONS'][] = $event;
}
$success = $storage->update_event($master);
@ -955,10 +968,11 @@ class kolab_driver extends calendar_driver
$new_duration = $event['end']->format('U') - $event['start']->format('U');
$diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration;
$date_shift = $old['start']->diff($event['start']);
// shifted or resized
if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) {
$event['start'] = $master['start']->add($old['start']->diff($event['start']));
$event['start'] = $master['start']->add($date_shift);
$event['end'] = clone $event['start'];
$event['end']->add(new DateInterval('PT'.$new_duration.'S'));
@ -976,6 +990,20 @@ class kolab_driver extends calendar_driver
$event['end'] = $master['end'];
}
// adjust recurrence-id when start changed and therefore the entire recurrence chain changes
if (($old_start_date != $new_start_date || $old_start_time != $new_start_time) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) {
$recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis';
foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) {
$recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] :
rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone());
if (is_a($recurrence_id, 'DateTime')) {
$recurrence_id->add($date_shift);
$event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id;
$event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format);
}
}
}
// unset _dateonly flags in (cached) date objects
unset($event['start']->_dateonly, $event['end']->_dateonly);

View file

@ -318,6 +318,7 @@ class libvcalendar implements Iterator
if (!$seen[$object['uid']]++) {
// parse recurrence exceptions
if ($object['recurrence']) {
$object['recurrence']['EXCEPTIONS'] = array();
foreach ($vobject->children as $component) {
if ($component->name == 'VEVENT' && isset($component->{'RECURRENCE-ID'})) {
try {
@ -455,6 +456,9 @@ class libvcalendar implements Iterator
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':
@ -1151,11 +1155,10 @@ class libvcalendar implements Iterator
// 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'));
$recurrence_id = $this->datetime_prop('RECURRENCE-ID', $exdate, true);
// if ($ex['thisandfuture']) // not supported by any client :-(
// $recurrence_id->add('RANGE', 'THISANDFUTURE');
$exdate = $ex['recurrence_date'] ?: $ex['start'];
$recurrence_id = $this->datetime_prop('RECURRENCE-ID', $exdate, false, (bool)$event['allday']);
if ($ex['thisandfuture'])
$recurrence_id->add('RANGE', 'THISANDFUTURE');
$this->_to_ical($ex, $vcal, $get_attachment, $recurrence_id);
}
}

View file

@ -164,6 +164,7 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase
$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");
}
/**

View file

@ -22,7 +22,7 @@ DTSTART;TZID="W. Europe":20140230T150000
DTEND;TZID="W. Europe":20140230T163000
TRANSP:OPAQUE
RDATE;TZID="W. Europe";VALUE=PERIOD:20140227T140000/20140227T153000
RECURRENCE-ID:20140227T130000Z
RECURRENCE-ID;RANGE=THISANDFUTURE:20140227T130000Z
SEQUENCE:0
UID:7e93e8e8eef16f28aa33b78cd73613ebff
DTSTAMP:20140120T105609Z

View file

@ -94,13 +94,26 @@ class kolab_format_event extends kolab_format_xcal
$this->obj->setStatus($status);
// save recurrence exceptions
if (is_array($object['recurrence']) && $object['recurrence']['EXCEPTIONS']) {
if (is_array($object['recurrence']) && is_array($object['recurrence']['EXCEPTIONS'])) {
$recurrence_id_format = $object['allday'] ? 'Ymd' : 'Ymd\THis';
$vexceptions = new vectorevent;
foreach((array)$object['recurrence']['EXCEPTIONS'] as $i => $exception) {
$exevent = new kolab_format_event;
$exevent->set(($compacted = $this->compact_exception($exception, $object))); // only save differing values
$exevent->obj->setRecurrenceID(self::get_datetime($exception['start'], null, true), (bool)$exception['thisandfuture']);
// get value for recurrence-id
if (!empty($exception['recurrence_date']) && is_a($exception['recurrence_date'], 'DateTime')) {
$recurrence_id = $exception['recurrence_date'];
$compacted['_instance'] = $recurrence_id->format($recurrence_id_format);
}
else if (!empty($exception['_instance']) && strlen($exception['_instance']) > 4) {
$recurrence_id = rcube_utils::anytodatetime($exception['_instance'], $object['start']->getTimezone());
$compacted['recurrence_date'] = $recurrence_id;
}
$exevent->obj->setRecurrenceID(self::get_datetime($recurrence_id ?: $exception['start'], null, $object['allday']), (bool)$exception['thisandfuture']);
$vexceptions->push($exevent->obj);
// write cleaned-up exception data back to memory/cache
$object['recurrence']['EXCEPTIONS'][$i] = $this->expand_exception($compacted, $object);
}
@ -172,15 +185,27 @@ class kolab_format_event extends kolab_format_xcal
// this is an exception object
if ($this->obj->recurrenceID()->isValid()) {
$object['thisandfuture'] = $this->obj->thisAndFuture();
$object['recurrence_date'] = self::php_datetime($this->obj->recurrenceID());
}
// read exception event objects
else if (($exceptions = $this->obj->exceptions()) && is_object($exceptions) && $exceptions->size()) {
$recurrence_exceptions = array();
$recurrence_id_format = $object['allday'] ? 'Ymd' : 'Ymd\THis';
for ($i=0; $i < $exceptions->size(); $i++) {
if (($exobj = $exceptions->get($i))) {
$exception = new kolab_format_event($exobj);
if ($exception->is_valid()) {
$recurrence_exceptions[] = $this->expand_exception($exception->to_array(), $object);
$exdata = $exception->to_array();
// fix date-only recurrence ID saved by old versions
if ($exdata['recurrence_date'] && $exdata['recurrence_date']->_dateonly && !$object['allday']) {
$exdata['recurrence_date']->setTimezone($object['start']->getTimezone());
$exdata['recurrence_date']->setTime($object['start']->format('G'), intval($object['start']->format('i')), intval($object['start']->format('s')));
}
$recurrence_id = $exdata['recurrence_date'] ?: $exdata['start'];
$exdata['_instance'] = $recurrence_id->format($recurrence_id_format);
$recurrence_exceptions[] = $this->expand_exception($exdata, $object);
}
}
}
@ -211,21 +236,21 @@ class kolab_format_event extends kolab_format_xcal
*/
private function compact_exception($exception, $master)
{
$forbidden = array('recurrence','organizer','attendees','sequence');
$forbidden = array('recurrence','organizer','_attachments');
foreach ($forbidden as $prop) {
if (array_key_exists($prop, $exception)) {
unset($exception[$prop]);
foreach ($forbidden as $prop) {
if (array_key_exists($prop, $exception)) {
unset($exception[$prop]);
}
}
}
foreach ($master as $prop => $value) {
if (isset($exception[$prop]) && gettype($exception[$prop]) == gettype($value) && $exception[$prop] == $value) {
unset($exception[$prop]);
foreach ($master as $prop => $value) {
if (isset($exception[$prop]) && gettype($exception[$prop]) == gettype($value) && $exception[$prop] == $value) {
unset($exception[$prop]);
}
}
}
return $exception;
return $exception;
}
/**
@ -233,12 +258,16 @@ class kolab_format_event extends kolab_format_xcal
*/
private function expand_exception($exception, $master)
{
foreach ($master as $prop => $value) {
if (empty($exception[$prop]) && !empty($value))
$exception[$prop] = $value;
}
foreach ($master as $prop => $value) {
if (empty($exception[$prop]) && !empty($value)) {
$exception[$prop] = $value;
if ($prop == 'recurrence') {
unset($exception[$prop]['EXCEPTIONS']);
}
}
}
return $exception;
return $exception;
}
}