* Calendar: fix generating/importing exceptions on recurring events using explicit RDATEs instead of a RRULE

also correctly recognize Windows timezone names without "Standard Time" postfix like "Romance" instead of "Romance Standard Time" for "Europe/Paris"
This commit is contained in:
ralf 2024-06-04 15:30:54 +02:00
parent cc98141e86
commit 2bfcc20856
6 changed files with 97 additions and 67 deletions

View File

@ -128,7 +128,8 @@ class calendar_bo
MCAL_RECUR_WEEKLY => 'Weekly', MCAL_RECUR_WEEKLY => 'Weekly',
MCAL_RECUR_MONTHLY_WDAY => 'Monthly (by day)', MCAL_RECUR_MONTHLY_WDAY => 'Monthly (by day)',
MCAL_RECUR_MONTHLY_MDAY => 'Monthly (by date)', MCAL_RECUR_MONTHLY_MDAY => 'Monthly (by date)',
MCAL_RECUR_YEARLY => 'Yearly' MCAL_RECUR_YEARLY => 'Yearly',
MCAL_RECUR_RDATE/*calendar_rrule::PERIOD*/ => 'Explicit dates',
); );
/** /**
* @var array recur_days translates MCAL recur-days to verbose labels * @var array recur_days translates MCAL recur-days to verbose labels
@ -946,7 +947,7 @@ class calendar_bo
foreach($events as $event) foreach($events as $event)
{ {
// PERIOD // PERIOD
$is_exception = $event['recur_type'] != calendar_rrule::PERIOD && in_array(Api\DateTime::to($event['start'], true), $exceptions); $is_exception = in_array(Api\DateTime::to($event['start'], true), $exceptions);
$start = $this->date2ts($event['start'],true); $start = $this->date2ts($event['start'],true);
if ($event['whole_day']) if ($event['whole_day'])
{ {
@ -1024,10 +1025,11 @@ class calendar_bo
$event[$ts] = $this->date2usertime((int)$event[$ts],$date_format); $event[$ts] = $this->date2usertime((int)$event[$ts],$date_format);
} }
} }
// same with the recur exceptions // same with the recur exceptions and rdates
if (isset($event['recur_exception']) && is_array($event['recur_exception'])) foreach(['recur_exception', 'recur_rdates'] as $name)
{ {
foreach($event['recur_exception'] as &$date) if (is_array($event[$name] ?? null)) continue;
foreach($event[$name] as &$date)
{ {
if ($event['whole_day'] && $date_format != 'server') if ($event['whole_day'] && $date_format != 'server')
{ {
@ -1139,7 +1141,6 @@ class calendar_bo
* @param mixed $start start-date * @param mixed $start start-date
* @param mixed $end end-date * @param mixed $end end-date
* @param array $events where the repetions get inserted * @param array $events where the repetions get inserted
* @param array $recur_exceptions with date (in Ymd) as key (and True as values), seems not to be used anymore
*/ */
function insert_all_recurrences($event,$_start,$end,&$events) function insert_all_recurrences($event,$_start,$end,&$events)
{ {
@ -1165,20 +1166,15 @@ class calendar_bo
new Api\DateTime($event['recur_enddate'], calendar_timezones::DateTimeZone($event['tzid'])); new Api\DateTime($event['recur_enddate'], calendar_timezones::DateTimeZone($event['tzid']));
// unset exceptions, as we need to add them as recurrence too, but marked as exception // unset exceptions, as we need to add them as recurrence too, but marked as exception
// (Period needs them though) unset($event['recur_exception']);
if($event['recur_type'] != calendar_rrule::PERIOD)
{
unset($event['recur_exception']);
}
// loop over all recurrences and insert them, if they are after $start // loop over all recurrences and insert them, if they are after $start
$rrule = calendar_rrule::event2rrule($event, !$event['whole_day'], // true = we operate in usertime, like the rest of calendar_bo $rrule = calendar_rrule::event2rrule($event, !$event['whole_day'], // true = we operate in usertime, like the rest of calendar_bo
// For whole day events, just stay in server time // For whole day events, just stay in server time
$event['whole_day'] ? Api\DateTime::$server_timezone->getName() : Api\DateTime::$user_timezone->getName() $event['whole_day'] ? Api\DateTime::$server_timezone->getName() : Api\DateTime::$user_timezone->getName()
); );
if($event['recur_type'] == calendar_rrule::PERIOD) unset($event['recur_rdates']);
{ $event['recur_type'] = MCAL_RECUR_NONE;
unset($event['recur_exception']);
}
foreach($rrule as $time) foreach($rrule as $time)
{ {
// $time is in timezone of event, convert it to usertime used here // $time is in timezone of event, convert it to usertime used here
@ -1194,7 +1190,7 @@ class calendar_bo
if (($ts = $this->date2ts($time)) < $start-$event_length) if (($ts = $this->date2ts($time)) < $start-$event_length)
{ {
//echo "<p>".$time." --> ignored as $ts < $start-$event_length</p>\n"; //echo "<p>".$time." --> ignored as $ts < $start-$event_length</p>\n";
continue; // to early or original event (returned by interator too) continue; // to early or original event (returned by iterator too)
} }
$ts_end = $ts + $event_length; $ts_end = $ts + $event_length;
@ -1230,7 +1226,7 @@ class calendar_bo
} }
/** /**
* Adds one repetion of $event for $date_ymd to the $events array, after adjusting its start- and end-time * Adds one repetition of $event for $date_ymd to the $events array, after adjusting its start- and end-time
* *
* @param array $events array in which the event gets inserted * @param array $events array in which the event gets inserted
* @param array $event event to insert, it has start- and end-date of the first recurrence, not of $date_ymd * @param array $event event to insert, it has start- and end-date of the first recurrence, not of $date_ymd

View File

@ -1404,7 +1404,7 @@ class calendar_boupdate extends calendar_bo
$this->check_reset_statuses($event, $old_event); $this->check_reset_statuses($event, $old_event);
// set recur-enddate/range-end to real end-date of last recurrence // set recur-enddate/range-end to real end-date of last recurrence
if (!empty($event['recur_type']) && $event['recur_enddate'] && $event['start']) if (!empty($event['recur_type']) && (!empty($event['recur_enddate']) || $event['recur_type'] == calendar_rrule::PERIOD) && $event['start'])
{ {
$event['recur_enddate'] = new Api\DateTime($event['recur_enddate'], calendar_timezones::DateTimeZone($event['tzid'])); $event['recur_enddate'] = new Api\DateTime($event['recur_enddate'], calendar_timezones::DateTimeZone($event['tzid']));
$event['recur_enddate']->setTime(23,59,59); $event['recur_enddate']->setTime(23,59,59);
@ -1485,10 +1485,11 @@ class calendar_boupdate extends calendar_bo
{ {
$event['tz_id'] = calendar_timezones::tz2id($event['tzid'] = Api\DateTime::$user_timezone->getName()); $event['tz_id'] = calendar_timezones::tz2id($event['tzid'] = Api\DateTime::$user_timezone->getName());
} }
// same with the recur exceptions // same with the recur exceptions and rdates
if (isset($event['recur_exception']) && is_array($event['recur_exception'])) foreach(['recur_exception', 'recur_rdates'] as $name)
{ {
foreach($event['recur_exception'] as &$date) if (!is_array($event[$name] ?? null)) continue;
foreach($event[$name] as &$date)
{ {
if ($event['whole_day']) if ($event['whole_day'])
{ {
@ -2400,9 +2401,9 @@ class calendar_boupdate extends calendar_bo
* *
* @param array $event the vCalendar data we try to find * @param array $event the vCalendar data we try to find
* @param string filter='exact' exact -> find the matching entry * @param string filter='exact' exact -> find the matching entry
* check -> check (consitency) for identical matches * check -> check (consistency) for identical matches
* relax -> be more tolerant * relax -> be more tolerant
* master -> try to find a releated series master * master -> try to find a related series master
* @return array calendar_ids of matching entries * @return array calendar_ids of matching entries
*/ */
function find_event($event, $filter='exact') function find_event($event, $filter='exact')
@ -3120,12 +3121,12 @@ class calendar_boupdate extends calendar_bo
// we convert here from server-time to timestamps in user-time! // we convert here from server-time to timestamps in user-time!
if (isset($event[$ts])) $event[$ts] = $event[$ts] ? $this->date2usertime($event[$ts]) : 0; if (isset($event[$ts])) $event[$ts] = $event[$ts] ? $this->date2usertime($event[$ts]) : 0;
} }
// same with the recur exceptions // same with the recur exceptions and rdates
if (isset($event['recur_exception']) && is_array($event['recur_exception'])) foreach(['recur_exception', 'recur_rdates'] as $name)
{ {
foreach($event['recur_exception'] as $n => $date) foreach($event[$name] ?? [] as $n => $date)
{ {
$event['recur_exception'][$n] = $this->date2usertime($date); $event[$name][$n] = $this->date2usertime($date);
} }
} }
// same with the alarms // same with the alarms
@ -3137,6 +3138,7 @@ class calendar_boupdate extends calendar_bo
} }
} }
} }
/** /**
* Delete events that are more than $age years old * Delete events that are more than $age years old
* *

View File

@ -2760,10 +2760,10 @@ class calendar_ical extends calendar_boupdate
if($attributes['params']['VALUE'] == 'PERIOD') if($attributes['params']['VALUE'] == 'PERIOD')
{ {
$vcardData['recur_type'] = calendar_rrule::PERIOD; $vcardData['recur_type'] = calendar_rrule::PERIOD;
$vcardData['recur_exception'] = []; $vcardData['recur_rdates'] = [];
foreach($attributes['values'] as $date) foreach($attributes['values'] as $date)
{ {
$vcardData['recur_exception'][] = mktime( $vcardData['recur_rdates'][] = mktime(
$hour, $hour,
$minutes, $minutes,
$seconds, $seconds,
@ -3180,7 +3180,7 @@ class calendar_ical extends calendar_boupdate
$event['priority'] = 2; // default $event['priority'] = 2; // default
$event['alarm'] = $alarms; $event['alarm'] = $alarms;
// now that we know what the vard provides, // now that we know what the ical provides,
// we merge that data with the information we have about the device // we merge that data with the information we have about the device
while (($fieldName = array_shift($supportedFields))) while (($fieldName = array_shift($supportedFields)))
{ {
@ -3191,6 +3191,7 @@ class calendar_ical extends calendar_boupdate
case 'recur_data': case 'recur_data':
case 'recur_exception': case 'recur_exception':
case 'recur_count': case 'recur_count':
case 'recur_rdates':
case 'whole_day': case 'whole_day':
// not handled here // not handled here
break; break;
@ -3200,7 +3201,7 @@ class calendar_ical extends calendar_boupdate
if ($event['recur_type'] != MCAL_RECUR_NONE) if ($event['recur_type'] != MCAL_RECUR_NONE)
{ {
$event['reference'] = 0; $event['reference'] = 0;
foreach (array('recur_interval','recur_enddate','recur_data','recur_exception','recur_count') as $r) foreach (array('recur_interval','recur_enddate','recur_data','recur_exception','recur_count','recur_rdates') as $r)
{ {
if (isset($vcardData[$r])) if (isset($vcardData[$r]))
{ {

View File

@ -45,29 +45,28 @@ class calendar_rrule implements Iterator
*/ */
const WEEKLY = 2; const WEEKLY = 2;
/** /**
* Monthly recurrance iCal: monthly_bymonthday * Monthly recurrence iCal: monthly_bymonthday
*/ */
const MONTHLY_MDAY = 3; const MONTHLY_MDAY = 3;
/** /**
* Monthly recurrance iCal: BYDAY (by weekday, eg. 1st Friday of month) * Monthly recurrence iCal: BYDAY (by weekday, eg. 1st Friday of month)
*/ */
const MONTHLY_WDAY = 4; const MONTHLY_WDAY = 4;
/** /**
* Yearly recurrance * Yearly recurrence
*/ */
const YEARLY = 5; const YEARLY = 5;
/** /**
* Hourly recurrance * Hourly recurrence
*/ */
const HOURLY = 8; const HOURLY = 8;
/** /**
* Minutely recurrance * Minutely recurrence
*/ */
const MINUTELY = 7; const MINUTELY = 7;
/** /**
* By date or period * RDATE: date or period (a list of dates, instead of a RRULE)
* (a list of dates)
*/ */
const PERIOD = 9; const PERIOD = 9;
@ -226,9 +225,9 @@ class calendar_rrule implements Iterator
public $time; public $time;
/** /**
* Current "position" / time * Current "position" / time or null, if invalid (out of explicit RDATEs)
* *
* @var Api\DateTime * @var Api\DateTime|null
*/ */
public $current; public $current;
@ -257,9 +256,10 @@ class calendar_rrule implements Iterator
* @param int $interval =1 1, 2, ... * @param int $interval =1 1, 2, ...
* @param DateTime $enddate =null enddate or null for no enddate (in which case we user '+5 year' on $time) * @param DateTime $enddate =null enddate or null for no enddate (in which case we user '+5 year' on $time)
* @param int $weekdays =0 self::SUNDAY=1|self::MONDAY=2|...|self::SATURDAY=64 * @param int $weekdays =0 self::SUNDAY=1|self::MONDAY=2|...|self::SATURDAY=64
* @param array $exceptions =null DateTime objects with exceptions * @param DateTime[] $exceptions =null DateTime objects with exceptions
* @param DateTime[] $rdates =null DateTime objects with rdates (for type self::PERIOD)
*/ */
public function __construct(DateTime $time,$type,$interval=1,DateTime $enddate=null,$weekdays=0,array $exceptions=null) public function __construct(DateTime $time,$type,$interval=1,DateTime $enddate=null,$weekdays=0,array $exceptions=null,array $rdates=null)
{ {
switch($GLOBALS['egw_info']['user']['preferences']['calendar']['weekdaystarts']) switch($GLOBALS['egw_info']['user']['preferences']['calendar']['weekdaystarts'])
{ {
@ -319,16 +319,15 @@ class calendar_rrule implements Iterator
$this->enddate = $enddate; $this->enddate = $enddate;
if($type == self::PERIOD) if($type == self::PERIOD)
{ {
foreach($exceptions as $exception) foreach($rdates as $rdate)
{ {
$exception->setTimezone($this->time->getTimezone()); $rdate->setTimezone($this->time->getTimezone());
$this->period[] = $exception; $this->period[] = $rdate;
} }
$enddate = clone(count($this->period) ? end($this->period) : $this->time); $enddate = clone(count($this->period) ? end($this->period) : $this->time);
// Make sure to include the last date as valid // Make sure to include the last date as valid
$enddate->modify('+1 second'); $enddate->modify('+1 second');
reset($this->period); reset($this->period);
unset($exceptions);
} }
// no recurrence --> current date is enddate // no recurrence --> current date is enddate
if ($type == self::NONE) if ($type == self::NONE)
@ -413,11 +412,11 @@ class calendar_rrule implements Iterator
/** /**
* Return the current element * Return the current element
* *
* @return Api\DateTime * @return ?Api\DateTime
*/ */
public function current(): Api\DateTime public function current(): ?Api\DateTime
{ {
return clone $this->current; return $this->current ? clone $this->current : null;
} }
/** /**
@ -427,7 +426,7 @@ class calendar_rrule implements Iterator
*/ */
public function key(): int public function key(): int
{ {
return (int)$this->current->format('Ymd'); return $this->current ? (int)$this->current->format('Ymd') : 0;
} }
/** /**
@ -498,14 +497,15 @@ class calendar_rrule implements Iterator
$this->current->modify($this->interval.' minute'); $this->current->modify($this->interval.' minute');
break; break;
case self::PERIOD: case self::PERIOD:
$index = array_search($this->current, $this->period); if (($next = next($this->period)))
$next = $this->enddate ?? new Api\DateTime();
if($index !== false && $index + 1 < count($this->period))
{ {
$next = $this->period[$index + 1]; $this->current->setDate($next->format('Y'), $next->format('m'), $next->format('d'));
$this->current->setTime($next->format('H'), $next->format('i'), $next->format('s'), 0);
}
else
{
$this->current = null;
} }
$this->current->setDate($next->format('Y'), $next->format('m'), $next->format('d'));
$this->current->setTime($next->format('H'), $next->format('i'), $next->format('s'), 0);
break; break;
default: default:
@ -522,7 +522,7 @@ class calendar_rrule implements Iterator
{ {
$this->next_no_exception(); $this->next_no_exception();
} }
while($this->exceptions && in_array($this->current->format('Ymd'),$this->exceptions)); while($this->current && $this->exceptions && in_array($this->current->format('Ymd'),$this->exceptions));
} }
/** /**
@ -595,6 +595,10 @@ class calendar_rrule implements Iterator
*/ */
public function rewind(): void public function rewind(): void
{ {
if ($this->type == self::PERIOD)
{
reset($this->period);
}
$this->current = clone $this->time; $this->current = clone $this->time;
while ($this->valid() && while ($this->valid() &&
$this->exceptions && $this->exceptions &&
@ -612,6 +616,10 @@ class calendar_rrule implements Iterator
*/ */
public function validDate(bool $use_just_date=null): bool public function validDate(bool $use_just_date=null): bool
{ {
if (!$this->current)
{
return false;
}
if ($use_just_date) if ($use_just_date)
{ {
return $this->current->format('Ymd') <= $this->enddate_ymd; return $this->current->format('Ymd') <= $this->enddate_ymd;
@ -626,7 +634,7 @@ class calendar_rrule implements Iterator
*/ */
public function valid(): bool public function valid(): bool
{ {
return $this->current->format('ts') < $this->enddate_ts; return $this->current && $this->current->format('ts') < $this->enddate_ts;
} }
/** /**
@ -853,10 +861,17 @@ class calendar_rrule implements Iterator
{ {
foreach($event['recur_exception'] as $exception) foreach($event['recur_exception'] as $exception)
{ {
$exceptions[] = is_a($exception,'DateTime') ? $exception : new Api\DateTime($exception,$timestamp_tz); $exceptions[] = is_a($exception,'DateTime') ? $exception : new Api\DateTime($exception, $timestamp_tz);
} }
} }
return new calendar_rrule($time,$event['recur_type'],$event['recur_interval'],$enddate??null,$event['recur_data'],$exceptions??null); if (is_array($event['recur_rdates']))
{
foreach($event['recur_rdates'] as $rdate)
{
$rdates[] = is_a($rdate,'DateTime') ? $rdate : new Api\DateTime($rdate, $timestamp_tz);
}
}
return new calendar_rrule($time,$event['recur_type'],$event['recur_interval'],$enddate??null,$event['recur_data'],$exceptions??null,$rdates??null);
} }
/** /**
@ -955,6 +970,7 @@ class calendar_rrule implements Iterator
'recur_enddate' => $this->enddate ? $this->enddate->format('ts') : null, 'recur_enddate' => $this->enddate ? $this->enddate->format('ts') : null,
'recur_data' => $this->weekdays, 'recur_data' => $this->weekdays,
'recur_exception' => $this->exceptions, 'recur_exception' => $this->exceptions,
'recur_rdates' => $this->period,
); );
} }

View File

@ -43,6 +43,7 @@ if(!extension_loaded('mcal'))
define('MCAL_M_WEEKEND',65); define('MCAL_M_WEEKEND',65);
define('MCAL_M_ALLDAYS',127); define('MCAL_M_ALLDAYS',127);
} }
define('MCAL_RECUR_RDATE',9);
define('REJECTED',0); define('REJECTED',0);
define('NO_RESPONSE',1); define('NO_RESPONSE',1);
@ -311,7 +312,7 @@ class calendar_so
* All times (start, end and modified) are returned as timesstamps in servertime! * All times (start, end and modified) are returned as timesstamps in servertime!
* *
* @param int|array|string $ids id or array of id's of the entries to read, or string with a single uid * @param int|array|string $ids id or array of id's of the entries to read, or string with a single uid
* @param int $recur_date =0 if set read the next recurrence at or after the timestamp, default 0 = read the initital one * @param int $recur_date =0 if set read the next recurrence at or after the timestamp, default 0 = read the initial one
* @param boolean $read_recurrence =false true: read the exception, not the series master (only for recur_date && $ids='<uid>'!) * @param boolean $read_recurrence =false true: read the exception, not the series master (only for recur_date && $ids='<uid>'!)
* @return array|boolean array with cal_id => event array pairs or false if entry not found * @return array|boolean array with cal_id => event array pairs or false if entry not found
*/ */
@ -426,12 +427,21 @@ class calendar_so
} }
if (!(int)$recur_date && !empty($event['recur_type'])) if (!(int)$recur_date && !empty($event['recur_type']))
{ {
foreach($this->db->select($this->dates_table, 'cal_id,cal_start', array( foreach($this->db->select($this->dates_table, 'cal_id,cal_start,recur_exception', [
'cal_id' => $ids, 'cal_id' => $ids,
]+($event['recur_type'] == MCAL_RECUR_RDATE ? [] : [
'recur_exception' => true, 'recur_exception' => true,
), __LINE__, __FILE__, false, 'ORDER BY cal_id,cal_start', 'calendar') as $row) ]), __LINE__, __FILE__, false, 'ORDER BY cal_id,cal_start', 'calendar') as $row)
{ {
$events[$row['cal_id']]['recur_exception'][] = $row['cal_start']; if ($row['recur_exception'])
{
$events[$row['cal_id']]['recur_exception'][] = $row['cal_start'];
}
// rdates are both, exceptions and regular dates!
if ($event['recur_type'] == MCAL_RECUR_RDATE)
{
$events[$row['cal_id']]['recur_rdates'][] = $row['cal_start'];
}
} }
break; // as above select read all exceptions (and I dont think too short uid problem still exists) break; // as above select read all exceptions (and I dont think too short uid problem still exists)
} }
@ -1496,7 +1506,7 @@ ORDER BY cal_user_type, cal_usre_id
if ($cal_id) if ($cal_id)
{ {
// query old recurrance information, before updating main table, where recur_endate is now stored // query old recurrence information, before updating main table, where recur_endate is now stored
if (!empty($event['recur_type'])) if (!empty($event['recur_type']))
{ {
$old_repeats = $this->db->select($this->repeats_table, "$this->repeats_table.*,range_end AS recur_enddate", $old_repeats = $this->db->select($this->repeats_table, "$this->repeats_table.*,range_end AS recur_enddate",
@ -1610,7 +1620,7 @@ ORDER BY cal_user_type, cal_usre_id
} }
$event['recur_exception'] = is_array($event['recur_exception']) ? $event['recur_exception'] : array(); $event['recur_exception'] = is_array($event['recur_exception']) ? $event['recur_exception'] : array();
if (!empty($event['recur_exception'])) if (count($event['recur_exception']) > 1)
{ {
sort($event['recur_exception']); sort($event['recur_exception']);
} }
@ -1707,7 +1717,7 @@ ORDER BY cal_user_type, cal_usre_id
// truncate recurrences by given exceptions // truncate recurrences by given exceptions
if (count($event['recur_exception'])) if (count($event['recur_exception']))
{ {
// added and existing exceptions: delete the execeptions from the user table, it could be the first time // added and existing exceptions: delete the exceptions from the user table, it could be the first time
$this->db->delete($this->user_table,array('cal_id' => $cal_id,'cal_recur_date' => $event['recur_exception']),__LINE__,__FILE__,'calendar'); $this->db->delete($this->user_table,array('cal_id' => $cal_id,'cal_recur_date' => $event['recur_exception']),__LINE__,__FILE__,'calendar');
// update recur_exception flag based on current exceptions // update recur_exception flag based on current exceptions
$this->db->update($this->dates_table, 'recur_exception='.$this->db->expression($this->dates_table,array( $this->db->update($this->dates_table, 'recur_exception='.$this->db->expression($this->dates_table,array(

View File

@ -113,6 +113,11 @@ class calendar_timezones
self::$tz_cache[$id] = Api\Db::strip_array_keys($data,'tz_'); self::$tz_cache[$id] = Api\Db::strip_array_keys($data,'tz_');
} }
} }
// check for a Windows timezone without "Standard Time" postfix
if (!isset($id) && strpos($tzid, '/') === false)
{
$id = self::tz2id($tzid.' Standard Time');
}
if (isset($id) && $what != 'id') if (isset($id) && $what != 'id')
{ {
return self::id2tz($id,$what); return self::id2tz($id,$what);