0
0
Fork 0
mirror of https://github.com/nextcloud/server.git synced 2025-02-11 03:29:29 +00:00
nextcloud_server/apps/dav/lib/CalDAV/EventReader.php
SebastianKrupinski a92547888c fix: add support for Microsoft time zones
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
2024-12-09 12:39:37 -05:00

771 lines
23 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use InvalidArgumentException;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Reader;
class EventReader {
protected VEvent $baseEvent;
protected DateTimeInterface $baseEventStartDate;
protected DateTimeZone $baseEventStartTimeZone;
protected DateTimeInterface $baseEventEndDate;
protected DateTimeZone $baseEventEndTimeZone;
protected bool $baseEventStartDateFloating = false;
protected bool $baseEventEndDateFloating = false;
protected int $baseEventDuration;
protected ?EventReaderRRule $rruleIterator = null;
protected ?EventReaderRDate $rdateIterator = null;
protected ?EventReaderRRule $eruleIterator = null;
protected ?EventReaderRDate $edateIterator = null;
protected array $recurrenceModified;
protected ?DateTimeInterface $recurrenceCurrentDate;
protected array $dayNamesMap = [
'MO' => 'Monday', 'TU' => 'Tuesday', 'WE' => 'Wednesday', 'TH' => 'Thursday', 'FR' => 'Friday', 'SA' => 'Saturday', 'SU' => 'Sunday'
];
protected array $monthNamesMap = [
1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', 5 => 'May', 6 => 'June',
7 => 'July', 8 => 'August', 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December'
];
protected array $relativePositionNamesMap = [
1 => 'First', 2 => 'Second', 3 => 'Third', 4 => 'Fourth', 5 => 'Fifty',
-1 => 'Last', -2 => 'Second Last', -3 => 'Third Last', -4 => 'Fourth Last', -5 => 'Fifty Last'
];
/**
* Initilizes the Event Reader
*
* There is several ways to set up the iterator.
*
* 1. You can pass a VCALENDAR component (as object or string) and a UID.
* 2. You can pass an array of VEVENTs (all UIDS should match).
* 3. You can pass a single VEVENT component (as object or string).
*
* Only the second method is recommended. The other 1 and 3 will be removed
* at some point in the future.
*
* The $uid parameter is only required for the first method.
*
* @since 30.0.0
*
* @param VCalendar|VEvent|Array|String $input
* @param string|null $uid
* @param DateTimeZone|null $timeZone reference timezone for floating dates and times
*/
public function __construct(VCalendar|VEvent|array|string $input, ?string $uid = null, ?DateTimeZone $timeZone = null) {
$timeZoneFactory = new TimeZoneFactory();
// evaluate if the input is a string and convert it to and vobject if required
if (is_string($input)) {
$input = Reader::read($input);
}
// evaluate if input is a single event vobject and convert it to a collection
if ($input instanceof VEvent) {
$events = [$input];
}
// evaluate if input is a calendar vobject
elseif ($input instanceof VCalendar) {
// Calendar + UID mode.
if ($uid === null) {
throw new InvalidArgumentException('The UID argument is required when a VCALENDAR object is used');
}
// extract events from calendar
$events = $input->getByUID($uid);
// evaluate if any event where found
if (count($events) === 0) {
throw new InvalidArgumentException('This VCALENDAR did not have an event with UID: ' . $uid);
}
// extract calendar timezone
if (isset($input->VTIMEZONE) && isset($input->VTIMEZONE->TZID)) {
$calendarTimeZone = $timeZoneFactory->fromName($input->VTIMEZONE->TZID->getValue());
}
}
// evaluate if input is a collection of event vobjects
elseif (is_array($input)) {
$events = $input;
} else {
throw new InvalidArgumentException('Invalid input data type');
}
// find base event instance and remove it from events collection
foreach ($events as $key => $vevent) {
if (!isset($vevent->{'RECURRENCE-ID'})) {
$this->baseEvent = $vevent;
unset($events[$key]);
}
}
// No base event was found. CalDAV does allow cases where only
// overridden instances are stored.
//
// In this particular case, we're just going to grab the first
// event and use that instead. This may not always give the
// desired result.
if (!isset($this->baseEvent) && count($events) > 0) {
$this->baseEvent = array_shift($events);
}
// determine the event starting time zone
// we require this to align all other dates times
// evaluate if timezone parameter was used (treat this as a override)
if ($timeZone !== null) {
$this->baseEventStartTimeZone = $timeZone;
}
// evaluate if event start date has a timezone parameter
elseif (isset($this->baseEvent->DTSTART->parameters['TZID'])) {
$this->baseEventStartTimeZone = $timeZoneFactory->fromName($this->baseEvent->DTSTART->parameters['TZID']->getValue()) ?? new DateTimeZone('UTC');
}
// evaluate if event parent calendar has a time zone
elseif (isset($calendarTimeZone)) {
$this->baseEventStartTimeZone = clone $calendarTimeZone;
}
// otherwise, as a last resort use the UTC timezone
else {
$this->baseEventStartTimeZone = new DateTimeZone('UTC');
}
// determine the event end time zone
// we require this to align all other dates and times
// evaluate if timezone parameter was used (treat this as a override)
if ($timeZone !== null) {
$this->baseEventEndTimeZone = $timeZone;
}
// evaluate if event end date has a timezone parameter
elseif (isset($this->baseEvent->DTEND->parameters['TZID'])) {
$this->baseEventEndTimeZone = $timeZoneFactory->fromName($this->baseEvent->DTEND->parameters['TZID']->getValue()) ?? new DateTimeZone('UTC');
}
// evaluate if event parent calendar has a time zone
elseif (isset($calendarTimeZone)) {
$this->baseEventEndTimeZone = clone $calendarTimeZone;
}
// otherwise, as a last resort use the start date time zone
else {
$this->baseEventEndTimeZone = clone $this->baseEventStartTimeZone;
}
// extract start date and time
$this->baseEventStartDate = $this->baseEvent->DTSTART->getDateTime($this->baseEventStartTimeZone);
$this->baseEventStartDateFloating = $this->baseEvent->DTSTART->isFloating();
// determine event end date and duration
// evaluate if end date exists
// extract end date and calculate duration
if (isset($this->baseEvent->DTEND)) {
$this->baseEventEndDate = $this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone);
$this->baseEventEndDateFloating = $this->baseEvent->DTEND->isFloating();
$this->baseEventDuration =
$this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone)->getTimeStamp() -
$this->baseEventStartDate->getTimeStamp();
}
// evaluate if duration exists
// extract duration and calculate end date
elseif (isset($this->baseEvent->DURATION)) {
$this->baseEventEndDate = DateTimeImmutable::createFromInterface($this->baseEventStartDate)
->add($this->baseEvent->DURATION->getDateInterval());
$this->baseEventDuration = $this->baseEventEndDate->getTimestamp() - $this->baseEventStartDate->getTimestamp();
}
// evaluate if start date is floating
// set duration to 24 hours and calculate the end date
// according to the rfc any event without a end date or duration is a complete day
elseif ($this->baseEventStartDateFloating == true) {
$this->baseEventDuration = 86400;
$this->baseEventEndDate = DateTimeImmutable::createFromInterface($this->baseEventStartDate)
->setTimestamp($this->baseEventStartDate->getTimestamp() + $this->baseEventDuration);
}
// otherwise, set duration to zero this should never happen
else {
$this->baseEventDuration = 0;
$this->baseEventEndDate = $this->baseEventStartDate;
}
// evaluate if RRULE exist and construct iterator
if (isset($this->baseEvent->RRULE)) {
$this->rruleIterator = new EventReaderRRule(
$this->baseEvent->RRULE->getParts(),
$this->baseEventStartDate
);
}
// evaluate if RDATE exist and construct iterator
if (isset($this->baseEvent->RDATE)) {
$dates = [];
foreach ($this->baseEvent->RDATE as $entry) {
$dates[] = $entry->getValue();
}
$this->rdateIterator = new EventReaderRDate(
implode(',', $dates),
$this->baseEventStartDate
);
}
// evaluate if EXRULE exist and construct iterator
if (isset($this->baseEvent->EXRULE)) {
$this->eruleIterator = new EventReaderRRule(
$this->baseEvent->EXRULE->getParts(),
$this->baseEventStartDate
);
}
// evaluate if EXDATE exist and construct iterator
if (isset($this->baseEvent->EXDATE)) {
$dates = [];
foreach ($this->baseEvent->EXDATE as $entry) {
$dates[] = $entry->getValue();
}
$this->edateIterator = new EventReaderRDate(
implode(',', $dates),
$this->baseEventStartDate
);
}
// construct collection of modified events with recurrence id as hash
foreach ($events as $vevent) {
$this->recurrenceModified[$vevent->{'RECURRENCE-ID'}->getDateTime($this->baseEventStartTimeZone)->getTimeStamp()] = $vevent;
}
$this->recurrenceCurrentDate = clone $this->baseEventStartDate;
}
/**
* retrieve date and time of event start
*
* @since 30.0.0
*
* @return DateTime
*/
public function startDateTime(): DateTime {
return DateTime::createFromInterface($this->baseEventStartDate);
}
/**
* retrieve time zone of event start
*
* @since 30.0.0
*
* @return DateTimeZone
*/
public function startTimeZone(): DateTimeZone {
return $this->baseEventStartTimeZone;
}
/**
* retrieve date and time of event end
*
* @since 30.0.0
*
* @return DateTime
*/
public function endDateTime(): DateTime {
return DateTime::createFromInterface($this->baseEventEndDate);
}
/**
* retrieve time zone of event end
*
* @since 30.0.0
*
* @return DateTimeZone
*/
public function endTimeZone(): DateTimeZone {
return $this->baseEventEndTimeZone;
}
/**
* is this an all day event
*
* @since 30.0.0
*
* @return bool
*/
public function entireDay(): bool {
return $this->baseEventStartDateFloating;
}
/**
* is this a recurring event
*
* @since 30.0.0
*
* @return bool
*/
public function recurs(): bool {
return ($this->rruleIterator !== null || $this->rdateIterator !== null);
}
/**
* event recurrence pattern
*
* @since 30.0.0
*
* @return string|null R - Relative or A - Absolute
*/
public function recurringPattern(): ?string {
if ($this->rruleIterator === null && $this->rdateIterator === null) {
return null;
}
if ($this->rruleIterator?->isRelative()) {
return 'R';
}
return 'A';
}
/**
* event recurrence precision
*
* @since 30.0.0
*
* @return string|null daily, weekly, monthly, yearly, fixed
*/
public function recurringPrecision(): ?string {
if ($this->rruleIterator !== null) {
return $this->rruleIterator->precision();
}
if ($this->rdateIterator !== null) {
return 'fixed';
}
return null;
}
/**
* event recurrence interval
*
* @since 30.0.0
*
* @return int|null
*/
public function recurringInterval(): ?int {
return $this->rruleIterator?->interval();
}
/**
* event recurrence conclusion
*
* returns true if RRULE with UNTIL or COUNT (calculated) is used
* returns true RDATE is used
* returns false if RRULE or RDATE are absent, or RRRULE is infinite
*
* @since 30.0.0
*
* @return bool
*/
public function recurringConcludes(): bool {
// retrieve rrule conclusions
if ($this->rruleIterator?->concludesOn() !== null ||
$this->rruleIterator?->concludesAfter() !== null) {
return true;
}
// retrieve rdate conclusions
if ($this->rdateIterator?->concludesAfter() !== null) {
return true;
}
return false;
}
/**
* event recurrence conclusion iterations
*
* returns the COUNT value if RRULE is used
* returns the collection count if RDATE is used
* returns combined count of RRULE COUNT and RDATE if both are used
* returns null if RRULE and RDATE are absent
*
* @since 30.0.0
*
* @return int|null
*/
public function recurringConcludesAfter(): ?int {
// construct count place holder
$count = 0;
// retrieve and add RRULE iterations count
$count += (int)$this->rruleIterator?->concludesAfter();
// retrieve and add RDATE iterations count
$count += (int)$this->rdateIterator?->concludesAfter();
// return count
return !empty($count) ? $count : null;
}
/**
* event recurrence conclusion date
*
* returns the last date of UNTIL or COUNT (calculated) if RRULE is used
* returns the last date in the collection if RDATE is used
* returns the highest date if both RRULE and RDATE are used
* returns null if RRULE and RDATE are absent or RRULE is infinite
*
* @since 30.0.0
*
* @return DateTime|null
*/
public function recurringConcludesOn(): ?DateTime {
if ($this->rruleIterator !== null) {
// retrieve rrule conclusion date
$rrule = $this->rruleIterator->concludes();
// evaluate if rrule conclusion is null
// if this is null that means the recurrence is infinate
if ($rrule === null) {
return null;
}
}
// retrieve rdate conclusion date
if ($this->rdateIterator !== null) {
$rdate = $this->rdateIterator->concludes();
}
// evaluate if both rrule and rdate have date
if (isset($rdate) && isset($rrule)) {
// return the highest date
return (($rdate > $rrule) ? $rdate : $rrule);
} elseif (isset($rrule)) {
return $rrule;
} elseif (isset($rdate)) {
return $rdate;
}
return null;
}
/**
* event recurrence days of the week
*
* returns collection of RRULE BYDAY day(s) ['MO','WE','FR']
* returns blank collection if RRULE is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringDaysOfWeek(): array {
// evaluate if RRULE exists and return day(s) of the week
return $this->rruleIterator !== null ? $this->rruleIterator->daysOfWeek() : [];
}
/**
* event recurrence days of the week (named)
*
* returns collection of RRULE BYDAY day(s) ['Monday','Wednesday','Friday']
* returns blank collection if RRULE is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringDaysOfWeekNamed(): array {
// evaluate if RRULE exists and extract day(s) of the week
$days = $this->rruleIterator !== null ? $this->rruleIterator->daysOfWeek() : [];
// convert numberic month to month name
foreach ($days as $key => $value) {
$days[$key] = $this->dayNamesMap[$value];
}
// return names collection
return $days;
}
/**
* event recurrence days of the month
*
* returns collection of RRULE BYMONTHDAY day(s) [7, 15, 31]
* returns blank collection if RRULE is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringDaysOfMonth(): array {
// evaluate if RRULE exists and return day(s) of the month
return $this->rruleIterator !== null ? $this->rruleIterator->daysOfMonth() : [];
}
/**
* event recurrence days of the year
*
* returns collection of RRULE BYYEARDAY day(s) [57, 205, 365]
* returns blank collection if RRULE is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringDaysOfYear(): array {
// evaluate if RRULE exists and return day(s) of the year
return $this->rruleIterator !== null ? $this->rruleIterator->daysOfYear() : [];
}
/**
* event recurrence weeks of the month
*
* returns collection of RRULE SETPOS weeks(s) [1, 3, -1]
* returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringWeeksOfMonth(): array {
// evaluate if RRULE exists and RRULE is relative return relative position(s)
return $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : [];
}
/**
* event recurrence weeks of the month (named)
*
* returns collection of RRULE SETPOS weeks(s) [1, 3, -1]
* returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringWeeksOfMonthNamed(): array {
// evaluate if RRULE exists and extract relative position(s)
$positions = $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : [];
// convert numberic relative position to relative label
foreach ($positions as $key => $value) {
$positions[$key] = $this->relativePositionNamesMap[$value];
}
// return positions collection
return $positions;
}
/**
* event recurrence weeks of the year
*
* returns collection of RRULE BYWEEKNO weeks(s) [12, 32, 52]
* returns blank collection if RRULE is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringWeeksOfYear(): array {
// evaluate if RRULE exists and return weeks(s) of the year
return $this->rruleIterator !== null ? $this->rruleIterator->weeksOfYear() : [];
}
/**
* event recurrence months of the year
*
* returns collection of RRULE BYMONTH month(s) [3, 7, 12]
* returns blank collection if RRULE is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringMonthsOfYear(): array {
// evaluate if RRULE exists and return month(s) of the year
return $this->rruleIterator !== null ? $this->rruleIterator->monthsOfYear() : [];
}
/**
* event recurrence months of the year (named)
*
* returns collection of RRULE BYMONTH month(s) [3, 7, 12]
* returns blank collection if RRULE is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringMonthsOfYearNamed(): array {
// evaluate if RRULE exists and extract month(s) of the year
$months = $this->rruleIterator !== null ? $this->rruleIterator->monthsOfYear() : [];
// convert numberic month to month name
foreach ($months as $key => $value) {
$months[$key] = $this->monthNamesMap[$value];
}
// return months collection
return $months;
}
/**
* event recurrence relative positions
*
* returns collection of RRULE SETPOS value(s) [1, 5, -3]
* returns blank collection if RRULE is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringRelativePosition(): array {
// evaluate if RRULE exists and return relative position(s)
return $this->rruleIterator !== null ? $this->rruleIterator->relativePosition() : [];
}
/**
* event recurrence relative positions (named)
*
* returns collection of RRULE SETPOS [1, 3, -1]
* returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect
*
* @since 30.0.0
*
* @return array
*/
public function recurringRelativePositionNamed(): array {
// evaluate if RRULE exists and extract relative position(s)
$positions = $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : [];
// convert numberic relative position to relative label
foreach ($positions as $key => $value) {
$positions[$key] = $this->relativePositionNamesMap[$value];
}
// return positions collection
return $positions;
}
/**
* event recurrence date
*
* returns date of currently selected recurrence
*
* @since 30.0.0
*
* @return DateTime
*/
public function recurrenceDate(): ?DateTime {
if ($this->recurrenceCurrentDate !== null) {
return DateTime::createFromInterface($this->recurrenceCurrentDate);
} else {
return null;
}
}
/**
* event recurrence rewind
*
* sets the current recurrence to the first recurrence in the collection
*
* @since 30.0.0
*
* @return void
*/
public function recurrenceRewind(): void {
// rewind and increment rrule
if ($this->rruleIterator !== null) {
$this->rruleIterator->rewind();
}
// rewind and increment rdate
if ($this->rdateIterator !== null) {
$this->rdateIterator->rewind();
}
// rewind and increment exrule
if ($this->eruleIterator !== null) {
$this->eruleIterator->rewind();
}
// rewind and increment exdate
if ($this->edateIterator !== null) {
$this->edateIterator->rewind();
}
// set current date to event start date
$this->recurrenceCurrentDate = clone $this->baseEventStartDate;
}
/**
* event recurrence advance
*
* sets the current recurrence to the next recurrence in the collection
*
* @since 30.0.0
*
* @return void
*/
public function recurrenceAdvance(): void {
// place holders
$nextOccurrenceDate = null;
$nextExceptionDate = null;
$rruleDate = null;
$rdateDate = null;
$eruleDate = null;
$edateDate = null;
// evaludate if rrule is set and advance one interation past current date
if ($this->rruleIterator !== null) {
// forward rrule to the next future date
while ($this->rruleIterator->valid() && $this->rruleIterator->current() <= $this->recurrenceCurrentDate) {
$this->rruleIterator->next();
}
$rruleDate = $this->rruleIterator->current();
}
// evaludate if rdate is set and advance one interation past current date
if ($this->rdateIterator !== null) {
// forward rdate to the next future date
while ($this->rdateIterator->valid() && $this->rdateIterator->current() <= $this->recurrenceCurrentDate) {
$this->rdateIterator->next();
}
$rdateDate = $this->rdateIterator->current();
}
if ($rruleDate !== null && $rdateDate !== null) {
$nextOccurrenceDate = ($rruleDate <= $rdateDate) ? $rruleDate : $rdateDate;
} elseif ($rruleDate !== null) {
$nextOccurrenceDate = $rruleDate;
} elseif ($rdateDate !== null) {
$nextOccurrenceDate = $rdateDate;
}
// evaludate if exrule is set and advance one interation past current date
if ($this->eruleIterator !== null) {
// forward exrule to the next future date
while ($this->eruleIterator->valid() && $this->eruleIterator->current() <= $this->recurrenceCurrentDate) {
$this->eruleIterator->next();
}
$eruleDate = $this->eruleIterator->current();
}
// evaludate if exdate is set and advance one interation past current date
if ($this->edateIterator !== null) {
// forward exdate to the next future date
while ($this->edateIterator->valid() && $this->edateIterator->current() <= $this->recurrenceCurrentDate) {
$this->edateIterator->next();
}
$edateDate = $this->edateIterator->current();
}
// evaludate if exrule and exdate are set and set nextExDate to the first next date
if ($eruleDate !== null && $edateDate !== null) {
$nextExceptionDate = ($eruleDate <= $edateDate) ? $eruleDate : $edateDate;
} elseif ($eruleDate !== null) {
$nextExceptionDate = $eruleDate;
} elseif ($edateDate !== null) {
$nextExceptionDate = $edateDate;
}
// if the next date is part of exrule or exdate find another date
if ($nextOccurrenceDate !== null && $nextExceptionDate !== null && $nextOccurrenceDate == $nextExceptionDate) {
$this->recurrenceCurrentDate = $nextOccurrenceDate;
$this->recurrenceAdvance();
} else {
$this->recurrenceCurrentDate = $nextOccurrenceDate;
}
}
/**
* event recurrence advance
*
* sets the current recurrence to the next recurrence in the collection after the specific date
*
* @since 30.0.0
*
* @param DateTimeInterface $dt date and time to advance
*
* @return void
*/
public function recurrenceAdvanceTo(DateTimeInterface $dt): void {
while ($this->recurrenceCurrentDate !== null && $this->recurrenceCurrentDate < $dt) {
$this->recurrenceAdvance();
}
}
}