Fix various calendar recurrence issues

This commit is contained in:
Aleksander Machniak 2022-12-20 14:15:39 +01:00
parent 371a664e92
commit a0c564f946
6 changed files with 189 additions and 51 deletions

View file

@ -2457,11 +2457,10 @@ $("#rcmfd_new_category").keypress(function(event) {
*/
private function notify_attendees($event, $old, $action = 'edit', $comment = null, $rsvp = null)
{
$is_cancelled = false;
if ($action == 'remove' || ($event['status'] == 'CANCELLED' && ($old['status'] ?? '') != $event['status'])) {
$event['cancelled'] = true;
$is_cancelled = true;
}
$is_cancelled = $action == 'remove'
|| (!empty($event['status']) && $event['status'] == 'CANCELLED' && ($old['status'] ?? '') != $event['status']);
$event['cancelled'] = $is_cancelled;
if ($rsvp === null) {
$rsvp = !$old || ($event['sequence'] ?? 0) > ($old['sequence'] ?? 0);

View file

@ -298,7 +298,7 @@ class caldav_calendar extends kolab_storage_dav_folder
// find and merge exception for the first instance
if ($virtual && !empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS'])) {
foreach ($event['recurrence']['EXCEPTIONS'] as $exception) {
if ($event['_instance'] == $exception['_instance']) {
if (libcalendaring::is_recurrence_exception($event, $exception)) {
unset($exception['calendar'], $exception['className'], $exception['_folder_id']);
// clone date objects from main event before adjusting them with exception data
if (is_object($event['start'])) {
@ -307,6 +307,7 @@ class caldav_calendar extends kolab_storage_dav_folder
if (is_object($event['end'])) {
$event['end'] = clone $record['end'];
}
kolab_driver::merge_exception_data($event, $exception);
}
}
@ -365,9 +366,6 @@ class caldav_calendar extends kolab_storage_dav_folder
// $config = kolab_storage_config::get_instance();
// $config->apply_links($events);
// Avoid session race conditions that will loose temporary subscriptions
// $this->cal->rc->session->nowrite = true;
return $events;
}
@ -649,7 +647,7 @@ class caldav_calendar extends kolab_storage_dav_folder
return $events;
}
// use libkolab to compute recurring events
// use libcalendaring to compute recurring events
$recurrence = libcalendaring::get_recurrence($event);
$i = 0;

View file

@ -829,7 +829,7 @@ class kolab_driver extends calendar_driver
{
$ret = true;
$success = false;
$savemode = isset($event['_savemode']) ? $event['_savemode'] : null;
$savemode = $event['_savemode'] ?? null;
if (!$force) {
unset($event['attendees']);
@ -864,7 +864,7 @@ class kolab_driver extends calendar_driver
// removing an exception instance
if ((!empty($event['recurrence_id']) || !empty($event['isexception'])) && !empty($master['exceptions'])) {
foreach ($master['exceptions'] as $i => $exception) {
if ($exception['_instance'] == $event['_instance']) {
if (libcalendaring::is_recurrence_exception($event, $exception)) {
unset($master['exceptions'][$i]);
// set event date back to the actual occurrence
if (!empty($exception['recurrence_date'])) {
@ -1071,12 +1071,14 @@ class kolab_driver extends calendar_driver
}
// Stick to the master timezone for all occurrences (Bifrost#T104637)
$master_tz = $master['start']->getTimezone();
$event_tz = $event['start']->getTimezone();
if (empty($master['allday']) || !empty($event['allday'])) {
$master_tz = $master['start']->getTimezone();
$event_tz = $event['start']->getTimezone();
if ($master_tz->getName() != $event_tz->getName()) {
$event['start']->setTimezone($master_tz);
$event['end']->setTimezone($master_tz);
if ($master_tz->getName() != $event_tz->getName()) {
$event['start']->setTimezone($master_tz);
$event['end']->setTimezone($master_tz);
}
}
}
@ -1138,14 +1140,20 @@ class kolab_driver extends calendar_driver
if ($reschedule) {
unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']);
}
else if (isset($event['recurrence']['EXCEPTIONS']) && is_array($event['recurrence']['EXCEPTIONS'])) {
// only keep relevant exceptions
$event['recurrence']['EXCEPTIONS'] = array_filter(
$event['recurrence']['EXCEPTIONS'],
function($exception) use ($event) {
return $exception['start'] > $event['start'];
}
);
else {
if (isset($event['recurrence']['EXCEPTIONS']) && is_array($event['recurrence']['EXCEPTIONS'])) {
// only keep relevant exceptions
$event['recurrence']['EXCEPTIONS'] = array_filter(
$event['recurrence']['EXCEPTIONS'],
function($exception) use ($event) {
return $exception['start'] > $event['start'];
}
);
// set link to top-level exceptions
$event['exceptions'] = &$event['recurrence']['EXCEPTIONS'];
}
if (isset($event['recurrence']['EXDATE']) && is_array($event['recurrence']['EXDATE'])) {
$event['recurrence']['EXDATE'] = array_filter(
$event['recurrence']['EXDATE'],
@ -1154,8 +1162,6 @@ class kolab_driver extends calendar_driver
}
);
}
// set link to top-level exceptions
$event['exceptions'] = &$event['recurrence']['EXCEPTIONS'];
}
// compute remaining occurrences
@ -1223,7 +1229,7 @@ class kolab_driver extends calendar_driver
$event['sequence'] = max($old['sequence'] ?? 0, $master['sequence'] ?? 0) + 1;
}
else if (!isset($event['sequence'])) {
$event['sequence'] = !empty($old['sequence']) ? $old['sequence'] : $master['sequence'];
$event['sequence'] = !empty($old['sequence']) ? $old['sequence'] : $master['sequence'] ?? 1;
}
// save properties to a recurrence exception instance
@ -1306,7 +1312,7 @@ class kolab_driver extends calendar_driver
}
// TODO: forward changes to exceptions (which do not yet have differing values stored)
if (!empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) {
if (!empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS']) && empty($with_exceptions)) {
// determine added and removed attendees
$old_attendees = $current_attendees = $added_attendees = [];
@ -1464,7 +1470,7 @@ class kolab_driver extends calendar_driver
foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
// update a specific instance
if ($exception['_instance'] == $old['_instance']) {
if (libcalendaring::is_recurrence_exception($old, $exception)) {
$existing = $i;
// check savemode against existing exception mode.
@ -1472,7 +1478,7 @@ class kolab_driver extends calendar_driver
$thisandfuture = !empty($exception['thisandfuture']);
if ($thisandfuture === ($savemode == 'future')) {
$event['_instance'] = $old['_instance'];
$event['thisandfuture'] = $old['thisandfuture'];
$event['thisandfuture'] = !empty($old['thisandfuture']);
$event['recurrence_date'] = $old['recurrence_date'];
$master['recurrence']['EXCEPTIONS'][$i] = $event;
$saved = true;
@ -1480,12 +1486,17 @@ class kolab_driver extends calendar_driver
}
// merge the new event properties onto future exceptions
if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) {
unset($event['thisandfuture']);
self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, ['attendees']);
if ($savemode == 'future') {
$exception_instance = libcalendaring::recurrence_instance_identifier($exception, true);
$old_instance = libcalendaring::recurrence_instance_identifier($old, true);
if (!empty($added_attendees) || !empty($removed_attendees)) {
calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees);
if ($exception_instance >= $old_instance) {
unset($event['thisandfuture']);
self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, ['attendees']);
if (!empty($added_attendees) || !empty($removed_attendees)) {
calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees);
}
}
}
}
@ -1525,7 +1536,7 @@ class kolab_driver extends calendar_driver
public static function add_exception(&$master, $event, $old = null)
{
if ($old) {
$event['_instance'] = $old['_instance'];
$event['_instance'] = $old['_instance'] ?? null;
if (empty($event['recurrence_date'])) {
$event['recurrence_date'] = !empty($old['recurrence_date']) ? $old['recurrence_date'] : $old['start'];
}
@ -1534,10 +1545,6 @@ class kolab_driver extends calendar_driver
$event['recurrence_date'] = $event['start'];
}
if (empty($event['_instance']) && $event['recurrence_date'] instanceof DateTimeInterface) {
$event['_instance'] = libcalendaring::recurrence_instance_identifier($event, !empty($master['allday']));
}
if (!isset($master['exceptions'])) {
if (isset($master['recurrence']['EXCEPTIONS'])) {
$master['exceptions'] = &$master['recurrence']['EXCEPTIONS'];
@ -1549,7 +1556,7 @@ class kolab_driver extends calendar_driver
$existing = false;
foreach ($master['exceptions'] as $i => $exception) {
if ($exception['_instance'] == $event['_instance']) {
if (libcalendaring::is_recurrence_exception($event, $exception)) {
$master['exceptions'][$i] = $event;
$existing = true;
}
@ -2139,7 +2146,7 @@ class kolab_driver extends calendar_driver
return $record;
}
$record['id'] = $record['uid'];
$record['id'] = $record['uid'] ?? null;
if (!empty($record['_instance'])) {
$record['id'] .= '-' . $record['_instance'];

View file

@ -33,6 +33,7 @@ class libcalendaring_recurrence
protected $dateonly = false;
protected $event;
protected $duration;
protected $isStart = true;
/**
* Default constructor
@ -109,7 +110,23 @@ class libcalendaring_recurrence
*/
public function next_instance()
{
if ($next_start = $this->next_start()) {
// Here's the workaround for an issue for an event with its start date excluded
// E.g. A daily event starting on 10th which is one of EXDATE dates
// should return 11th as next_instance() when called for the first time.
// Looks like Sabre is setting internal "current date" to 11th on such an object
// initialization, therefore calling next() would move it to 12th.
if ($this->isStart && ($next_start = $this->engine->getDtStart())
&& $next_start->format('Ymd') != $this->start->format('Ymd')
) {
$next_start = $this->toDateTime($next_start);
}
else {
$next_start = $this->next_start();
}
$this->isStart = false;
if ($next_start) {
$next = $this->event;
$next['start'] = $next_start;
@ -119,7 +136,7 @@ class libcalendaring_recurrence
}
$next['recurrence_date'] = clone $next_start;
$next['_instance'] = libcalendaring::recurrence_instance_identifier($next, !empty($this->event['allday']));
$next['_instance'] = libcalendaring::recurrence_instance_identifier($next);
unset($next['_formatobj']);

View file

@ -1348,6 +1348,27 @@ class libcalendaring extends rcube_plugin
}
}
/**
* Check if a specified event is "identical" to the specified recurrence exception
*
* @param array Hash array with occurrence properties
* @param array Hash array with exception properties
*
* @return bool
*/
public static function is_recurrence_exception($event, $exception)
{
$instance_date = !empty($event['recurrence_date']) ? $event['recurrence_date'] : $event['start'];
$exception_date = !empty($exception['recurrence_date']) ? $exception['recurrence_date'] : $exception['start'];
if ($instance_date instanceof DateTimeInterface && $exception_date instanceof DateTimeInterface) {
// Timezone???
return $instance_date->format('Ymd') === $exception_date->format('Ymd');
}
return false;
}
/********* Attendee handling functions *********/

View file

@ -158,13 +158,11 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
'2017-09-08 11:00:00',
'2017-09-08 11:00:00',
),
/*
array(
array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'),
'2017-08-31 11:00:00',
'2017-09-08 11:00:00',
),
*/
array(
array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'),
'2017-09-08 11:00:00',
@ -185,13 +183,11 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
'2017-09-08 11:00:00',
'2017-09-08 11:00:00',
),
/*
array(
array('FREQ' => 'MONTHLY', 'INTERVAL' => '2', 'BYMONTHDAY' => '8'),
'2017-08-31 11:00:00',
'2017-09-08 11:00:00', // ??????
),
*/
// yearly
array(
array('FREQ' => 'YEARLY', 'INTERVAL' => '1'),
@ -204,6 +200,7 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
'2017-08-16 12:00:00',
),
/*
// Not supported by Sabre (requires BYMONTH too)
array(
array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYDAY' => '-1MO'),
'2017-08-16 11:00:00',
@ -215,7 +212,6 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
'2017-08-16 11:00:00',
'2017-08-28 11:00:00',
),
/*
array(
array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1', 'BYDAY' => '1MO'),
'2017-08-16 11:00:00',
@ -226,7 +222,6 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
'2017-08-16 11:00:00',
'2017-09-04 11:00:00',
),
*/
array(
array('FREQ' => 'YEARLY', 'INTERVAL' => '2'),
'2017-08-16 11:00:00',
@ -238,6 +233,7 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
'2017-08-16 11:00:00',
),
/*
// Not supported by Sabre (requires BYMONTH too)
array(
array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYDAY' => '-1MO'),
'2017-08-16 11:00:00',
@ -334,4 +330,104 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
$this->assertEquals($start->getTimezone()->getName(), $next['start']->getTimezone()->getName(), 'Same timezone');
$this->assertTrue($next['start']->_dateonly, '_dateonly flag');
}
/**
* Test for libcalendaring_recurrence::next_instance()
*/
function test_next_instance_exdate()
{
date_default_timezone_set('America/New_York');
$start = new libcalendaring_datetime('2023-01-18 10:00:00', new DateTimeZone('Europe/Berlin'));
$end = new libcalendaring_datetime('2023-01-18 10:30:00', new DateTimeZone('Europe/Berlin'));
$event = [
'start' => $start,
'end' => $end,
'recurrence' => [
'FREQ' => 'DAILY',
'INTERVAL' => '1',
'EXDATE' => [
// Exclude the start date
new libcalendaring_datetime('2023-01-18 10:00:00', new DateTimeZone('Europe/Berlin')),
],
],
];
$recurrence = new libcalendaring_recurrence($this->plugin, $event);
$next = $recurrence->next_instance();
$this->assertEquals('2023-01-19 10:00:00', $next['start']->format('Y-m-d H:i:s'));
$this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
$this->assertFalse($next['start']->_dateonly);
$next = $recurrence->next_instance();
$this->assertEquals('2023-01-20 10:00:00', $next['start']->format('Y-m-d H:i:s'));
$this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
$this->assertFalse($next['start']->_dateonly);
}
/**
* Test for libcalendaring_recurrence::next_instance()
*/
function test_next_instance_dst()
{
date_default_timezone_set('America/New_York');
$start = new libcalendaring_datetime('2021-03-10 10:00:00', new DateTimeZone('Europe/Berlin'));
$end = new libcalendaring_datetime('2021-03-10 10:30:00', new DateTimeZone('Europe/Berlin'));
$event = [
'start' => $start,
'end' => $end,
'recurrence' => [
'FREQ' => 'MONTHLY',
'INTERVAL' => '1',
],
];
$recurrence = new libcalendaring_recurrence($this->plugin, $event);
$next = $recurrence->next_instance();
$this->assertEquals('2021-04-10 10:00:00', $next['start']->format('Y-m-d H:i:s'));
$this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
$next = $recurrence->next_instance();
$this->assertEquals('2021-05-10 10:00:00', $next['start']->format('Y-m-d H:i:s'));
$this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
$start = new libcalendaring_datetime('2021-10-10 10:00:00', new DateTimeZone('Europe/Berlin'));
$end = new libcalendaring_datetime('2021-10-10 10:30:00', new DateTimeZone('Europe/Berlin'));
$event = [
'start' => $start,
'end' => $end,
'recurrence' => [
'FREQ' => 'MONTHLY',
'INTERVAL' => '1',
],
];
$recurrence = new libcalendaring_recurrence($this->plugin, $event);
$next = $recurrence->next_instance();
$this->assertEquals('2021-11-10 10:00:00', $next['start']->format('Y-m-d H:i:s'));
$this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
$next = $recurrence->next_instance();
$this->assertEquals('2021-12-10 10:00:00', $next['start']->format('Y-m-d H:i:s'));
$this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
$next = $recurrence->next_instance();
$next = $recurrence->next_instance();
$next = $recurrence->next_instance();
$next = $recurrence->next_instance();
$this->assertEquals('2022-04-10 10:00:00', $next['start']->format('Y-m-d H:i:s'));
$this->assertEquals('Europe/Berlin', $next['start']->getTimezone()->getName());
}
}