From ad55fc706d3f5813f6a0f4b0f93b8970046b4d6d Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 12 Feb 2015 10:08:22 +0100 Subject: [PATCH] Fix handling of Recurrence-ID properties for recurrence exceptions to comply with RFC 5545 (#4385) --- .../calendar/drivers/kolab/kolab_calendar.php | 74 +++++++++++-------- .../calendar/drivers/kolab/kolab_driver.php | 54 ++++++++++---- plugins/libcalendaring/libvcalendar.php | 13 ++-- plugins/libcalendaring/tests/libvcalendar.php | 1 + .../tests/resources/recurrence-id.ics | 2 +- plugins/libkolab/lib/kolab_format_event.php | 65 +++++++++++----- 6 files changed, 140 insertions(+), 69 deletions(-) diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index 9d573eec..2b50ac8a 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -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; diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index b53ffbf2..0d5b9ab5 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -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); diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 127ee36f..4163cfbe 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -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); } } diff --git a/plugins/libcalendaring/tests/libvcalendar.php b/plugins/libcalendaring/tests/libvcalendar.php index 9e2f03bf..f8b0d857 100644 --- a/plugins/libcalendaring/tests/libvcalendar.php +++ b/plugins/libcalendaring/tests/libvcalendar.php @@ -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"); } /** diff --git a/plugins/libcalendaring/tests/resources/recurrence-id.ics b/plugins/libcalendaring/tests/resources/recurrence-id.ics index 41485f9d..8229da28 100644 --- a/plugins/libcalendaring/tests/resources/recurrence-id.ics +++ b/plugins/libcalendaring/tests/resources/recurrence-id.ics @@ -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 diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index 8cad89a1..03b5dde1 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -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; } }