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) private function notify_attendees($event, $old, $action = 'edit', $comment = null, $rsvp = null)
{ {
$is_cancelled = false; $is_cancelled = $action == 'remove'
if ($action == 'remove' || ($event['status'] == 'CANCELLED' && ($old['status'] ?? '') != $event['status'])) { || (!empty($event['status']) && $event['status'] == 'CANCELLED' && ($old['status'] ?? '') != $event['status']);
$event['cancelled'] = true;
$is_cancelled = true; $event['cancelled'] = $is_cancelled;
}
if ($rsvp === null) { if ($rsvp === null) {
$rsvp = !$old || ($event['sequence'] ?? 0) > ($old['sequence'] ?? 0); $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 // find and merge exception for the first instance
if ($virtual && !empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS'])) { if ($virtual && !empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS'])) {
foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { 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']); unset($exception['calendar'], $exception['className'], $exception['_folder_id']);
// clone date objects from main event before adjusting them with exception data // clone date objects from main event before adjusting them with exception data
if (is_object($event['start'])) { if (is_object($event['start'])) {
@ -307,6 +307,7 @@ class caldav_calendar extends kolab_storage_dav_folder
if (is_object($event['end'])) { if (is_object($event['end'])) {
$event['end'] = clone $record['end']; $event['end'] = clone $record['end'];
} }
kolab_driver::merge_exception_data($event, $exception); 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 = kolab_storage_config::get_instance();
// $config->apply_links($events); // $config->apply_links($events);
// Avoid session race conditions that will loose temporary subscriptions
// $this->cal->rc->session->nowrite = true;
return $events; return $events;
} }
@ -649,7 +647,7 @@ class caldav_calendar extends kolab_storage_dav_folder
return $events; return $events;
} }
// use libkolab to compute recurring events // use libcalendaring to compute recurring events
$recurrence = libcalendaring::get_recurrence($event); $recurrence = libcalendaring::get_recurrence($event);
$i = 0; $i = 0;

View file

@ -829,7 +829,7 @@ class kolab_driver extends calendar_driver
{ {
$ret = true; $ret = true;
$success = false; $success = false;
$savemode = isset($event['_savemode']) ? $event['_savemode'] : null; $savemode = $event['_savemode'] ?? null;
if (!$force) { if (!$force) {
unset($event['attendees']); unset($event['attendees']);
@ -864,7 +864,7 @@ class kolab_driver extends calendar_driver
// removing an exception instance // removing an exception instance
if ((!empty($event['recurrence_id']) || !empty($event['isexception'])) && !empty($master['exceptions'])) { if ((!empty($event['recurrence_id']) || !empty($event['isexception'])) && !empty($master['exceptions'])) {
foreach ($master['exceptions'] as $i => $exception) { foreach ($master['exceptions'] as $i => $exception) {
if ($exception['_instance'] == $event['_instance']) { if (libcalendaring::is_recurrence_exception($event, $exception)) {
unset($master['exceptions'][$i]); unset($master['exceptions'][$i]);
// set event date back to the actual occurrence // set event date back to the actual occurrence
if (!empty($exception['recurrence_date'])) { if (!empty($exception['recurrence_date'])) {
@ -1071,6 +1071,7 @@ class kolab_driver extends calendar_driver
} }
// Stick to the master timezone for all occurrences (Bifrost#T104637) // Stick to the master timezone for all occurrences (Bifrost#T104637)
if (empty($master['allday']) || !empty($event['allday'])) {
$master_tz = $master['start']->getTimezone(); $master_tz = $master['start']->getTimezone();
$event_tz = $event['start']->getTimezone(); $event_tz = $event['start']->getTimezone();
@ -1079,6 +1080,7 @@ class kolab_driver extends calendar_driver
$event['end']->setTimezone($master_tz); $event['end']->setTimezone($master_tz);
} }
} }
}
// check if update affects scheduling and update attendee status accordingly // check if update affects scheduling and update attendee status accordingly
$reschedule = $this->check_scheduling($event, $old, true); $reschedule = $this->check_scheduling($event, $old, true);
@ -1138,7 +1140,8 @@ class kolab_driver extends calendar_driver
if ($reschedule) { if ($reschedule) {
unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']); unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']);
} }
else if (isset($event['recurrence']['EXCEPTIONS']) && is_array($event['recurrence']['EXCEPTIONS'])) { else {
if (isset($event['recurrence']['EXCEPTIONS']) && is_array($event['recurrence']['EXCEPTIONS'])) {
// only keep relevant exceptions // only keep relevant exceptions
$event['recurrence']['EXCEPTIONS'] = array_filter( $event['recurrence']['EXCEPTIONS'] = array_filter(
$event['recurrence']['EXCEPTIONS'], $event['recurrence']['EXCEPTIONS'],
@ -1146,6 +1149,11 @@ class kolab_driver extends calendar_driver
return $exception['start'] > $event['start']; 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'])) { if (isset($event['recurrence']['EXDATE']) && is_array($event['recurrence']['EXDATE'])) {
$event['recurrence']['EXDATE'] = array_filter( $event['recurrence']['EXDATE'] = array_filter(
$event['recurrence']['EXDATE'], $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 // compute remaining occurrences
@ -1223,7 +1229,7 @@ class kolab_driver extends calendar_driver
$event['sequence'] = max($old['sequence'] ?? 0, $master['sequence'] ?? 0) + 1; $event['sequence'] = max($old['sequence'] ?? 0, $master['sequence'] ?? 0) + 1;
} }
else if (!isset($event['sequence'])) { 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 // 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) // 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 // determine added and removed attendees
$old_attendees = $current_attendees = $added_attendees = []; $old_attendees = $current_attendees = $added_attendees = [];
@ -1464,7 +1470,7 @@ class kolab_driver extends calendar_driver
foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
// update a specific instance // update a specific instance
if ($exception['_instance'] == $old['_instance']) { if (libcalendaring::is_recurrence_exception($old, $exception)) {
$existing = $i; $existing = $i;
// check savemode against existing exception mode. // check savemode against existing exception mode.
@ -1472,7 +1478,7 @@ class kolab_driver extends calendar_driver
$thisandfuture = !empty($exception['thisandfuture']); $thisandfuture = !empty($exception['thisandfuture']);
if ($thisandfuture === ($savemode == 'future')) { if ($thisandfuture === ($savemode == 'future')) {
$event['_instance'] = $old['_instance']; $event['_instance'] = $old['_instance'];
$event['thisandfuture'] = $old['thisandfuture']; $event['thisandfuture'] = !empty($old['thisandfuture']);
$event['recurrence_date'] = $old['recurrence_date']; $event['recurrence_date'] = $old['recurrence_date'];
$master['recurrence']['EXCEPTIONS'][$i] = $event; $master['recurrence']['EXCEPTIONS'][$i] = $event;
$saved = true; $saved = true;
@ -1480,7 +1486,11 @@ class kolab_driver extends calendar_driver
} }
// merge the new event properties onto future exceptions // merge the new event properties onto future exceptions
if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { if ($savemode == 'future') {
$exception_instance = libcalendaring::recurrence_instance_identifier($exception, true);
$old_instance = libcalendaring::recurrence_instance_identifier($old, true);
if ($exception_instance >= $old_instance) {
unset($event['thisandfuture']); unset($event['thisandfuture']);
self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, ['attendees']); self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, ['attendees']);
@ -1489,6 +1499,7 @@ class kolab_driver extends calendar_driver
} }
} }
} }
}
/* /*
// we could not update the existing exception due to savemode mismatch... // we could not update the existing exception due to savemode mismatch...
if (!$saved && isset($existing) && !empty($master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture'])) { if (!$saved && isset($existing) && !empty($master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture'])) {
@ -1525,7 +1536,7 @@ class kolab_driver extends calendar_driver
public static function add_exception(&$master, $event, $old = null) public static function add_exception(&$master, $event, $old = null)
{ {
if ($old) { if ($old) {
$event['_instance'] = $old['_instance']; $event['_instance'] = $old['_instance'] ?? null;
if (empty($event['recurrence_date'])) { if (empty($event['recurrence_date'])) {
$event['recurrence_date'] = !empty($old['recurrence_date']) ? $old['recurrence_date'] : $old['start']; $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']; $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['exceptions'])) {
if (isset($master['recurrence']['EXCEPTIONS'])) { if (isset($master['recurrence']['EXCEPTIONS'])) {
$master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; $master['exceptions'] = &$master['recurrence']['EXCEPTIONS'];
@ -1549,7 +1556,7 @@ class kolab_driver extends calendar_driver
$existing = false; $existing = false;
foreach ($master['exceptions'] as $i => $exception) { foreach ($master['exceptions'] as $i => $exception) {
if ($exception['_instance'] == $event['_instance']) { if (libcalendaring::is_recurrence_exception($event, $exception)) {
$master['exceptions'][$i] = $event; $master['exceptions'][$i] = $event;
$existing = true; $existing = true;
} }
@ -2139,7 +2146,7 @@ class kolab_driver extends calendar_driver
return $record; return $record;
} }
$record['id'] = $record['uid']; $record['id'] = $record['uid'] ?? null;
if (!empty($record['_instance'])) { if (!empty($record['_instance'])) {
$record['id'] .= '-' . $record['_instance']; $record['id'] .= '-' . $record['_instance'];

View file

@ -33,6 +33,7 @@ class libcalendaring_recurrence
protected $dateonly = false; protected $dateonly = false;
protected $event; protected $event;
protected $duration; protected $duration;
protected $isStart = true;
/** /**
* Default constructor * Default constructor
@ -109,7 +110,23 @@ class libcalendaring_recurrence
*/ */
public function next_instance() 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 = $this->event;
$next['start'] = $next_start; $next['start'] = $next_start;
@ -119,7 +136,7 @@ class libcalendaring_recurrence
} }
$next['recurrence_date'] = clone $next_start; $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']); 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 *********/ /********* 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',
'2017-09-08 11:00:00', '2017-09-08 11:00:00',
), ),
/*
array( array(
array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'), array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'),
'2017-08-31 11:00:00', '2017-08-31 11:00:00',
'2017-09-08 11:00:00', '2017-09-08 11:00:00',
), ),
*/
array( array(
array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'), array('FREQ' => 'MONTHLY', 'INTERVAL' => '1', 'BYMONTHDAY' => '8,9'),
'2017-09-08 11:00:00', '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',
'2017-09-08 11:00:00', '2017-09-08 11:00:00',
), ),
/*
array( array(
array('FREQ' => 'MONTHLY', 'INTERVAL' => '2', 'BYMONTHDAY' => '8'), array('FREQ' => 'MONTHLY', 'INTERVAL' => '2', 'BYMONTHDAY' => '8'),
'2017-08-31 11:00:00', '2017-08-31 11:00:00',
'2017-09-08 11:00:00', // ?????? '2017-09-08 11:00:00', // ??????
), ),
*/
// yearly // yearly
array( array(
array('FREQ' => 'YEARLY', 'INTERVAL' => '1'), array('FREQ' => 'YEARLY', 'INTERVAL' => '1'),
@ -204,6 +200,7 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
'2017-08-16 12:00:00', '2017-08-16 12:00:00',
), ),
/* /*
// Not supported by Sabre (requires BYMONTH too)
array( array(
array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYDAY' => '-1MO'), array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYDAY' => '-1MO'),
'2017-08-16 11:00:00', '2017-08-16 11:00:00',
@ -215,7 +212,6 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
'2017-08-16 11:00:00', '2017-08-16 11:00:00',
'2017-08-28 11:00:00', '2017-08-28 11:00:00',
), ),
/*
array( array(
array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1', 'BYDAY' => '1MO'), array('FREQ' => 'YEARLY', 'INTERVAL' => '1', 'BYMONTH' => '1', 'BYDAY' => '1MO'),
'2017-08-16 11:00:00', '2017-08-16 11:00:00',
@ -226,7 +222,6 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
'2017-08-16 11:00:00', '2017-08-16 11:00:00',
'2017-09-04 11:00:00', '2017-09-04 11:00:00',
), ),
*/
array( array(
array('FREQ' => 'YEARLY', 'INTERVAL' => '2'), array('FREQ' => 'YEARLY', 'INTERVAL' => '2'),
'2017-08-16 11:00:00', '2017-08-16 11:00:00',
@ -238,6 +233,7 @@ class RecurrenceTest extends PHPUnit\Framework\TestCase
'2017-08-16 11:00:00', '2017-08-16 11:00:00',
), ),
/* /*
// Not supported by Sabre (requires BYMONTH too)
array( array(
array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYDAY' => '-1MO'), array('FREQ' => 'YEARLY', 'INTERVAL' => '2', 'BYDAY' => '-1MO'),
'2017-08-16 11:00:00', '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->assertEquals($start->getTimezone()->getName(), $next['start']->getTimezone()->getName(), 'Same timezone');
$this->assertTrue($next['start']->_dateonly, '_dateonly flag'); $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());
}
} }