/** * A pretty slow test. Had to be marked as 'medium' for phpunit to not die * after 1 second. Would be good to optimize later. * * @medium */ function testGetDTEnd() { $ics = <<<ICS BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Apple Inc.//iCal 4.0.4//EN CALSCALE:GREGORIAN BEGIN:VEVENT TRANSP:OPAQUE DTEND;TZID=America/New_York:20070925T170000 UID:uuid DTSTAMP:19700101T000000Z LOCATION: DESCRIPTION: STATUS:CONFIRMED SEQUENCE:18 SUMMARY:Stuff DTSTART;TZID=America/New_York:20070925T160000 CREATED:20071004T144642Z RRULE:FREQ=MONTHLY;INTERVAL=1;UNTIL=20071030T035959Z;BYDAY=5TU END:VEVENT END:VCALENDAR ICS; $vObject = Reader::read($ics); $it = new Recur\EventIterator($vObject, (string) $vObject->VEVENT->UID); while ($it->valid()) { $it->next(); } // If we got here, it means we were successful. The bug that was in the // system before would fail on the 5th tuesday of the month, if the 5th // tuesday did not exist. $this->assertTrue(true); }
/** * Different bug, also likely an infinite loop. */ function testYearlyByMonthLoop() { $ev = $this->vcal->createComponent('VEVENT'); $ev->UID = 'uuid'; $ev->DTSTART = '20120101T154500'; $ev->DTSTART['TZID'] = 'Europe/Berlin'; $ev->RRULE = 'FREQ=YEARLY;INTERVAL=1;UNTIL=20120203T225959Z;BYMONTH=2;BYSETPOS=1;BYDAY=SU,MO,TU,WE,TH,FR,SA'; $ev->DTEND = '20120101T164500'; $ev->DTEND['TZID'] = 'Europe/Berlin'; // This recurrence rule by itself is a yearly rule that should happen // every february. // // The BYDAY part expands this to every day of the month, but the // BYSETPOS limits this to only the 1st day of the month. Very crazy // way to specify this, and could have certainly been a lot easier. $this->vcal->add($ev); $it = new Recur\EventIterator($this->vcal, 'uuid'); $it->fastForward(new DateTime('2012-01-29 23:00:00', new DateTimeZone('UTC'))); $collect = array(); while ($it->valid()) { $collect[] = $it->getDTSTART(); if ($it->getDTSTART() > new DateTime('2013-02-05 22:59:59', new DateTimeZone('UTC'))) { break; } $it->next(); } $this->assertEquals(array(new DateTime('2012-02-01 15:45:00', new DateTimeZone('Europe/Berlin'))), $collect); }
/** * Processes incoming REPLY messages. * * The message is a reply. This is for example an attendee telling * an organizer he accepted the invite, or declined it. * * @param Message $itipMessage * @param VCalendar $existingObject * @return VCalendar|null */ protected function processMessageReply(Message $itipMessage, VCalendar $existingObject = null) { // A reply can only be processed based on an existing object. // If the object is not available, the reply is ignored. if (!$existingObject) { return null; } $instances = array(); $requestStatus = '2.0'; // Finding all the instances the attendee replied to. foreach ($itipMessage->message->VEVENT as $vevent) { $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master'; $attendee = $vevent->ATTENDEE; $instances[$recurId] = $attendee['PARTSTAT']->getValue(); if (isset($vevent->{'REQUEST-STATUS'})) { $requestStatus = $vevent->{'REQUEST-STATUS'}->getValue(); list($requestStatus) = explode(';', $requestStatus); } } // Now we need to loop through the original organizer event, to find // all the instances where we have a reply for. $masterObject = null; foreach ($existingObject->VEVENT as $vevent) { $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master'; if ($recurId === 'master') { $masterObject = $vevent; } if (isset($instances[$recurId])) { $attendeeFound = false; if (isset($vevent->ATTENDEE)) { foreach ($vevent->ATTENDEE as $attendee) { if ($attendee->getValue() === $itipMessage->sender) { $attendeeFound = true; $attendee['PARTSTAT'] = $instances[$recurId]; $attendee['SCHEDULE-STATUS'] = $requestStatus; // Un-setting the RSVP status, because we now know // that the attende already replied. unset($attendee['RSVP']); break; } } } if (!$attendeeFound) { // Adding a new attendee. The iTip documentation calls this // a party crasher. $attendee = $vevent->add('ATTENDEE', $itipMessage->sender, array('PARTSTAT' => $instances[$recurId])); if ($itipMessage->senderName) { $attendee['CN'] = $itipMessage->senderName; } } unset($instances[$recurId]); } } if (!$masterObject) { // No master object, we can't add new instances. return null; } // If we got replies to instances that did not exist in the // original list, it means that new exceptions must be created. foreach ($instances as $recurId => $partstat) { $recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid); $found = false; $iterations = 1000; do { $newObject = $recurrenceIterator->getEventObject(); $recurrenceIterator->next(); if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getValue() === $recurId) { $found = true; } $iterations--; } while ($recurrenceIterator->valid() && !$found && $iterations); // Invalid recurrence id. Skipping this object. if (!$found) { continue; } unset($newObject->RRULE, $newObject->EXDATE, $newObject->RDATE); $attendeeFound = false; if (isset($newObject->ATTENDEE)) { foreach ($newObject->ATTENDEE as $attendee) { if ($attendee->getValue() === $itipMessage->sender) { $attendeeFound = true; $attendee['PARTSTAT'] = $partstat; break; } } } if (!$attendeeFound) { // Adding a new attendee $attendee = $newObject->add('ATTENDEE', $itipMessage->sender, array('PARTSTAT' => $partstat)); if ($itipMessage->senderName) { $attendee['CN'] = $itipMessage->senderName; } } $existingObject->add($newObject); } return $existingObject; }
/** * If this calendar object, has events with recurrence rules, this method * can be used to expand the event into multiple sub-events. * * Each event will be stripped from it's recurrence information, and only * the instances of the event in the specified timerange will be left * alone. * * In addition, this method will cause timezone information to be stripped, * and normalized to UTC. * * This method will alter the VCalendar. This cannot be reversed. * * This functionality is specifically used by the CalDAV standard. It is * possible for clients to request expand events, if they are rather simple * clients and do not have the possibility to calculate recurrences. * * @param DateTime $start * @param DateTime $end * @param DateTimeZone $timeZone reference timezone for floating dates and * times. * @return void */ function expand(DateTime $start, DateTime $end, DateTimeZone $timeZone = null) { $newEvents = array(); if (!$timeZone) { $timeZone = new DateTimeZone('UTC'); } // An array of events. Events are indexed by UID. Each item in this // array is a list of one or more events that match the UID. $recurringEvents = array(); foreach ($this->select('VEVENT') as $key => $vevent) { $uid = (string) $vevent->UID; if (!$uid) { throw new \LogicException('Event did not have a UID!'); } if (isset($vevent->{'RECURRENCE-ID'}) || isset($vevent->RRULE)) { if (isset($recurringEvents[$uid])) { $recurringEvents[$uid][] = $vevent; } else { $recurringEvents[$uid] = array($vevent); } continue; } if (!isset($vevent->RRULE)) { if ($vevent->isInTimeRange($start, $end)) { $newEvents[] = $vevent; } continue; } } foreach ($recurringEvents as $events) { try { $it = new EventIterator($events, $timeZone); } catch (NoInstancesException $e) { // This event is recurring, but it doesn't have a single // instance. We are skipping this event from the output // entirely. continue; } $it->fastForward($start); while ($it->valid() && $it->getDTStart() < $end) { if ($it->getDTEnd() > $start) { $newEvents[] = $it->getEventObject(); } $it->next(); } } // Wiping out all old VEVENT objects unset($this->VEVENT); // Setting all properties to UTC time. foreach ($newEvents as $newEvent) { foreach ($newEvent->children as $child) { if ($child instanceof VObject\Property\ICalendar\DateTime && $child->hasTime()) { $dt = $child->getDateTimes($timeZone); // We only need to update the first timezone, because // setDateTimes will match all other timezones to the // first. $dt[0]->setTimeZone(new DateTimeZone('UTC')); $child->setDateTimes($dt); } } $this->add($newEvent); } // Removing all VTIMEZONE components unset($this->VTIMEZONE); }
/** * @depends testValues */ function testOverridenEventNoValuesExpected() { $vcal = new VCalendar(); $ev1 = $vcal->createComponent('VEVENT'); $ev1->UID = 'overridden'; $ev1->RRULE = 'FREQ=WEEKLY;COUNT=3'; $ev1->DTSTART = '20120124T120000Z'; $ev1->SUMMARY = 'baseEvent'; $vcal->add($ev1); // ev2 overrides an event, and puts it 6 days earlier instead. $ev2 = $vcal->createComponent('VEVENT'); $ev2->UID = 'overridden'; $ev2->{'RECURRENCE-ID'} = '20120131T120000Z'; $ev2->DTSTART = '20120125T120000Z'; $ev2->SUMMARY = 'Override!'; $vcal->add($ev2); $it = new EventIterator($vcal, 'overridden'); $dates = array(); $summaries = array(); // The reported problem was specifically related to the VCALENDAR // expansion. In this parcitular case, we had to forward to the 28th of // january. $it->fastForward(new DateTime('2012-01-28 23:00:00')); // We stop the loop when it hits the 6th of februari. Normally this // iterator would hit 24, 25 (overriden from 31) and 7 feb but because // we 'filter' from the 28th till the 6th, we should get 0 results. while ($it->valid() && $it->getDTSTart() < new DateTime('2012-02-06 23:00:00')) { $dates[] = $it->getDTStart(); $summaries[] = (string) $it->getEventObject()->SUMMARY; $it->next(); } $this->assertEquals(array(), $dates); $this->assertEquals(array(), $summaries); }
/** * Parses the input data and returns a correct VFREEBUSY object, wrapped in * a VCALENDAR. * * @return Component */ function getResult() { $busyTimes = []; foreach ($this->objects as $key => $object) { foreach ($object->getBaseComponents() as $component) { switch ($component->name) { case 'VEVENT': $FBTYPE = 'BUSY'; if (isset($component->TRANSP) && strtoupper($component->TRANSP) === 'TRANSPARENT') { break; } if (isset($component->STATUS)) { $status = strtoupper($component->STATUS); if ($status === 'CANCELLED') { break; } if ($status === 'TENTATIVE') { $FBTYPE = 'BUSY-TENTATIVE'; } } $times = []; if ($component->RRULE) { try { $iterator = new EventIterator($object, (string) $component->uid, $this->timeZone); } catch (NoInstancesException $e) { // This event is recurring, but it doesn't have a single // instance. We are skipping this event from the output // entirely. unset($this->objects[$key]); continue; } if ($this->start) { $iterator->fastForward($this->start); } $maxRecurrences = 200; while ($iterator->valid() && --$maxRecurrences) { $startTime = $iterator->getDTStart(); if ($this->end && $startTime > $this->end) { break; } $times[] = [$iterator->getDTStart(), $iterator->getDTEnd()]; $iterator->next(); } } else { $startTime = $component->DTSTART->getDateTime($this->timeZone); if ($this->end && $startTime > $this->end) { break; } $endTime = null; if (isset($component->DTEND)) { $endTime = $component->DTEND->getDateTime($this->timeZone); } elseif (isset($component->DURATION)) { $duration = DateTimeParser::parseDuration((string) $component->DURATION); $endTime = clone $startTime; $endTime = $endTime->add($duration); } elseif (!$component->DTSTART->hasTime()) { $endTime = clone $startTime; $endTime = $endTime->modify('+1 day'); } else { // The event had no duration (0 seconds) break; } $times[] = [$startTime, $endTime]; } foreach ($times as $time) { if ($this->end && $time[0] > $this->end) { break; } if ($this->start && $time[1] < $this->start) { break; } $busyTimes[] = [$time[0], $time[1], $FBTYPE]; } break; case 'VFREEBUSY': foreach ($component->FREEBUSY as $freebusy) { $fbType = isset($freebusy['FBTYPE']) ? strtoupper($freebusy['FBTYPE']) : 'BUSY'; // Skipping intervals marked as 'free' if ($fbType === 'FREE') { continue; } $values = explode(',', $freebusy); foreach ($values as $value) { list($startTime, $endTime) = explode('/', $value); $startTime = DateTimeParser::parseDateTime($startTime); if (substr($endTime, 0, 1) === 'P' || substr($endTime, 0, 2) === '-P') { $duration = DateTimeParser::parseDuration($endTime); $endTime = clone $startTime; $endTime = $endTime->add($duration); } else { $endTime = DateTimeParser::parseDateTime($endTime); } if ($this->start && $this->start > $endTime) { continue; } if ($this->end && $this->end < $startTime) { continue; } $busyTimes[] = [$startTime, $endTime, $fbType]; } } break; } } } if ($this->baseObject) { $calendar = $this->baseObject; } else { $calendar = new VCalendar(); } $vfreebusy = $calendar->createComponent('VFREEBUSY'); $calendar->add($vfreebusy); if ($this->start) { $dtstart = $calendar->createProperty('DTSTART'); $dtstart->setDateTime($this->start); $vfreebusy->add($dtstart); } if ($this->end) { $dtend = $calendar->createProperty('DTEND'); $dtend->setDateTime($this->end); $vfreebusy->add($dtend); } $dtstamp = $calendar->createProperty('DTSTAMP'); $dtstamp->setDateTime(new DateTimeImmutable('now', new \DateTimeZone('UTC'))); $vfreebusy->add($dtstamp); foreach ($busyTimes as $busyTime) { $busyTime[0] = $busyTime[0]->setTimeZone(new \DateTimeZone('UTC')); $busyTime[1] = $busyTime[1]->setTimeZone(new \DateTimeZone('UTC')); $prop = $calendar->createProperty('FREEBUSY', $busyTime[0]->format('Ymd\\THis\\Z') . '/' . $busyTime[1]->format('Ymd\\THis\\Z')); $prop['FBTYPE'] = $busyTime[2]; $vfreebusy->add($prop); } return $calendar; }
/** * If this calendar object, has events with recurrence rules, this method * can be used to expand the event into multiple sub-events. * * Each event will be stripped from it's recurrence information, and only * the instances of the event in the specified timerange will be left * alone. * * In addition, this method will cause timezone information to be stripped, * and normalized to UTC. * * This method will alter the VCalendar. This cannot be reversed. * * This functionality is specifically used by the CalDAV standard. It is * possible for clients to request expand events, if they are rather simple * clients and do not have the possibility to calculate recurrences. * * @param DateTime $start * @param DateTime $end * @return void */ public function expand(\DateTime $start, \DateTime $end) { $newEvents = array(); foreach ($this->select('VEVENT') as $key => $vevent) { if (isset($vevent->{'RECURRENCE-ID'})) { unset($this->children[$key]); continue; } if (!$vevent->rrule) { unset($this->children[$key]); if ($vevent->isInTimeRange($start, $end)) { $newEvents[] = $vevent; } continue; } $uid = (string) $vevent->uid; if (!$uid) { throw new \LogicException('Event did not have a UID!'); } $it = new EventIterator($this, $vevent->uid); $it->fastForward($start); while ($it->valid() && $it->getDTStart() < $end) { if ($it->getDTEnd() > $start) { $newEvents[] = $it->getEventObject(); } $it->next(); } unset($this->children[$key]); } // Setting all properties to UTC time. foreach ($newEvents as $newEvent) { foreach ($newEvent->children as $child) { if ($child instanceof VObject\Property\ICalendar\DateTime && $child->hasTime()) { $dt = $child->getDateTimes(); // We only need to update the first timezone, because // setDateTimes will match all other timezones to the // first. $dt[0]->setTimeZone(new \DateTimeZone('UTC')); $child->setDateTimes($dt); } } $this->add($newEvent); } // Removing all VTIMEZONE components unset($this->VTIMEZONE); }
/** * Parses some information from calendar objects, used for optimized * calendar-queries. * * Returns an array with the following keys: * * etag - An md5 checksum of the object without the quotes. * * size - Size of the object in bytes * * componentType - VEVENT, VTODO or VJOURNAL * * firstOccurence * * lastOccurence * * uid - value of the UID property * * @param string $calendarData * @return array */ public function getDenormalizedData($calendarData) { $vObject = Reader::read($calendarData); $componentType = null; $component = null; $firstOccurrence = null; $lastOccurrence = null; $uid = null; $classification = self::CLASSIFICATION_PUBLIC; foreach ($vObject->getComponents() as $component) { if ($component->name !== 'VTIMEZONE') { $componentType = $component->name; $uid = (string) $component->UID; break; } } if (!$componentType) { throw new \Sabre\DAV\Exception\BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component'); } if ($componentType === 'VEVENT' && $component->DTSTART) { $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp(); // Finding the last occurrence is a bit harder if (!isset($component->RRULE)) { if (isset($component->DTEND)) { $lastOccurrence = $component->DTEND->getDateTime()->getTimeStamp(); } elseif (isset($component->DURATION)) { $endDate = clone $component->DTSTART->getDateTime(); $endDate->add(DateTimeParser::parse($component->DURATION->getValue())); $lastOccurrence = $endDate->getTimeStamp(); } elseif (!$component->DTSTART->hasTime()) { $endDate = clone $component->DTSTART->getDateTime(); $endDate->modify('+1 day'); $lastOccurrence = $endDate->getTimeStamp(); } else { $lastOccurrence = $firstOccurrence; } } else { $it = new EventIterator($vObject, (string) $component->UID); $maxDate = new \DateTime(self::MAX_DATE); if ($it->isInfinite()) { $lastOccurrence = $maxDate->getTimeStamp(); } else { $end = $it->getDtEnd(); while ($it->valid() && $end < $maxDate) { $end = $it->getDtEnd(); $it->next(); } $lastOccurrence = $end->getTimeStamp(); } } } if ($component->CLASS) { $classification = CalDavBackend::CLASSIFICATION_PRIVATE; switch ($component->CLASS->getValue()) { case 'PUBLIC': $classification = CalDavBackend::CLASSIFICATION_PUBLIC; break; case 'CONFIDENTIAL': $classification = CalDavBackend::CLASSIFICATION_CONFIDENTIAL; break; } } return ['etag' => md5($calendarData), 'size' => strlen($calendarData), 'componentType' => $componentType, 'firstOccurence' => is_null($firstOccurrence) ? null : max(0, $firstOccurrence), 'lastOccurence' => $lastOccurrence, 'uid' => $uid, 'classification' => $classification]; }
/** * This method takes an array of iCalendar objects and applies its busy * times on fbData. * * @param FreeBusyData $fbData * @param VCalendar[] $objects */ protected function calculateBusy(FreeBusyData $fbData, array $objects) { foreach ($objects as $key => $object) { foreach ($object->getBaseComponents() as $component) { switch ($component->name) { case 'VEVENT': $FBTYPE = 'BUSY'; if (isset($component->TRANSP) && strtoupper($component->TRANSP) === 'TRANSPARENT') { break; } if (isset($component->STATUS)) { $status = strtoupper($component->STATUS); if ($status === 'CANCELLED') { break; } if ($status === 'TENTATIVE') { $FBTYPE = 'BUSY-TENTATIVE'; } } $times = []; if ($component->RRULE) { try { $iterator = new EventIterator($object, (string) $component->uid, $this->timeZone); } catch (NoInstancesException $e) { // This event is recurring, but it doesn't have a single // instance. We are skipping this event from the output // entirely. unset($this->objects[$key]); continue; } if ($this->start) { $iterator->fastForward($this->start); } $maxRecurrences = Settings::$maxRecurrences; while ($iterator->valid() && --$maxRecurrences) { $startTime = $iterator->getDTStart(); if ($this->end && $startTime > $this->end) { break; } $times[] = [$iterator->getDTStart(), $iterator->getDTEnd()]; $iterator->next(); } } else { $startTime = $component->DTSTART->getDateTime($this->timeZone); if ($this->end && $startTime > $this->end) { break; } $endTime = null; if (isset($component->DTEND)) { $endTime = $component->DTEND->getDateTime($this->timeZone); } elseif (isset($component->DURATION)) { $duration = DateTimeParser::parseDuration((string) $component->DURATION); $endTime = clone $startTime; $endTime = $endTime->add($duration); } elseif (!$component->DTSTART->hasTime()) { $endTime = clone $startTime; $endTime = $endTime->modify('+1 day'); } else { // The event had no duration (0 seconds) break; } $times[] = [$startTime, $endTime]; } foreach ($times as $time) { if ($this->end && $time[0] > $this->end) { break; } if ($this->start && $time[1] < $this->start) { break; } $fbData->add($time[0]->getTimeStamp(), $time[1]->getTimeStamp(), $FBTYPE); } break; case 'VFREEBUSY': foreach ($component->FREEBUSY as $freebusy) { $fbType = isset($freebusy['FBTYPE']) ? strtoupper($freebusy['FBTYPE']) : 'BUSY'; // Skipping intervals marked as 'free' if ($fbType === 'FREE') { continue; } $values = explode(',', $freebusy); foreach ($values as $value) { list($startTime, $endTime) = explode('/', $value); $startTime = DateTimeParser::parseDateTime($startTime); if (substr($endTime, 0, 1) === 'P' || substr($endTime, 0, 2) === '-P') { $duration = DateTimeParser::parseDuration($endTime); $endTime = clone $startTime; $endTime = $endTime->add($duration); } else { $endTime = DateTimeParser::parseDateTime($endTime); } if ($this->start && $this->start > $endTime) { continue; } if ($this->end && $this->end < $startTime) { continue; } $fbData->add($startTime->getTimeStamp(), $endTime->getTimeStamp(), $fbType); } } break; } } } }
/** * Parses some information from calendar objects, used for optimized * calendar-queries. * * Returns an array with the following keys: * * etag - An md5 checksum of the object without the quotes. * * size - Size of the object in bytes * * componentType - VEVENT, VTODO or VJOURNAL * * firstOccurence * * lastOccurence * * uid - value of the UID property * * @param string $calendarData * @return array */ protected function getDenormalizedData($calendarData) { $vObject = VObject\Reader::read($calendarData); $componentType = null; $component = null; $firstOccurence = null; $lastOccurence = null; $uid = null; foreach ($vObject->getComponents() as $component) { if ($component->name !== 'VTIMEZONE') { $componentType = $component->name; $uid = (string) $component->UID; break; } } if (!$componentType) { throw new \Sabre\DAV\Exception\BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component'); } if ($componentType === 'VEVENT') { $firstOccurence = $component->DTSTART->getDateTime()->getTimeStamp(); // Finding the last occurence is a bit harder if (!isset($component->RRULE)) { if (isset($component->DTEND)) { $lastOccurence = $component->DTEND->getDateTime()->getTimeStamp(); } elseif (isset($component->DURATION)) { $endDate = clone $component->DTSTART->getDateTime(); $endDate = $endDate->add(VObject\DateTimeParser::parse($component->DURATION->getValue())); $lastOccurence = $endDate->getTimeStamp(); } elseif (!$component->DTSTART->hasTime()) { $endDate = clone $component->DTSTART->getDateTime(); $endDate = $endDate->modify('+1 day'); $lastOccurence = $endDate->getTimeStamp(); } else { $lastOccurence = $firstOccurence; } } else { $it = new VObject\Recur\EventIterator($vObject, (string) $component->UID); $maxDate = new \DateTime(self::MAX_DATE); if ($it->isInfinite()) { $lastOccurence = $maxDate->getTimeStamp(); } else { $end = $it->getDtEnd(); while ($it->valid() && $end < $maxDate) { $end = $it->getDtEnd(); $it->next(); } $lastOccurence = $end->getTimeStamp(); } } } // Destroy circular references to PHP will GC the object. $vObject->destroy(); return ['etag' => md5($calendarData), 'size' => strlen($calendarData), 'componentType' => $componentType, 'firstOccurence' => $firstOccurence, 'lastOccurence' => $lastOccurence, 'uid' => $uid]; }
/** * Expand all events in this VCalendar object and return a new VCalendar * with the expanded events. * * If this calendar object, has events with recurrence rules, this method * can be used to expand the event into multiple sub-events. * * Each event will be stripped from it's recurrence information, and only * the instances of the event in the specified timerange will be left * alone. * * In addition, this method will cause timezone information to be stripped, * and normalized to UTC. * * @param DateTimeInterface $start * @param DateTimeInterface $end * @param DateTimeZone $timeZone reference timezone for floating dates and * times. * @return VCalendar */ function expand(DateTimeInterface $start, DateTimeInterface $end, DateTimeZone $timeZone = null) { $newChildren = []; $recurringEvents = []; if (!$timeZone) { $timeZone = new DateTimeZone('UTC'); } $stripTimezones = function (Component $component) use($timeZone, &$stripTimezones) { foreach ($component->children() as $componentChild) { if ($componentChild instanceof Property\ICalendar\DateTime && $componentChild->hasTime()) { $dt = $componentChild->getDateTimes($timeZone); // We only need to update the first timezone, because // setDateTimes will match all other timezones to the // first. $dt[0] = $dt[0]->setTimeZone(new DateTimeZone('UTC')); $componentChild->setDateTimes($dt); } elseif ($componentChild instanceof Component) { $stripTimezones($componentChild); } } return $component; }; foreach ($this->children() as $child) { if ($child instanceof Property && $child->name !== 'PRODID') { // We explictly want to ignore PRODID, because we want to // overwrite it with our own. $newChildren[] = clone $child; } elseif ($child instanceof Component && $child->name !== 'VTIMEZONE') { // We're also stripping all VTIMEZONE objects because we're // converting everything to UTC. if ($child->name === 'VEVENT' && (isset($child->{'RECURRENCE-ID'}) || isset($child->RRULE) || isset($child->RDATE))) { // Handle these a bit later. $uid = (string) $child->UID; if (!$uid) { throw new InvalidDataException('Every VEVENT object must have a UID property'); } if (isset($recurringEvents[$uid])) { $recurringEvents[$uid][] = clone $child; } else { $recurringEvents[$uid] = [clone $child]; } } elseif ($child->name === 'VEVENT' && $child->isInTimeRange($start, $end)) { $newChildren[] = $stripTimezones(clone $child); } } } foreach ($recurringEvents as $events) { try { $it = new EventIterator($events, $timeZone); } catch (NoInstancesException $e) { // This event is recurring, but it doesn't have a single // instance. We are skipping this event from the output // entirely. continue; } $it->fastForward($start); while ($it->valid() && $it->getDTStart() < $end) { if ($it->getDTEnd() > $start) { $newChildren[] = $stripTimezones($it->getEventObject()); } $it->next(); } } return new self($newChildren); }
/** * Validates if a component matches the given time range. * * This is all based on the rules specified in rfc4791, which are quite * complex. * * @param VObject\Node $component * @param DateTime $start * @param DateTime $end * @return bool */ protected function validateTimeRange(VObject\Node $component, $start, $end) { if (is_null($start)) { $start = new DateTime('1900-01-01'); } if (is_null($end)) { $end = new DateTime('3000-01-01'); } switch ($component->name) { case 'VEVENT': case 'VTODO': case 'VJOURNAL': return $component->isInTimeRange($start, $end); case 'VALARM': // If the valarm is wrapped in a recurring event, we need to // expand the recursions, and validate each. // // Our datamodel doesn't easily allow us to do this straight // in the VALARM component code, so this is a hack, and an // expensive one too. if ($component->parent->name === 'VEVENT' && $component->parent->RRULE) { // Fire up the iterator! $it = new VObject\Recur\EventIterator($component->parent->parent, (string) $component->parent->UID); while ($it->valid()) { $expandedEvent = $it->getEventObject(); // We need to check from these expanded alarms, which // one is the first to trigger. Based on this, we can // determine if we can 'give up' expanding events. $firstAlarm = null; if ($expandedEvent->VALARM !== null) { foreach ($expandedEvent->VALARM as $expandedAlarm) { $effectiveTrigger = $expandedAlarm->getEffectiveTriggerTime(); if ($expandedAlarm->isInTimeRange($start, $end)) { return true; } if ((string) $expandedAlarm->TRIGGER['VALUE'] === 'DATE-TIME') { // This is an alarm with a non-relative trigger // time, likely created by a buggy client. The // implication is that every alarm in this // recurring event trigger at the exact same // time. It doesn't make sense to traverse // further. } else { // We store the first alarm as a means to // figure out when we can stop traversing. if (!$firstAlarm || $effectiveTrigger < $firstAlarm) { $firstAlarm = $effectiveTrigger; } } } } if (is_null($firstAlarm)) { // No alarm was found. // // Or technically: No alarm that will change for // every instance of the recurrence was found, // which means we can assume there was no match. return false; } if ($firstAlarm > $end) { return false; } $it->next(); } return false; } else { return $component->isInTimeRange($start, $end); } case 'VFREEBUSY': throw new \Sabre\DAV\Exception\NotImplemented('time-range filters are currently not supported on ' . $component->name . ' components'); case 'COMPLETED': case 'CREATED': case 'DTEND': case 'DTSTAMP': case 'DTSTART': case 'DUE': case 'LAST-MODIFIED': return $start <= $component->getDateTime() && $end >= $component->getDateTime(); default: throw new \Sabre\DAV\Exception\BadRequest('You cannot create a time-range filter on a ' . $component->name . ' component'); } }
/** * Processes incoming REPLY messages. * * The message is a reply. This is for example an attendee telling * an organizer he accepted the invite, or declined it. * * @param Message $itipMessage * @param VCalendar $existingObject * @return VCalendar|null */ protected function processMessageReply(Message $itipMessage, VCalendar $existingObject = null) { // A reply can only be processed based on an existing object. // If the object is not available, the reply is ignored. if (!$existingObject) { return null; } $instances = array(); foreach ($itipMessage->message->VEVENT as $vevent) { $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master'; $attendee = $vevent->ATTENDEE; $instances[$recurId] = $attendee['PARTSTAT']->getValue(); } $masterObject = null; foreach ($existingObject->VEVENT as $vevent) { $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master'; if ($recurId === 'master') { $masterObject = $vevent; } if (isset($instances[$recurId])) { $attendeeFound = false; if (isset($vevent->ATTENDEE)) { foreach ($vevent->ATTENDEE as $attendee) { if ($attendee->getValue() === $itipMessage->sender) { $attendeeFound = true; $attendee['PARTSTAT'] = $instances[$recurId]; break; } } } if (!$attendeeFound) { // Adding a new attendee. The iTip documentation calls this // a party crasher. $attendee = $vevent->add('ATTENDEE', $itipMessage->sender, array('PARTSTAT' => $instances[$recurId])); if ($itipMessage->senderName) { $attendee['CN'] = $itipMessage->senderName; } } unset($instances[$recurId]); } } if (!$masterObject) { // No master object, we can't add new instances. return null; } // If we got replies to instances that did not exist in the // original list, it means that new exceptions must be created. foreach ($instances as $recurId => $partstat) { $recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid); $found = false; $iterations = 1000; do { $newObject = $recurrenceIterator->getEventObject(); $recurrenceIterator->next(); if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getValue() === $recurId) { $found = true; } $iterations--; } while ($recurrenceIterator->valid() && !$found && $iterations); // Invalid recurrence id. Skipping this object. if (!$found) { continue; } unset($newObject->RRULE, $newObject->EXDATE, $newObject->RDATE); $newObject->{'RECURRENCE-ID'} = $recurId; $attendeeFound = false; if (isset($newObject->ATTENDEE)) { foreach ($newObject->ATTENDEE as $attendee) { if ($attendee->getValue() === $itipMessage->sender) { $attendeeFound = true; $attendee['PARTSTAT'] = $partstat; break; } } } if (!$attendeeFound) { // Adding a new attendee $attendee = $newObject->add('ATTENDEE', $itipMessage->sender, array('PARTSTAT' => $partstat)); if ($itipMessage->senderName) { $attendee['CN'] = $itipMessage->senderName; } } $existingObject->add($newObject); } return $existingObject; }