diff --git a/lib/Component/VCalendar.php b/lib/Component/VCalendar.php index 988db9dc2..226f5de96 100644 --- a/lib/Component/VCalendar.php +++ b/lib/Component/VCalendar.php @@ -164,6 +164,37 @@ function getDocumentType() { } + /** + * Returns what kind of component this iCalendar object contains. + * + * This is useful in scenarios where only one component type may appear in + * a single iCalendar stream, such as CalDAV or iTip, but it makes no sense + * for calendars with mixed objects. + * + * All this does is loop through it's internal component and returns the + * name of the first component it runs into that's not a VTIMEZONE. + * + * If no such component is found, but there was an embedded VTIMEZONE, + * we'll return VTIMEZONE. + * + * @return string + */ + function getComponentType() { + + $hasVTimeZone = false; + foreach ($this->children as $childType => $nodes) { + + if ($childType === 'VTIMEZONE') { + $hasVTimeZone = true; + } elseif (array_key_exists($childType, self::$componentMap)) { + return $childType; + } + + } + return $hasVTimeZone ? 'VTIMEZONE' : null; + + } + /** * Returns a list of all 'base components'. For instance, if an Event has * a recurrence rule, and one instance is overridden, the overridden event diff --git a/lib/ITip/AbstractBroker.php b/lib/ITip/AbstractBroker.php new file mode 100644 index 000000000..7eabd534b --- /dev/null +++ b/lib/ITip/AbstractBroker.php @@ -0,0 +1,324 @@ + null, + 'component' => null, + 'organizer' => null, + 'organizerName' => null, + 'organizerForceSend' => null, + 'organizerScheduleAgent' => 'SERVER', + 'sequence' => null, + 'timezone' => null, + 'status' => null, + 'significantChangeHash' => '', + 'attendees' => [], + 'instances' => [], + 'exdate' => [], + ]; + + $significantChangeHash = ''; + + foreach ($calendar->getComponents() as $component) { + + if ($component->name === 'VTIMEZONE') { + continue; + } + + // Component + if (is_null($result['component'])) { + $result['component'] = $component->name; + } else { + if ($result['component'] !== $component->name) { + throw new ITipException('All components in a iTip message must have be of the same type.'); + } + } + + // UID + if (is_null($result['uid'])) { + $result['uid'] = $component->UID->getValue(); + } else { + if ($result['uid'] !== $component->UID->getValue()) { + throw new ITipException('All components in a iTip message must have the same UID'); + } + } + + if (isset($component->ORGANIZER)) { + if (is_null($result['organizer'])) { + $result['organizer'] = $component->ORGANIZER->getNormalizedValue(); + $result['organizerName'] = isset($component->ORGANIZER['CN']) ? (string)$component->ORGANIZER['CN'] : null; + } else { + if ($result['organizer'] !== $component->ORGANIZER->getNormalizedValue()) { + throw new SameOrganizerForAllComponentsException('Every instance of the event must have the same organizer.'); + } + } + $result['organizerForceSend'] = + isset($component->ORGANIZER['SCHEDULE-FORCE-SEND']) ? + strtoupper($component->ORGANIZER['SCHEDULE-FORCE-SEND']) : + null; + $result['organizerScheduleAgent'] = + isset($component->ORGANIZER['SCHEDULE-AGENT']) ? + strtoupper((string)$component->ORGANIZER['SCHEDULE-AGENT']) : + 'SERVER'; + } + + if (is_null($result['sequence']) && isset($component->SEQUENCE)) { + $result['sequence'] = $component->SEQUENCE->getValue(); + } + + if (isset($component->EXDATE)) { + foreach ($component->select('EXDATE') as $val) { + $result['exdate'] = array_merge($result['exdate'], $val->getParts()); + } + sort($result['exdate']); + } + if (isset($component->STATUS)) { + $result['status'] = strtoupper($component->STATUS->getValue()); + } + + $recurId = isset($component->{'RECURRENCE-ID'}) ? $component->{'RECURRENCE-ID'}->getValue() : 'master'; + + if (is_null($result['timezone'])) { + if (isset($component->{'RECURRENCE-ID'})) { + $result['timezone'] = $component->{'RECURRENCE-ID'}->getDateTime()->getTimeZone(); + } elseif (isset($component->DTSTART)) { + $result['timezone'] = $component->DTSTART->getDateTime()->getTimeZone(); + } + } + if (isset($component->ATTENDEE)) { + foreach ($component->ATTENDEE as $attendee) { + + if ($this->scheduleAgentServerRules && + isset($attendee['SCHEDULE-AGENT']) && + strtoupper($attendee['SCHEDULE-AGENT']->getValue()) === 'CLIENT' + ) { + continue; + } + $partStat = + isset($attendee['PARTSTAT']) ? + strtoupper($attendee['PARTSTAT']) : + 'NEEDS-ACTION'; + + $forceSend = + isset($attendee['SCHEDULE-FORCE-SEND']) ? + strtoupper($attendee['SCHEDULE-FORCE-SEND']) : + null; + + + if (isset($result['attendees'][$attendee->getNormalizedValue()])) { + $result['attendees'][$attendee->getNormalizedValue()]['instances'][$recurId] = [ + 'id' => $recurId, + 'partstat' => $partStat, + ]; + } else { + $result['attendees'][$attendee->getNormalizedValue()] = [ + 'href' => $attendee->getNormalizedValue(), + 'instances' => [ + $recurId => [ + 'id' => $recurId, + 'partstat' => $partStat, + ], + ], + 'name' => isset($attendee['CN']) ? (string)$attendee['CN'] : null, + 'forceSend' => $forceSend, + ]; + } + + } + $result['instances'][$recurId] = $component; + + } + + foreach ($this->significantChangeProperties as $prop) { + if (isset($component->$prop)) { + $significantChangeHash .= $prop . ':'; + + if ($prop === 'EXDATE') { + + $significantChangeHash .= implode(',', $result['exdate']) . ';'; + + } else { + $propertyValues = $component->select($prop); + + foreach ($propertyValues as $val) { + $significantChangeHash .= $val->getValue() . ';'; + } + + } + } + } + + } + $result['significantChangeHash'] = md5($significantChangeHash); + + //$result2 = $result; + //$result2['instances'] = array_keys($result2['instances']); + //print_r($result2); + + return $result; + + } + + +} diff --git a/lib/ITip/Broker.php b/lib/ITip/Broker.php index effa74317..b0144ec30 100644 --- a/lib/ITip/Broker.php +++ b/lib/ITip/Broker.php @@ -3,9 +3,7 @@ namespace Sabre\VObject\ITip; use Sabre\VObject\Component\VCalendar; -use Sabre\VObject\DateTimeParser; use Sabre\VObject\Reader; -use Sabre\VObject\Recur\EventIterator; /** * The ITip\Broker class is a utility class that helps with processing @@ -31,56 +29,64 @@ * 6. It can process a reply from an invite and update an events attendee * status based on a reply. * + * There are basically two major modes of operation: + * + * 1. processing a change in an object. The product of this is a series of + * iTip messages. + * 2. process an iTip message. The prodcut of this is a new calendar object, + * or an update to a calendar object. + * + * * @copyright Copyright (C) fruux GmbH (https://fruux.com/) * @author Evert Pot (http://evertpot.com/) * @license http://sabre.io/license/ Modified BSD License */ -class Broker { +class Broker extends AbstractBroker { /** - * This setting determines whether the rules for the SCHEDULE-AGENT - * parameter should be followed. - * - * This is a parameter defined on ATTENDEE properties, introduced by RFC - * 6638. This parameter allows a caldav client to tell the server 'Don't do - * any scheduling operations'. - * - * If this setting is turned on, any attendees with SCHEDULE-AGENT set to - * CLIENT will be ignored. This is the desired behavior for a CalDAV - * server, but if you're writing an iTip application that doesn't deal with - * CalDAV, you may want to ignore this parameter. - * - * @var bool + * This method takes an old and a new iCalendar object, and based on the + * difference it tries to determine if iTip messages must be sent. + * + * For example, if a DTSTART was changed on an event, and the event had + * one or more attendees, this method will generate an iTip message for + * every attendee to notify them of the change. + * + * Both the old and the new iCalendar object may be omitted, but not both. + * If the old iCalendar object was omitted, this method will treat this as + * if a new event is being created. + * + * If the new iCalendar object is omitted, this method will treat it as if + * it was deleted. A deletion might for example automatically trigger a + * "CANCELLED" iTip message for an organizer, or a "DECLINED" iTip message + * for an attendee. + * + * You must specify 1 or more uris for the current user. We need that + * information to figure out who is actually making the change. We're + * actually comparing this to the values of ATTENDEE and ORGANIZER. + * + * @param VCalendar $before + * @param VCalendar $after + * @param string|string[] $userUri + * return Message[] */ - public $scheduleAgentServerRules = true; + function processICalendarChange(VCalendar $before = null, VCalendar $after = null, $userUri) { - /** - * The broker will try during 'parseEvent' figure out whether the change - * was significant. - * - * It uses a few different ways to do this. One of these ways is seeing if - * certain properties changed values. This list of specified here. - * - * This list is taken from: - * * http://tools.ietf.org/html/rfc5546#section-2.1.4 - * - * @var string[] - */ - public $significantChangeProperties = [ - 'DTSTART', - 'DTEND', - 'DURATION', - 'DUE', - 'RRULE', - 'RDATE', - 'EXDATE', - 'STATUS', - ]; + if ($before === null && $after === null) { + throw new \InvalidArgumentException('$before and $after must both not be null'); + } + + $componentType = $before ? $before->getComponentType() : $after->getComponentType(); + + $broker = $this->getBroker($componentType); + return $broker->processICalendarChange($before, $after, $userUri); + + } /** - * This method is used to process an incoming itip message. + * This messages takes an iTip message as input, and transforms an + * iCalendar message based on it's input. * - * Examples: + * Some examples: * * 1. A user is an attendee to an event. The organizer sends an updated * meeting using a new iTip message with METHOD:REQUEST. This function @@ -100,890 +106,81 @@ class Broker { * REPLY, the message effectively gets ignored, and no 'existingObject' * will be created. * - * The updated $existingObject is also returned from this function. - * - * If the iTip message was not supported, we will always return false. + * If the iTip message is not supported, this method will not return + * anything. * - * @param Message $itipMessage + * @param Message $message * @param VCalendar $existingObject - * * @return VCalendar|null */ - function processMessage(Message $itipMessage, VCalendar $existingObject = null) { + function applyITipMessage(Message $message, VCalendar $existingObject = null) { - // We only support events at the moment. - if ($itipMessage->component !== 'VEVENT') { - return false; - } - - switch ($itipMessage->method) { - - case 'REQUEST' : - return $this->processMessageRequest($itipMessage, $existingObject); + $broker = $this->getBroker($message->component); + return $broker->applyITipMessage($message, $existingObject); - case 'CANCEL' : - return $this->processMessageCancel($itipMessage, $existingObject); - - case 'REPLY' : - return $this->processMessageReply($itipMessage, $existingObject); - - default : - // Unsupported iTip message - return; - - } - - return $existingObject; } - /** - * This function parses a VCALENDAR object and figure out if any messages - * need to be sent. - * - * A VCALENDAR object will be created from the perspective of either an - * attendee, or an organizer. You must pass a string identifying the - * current user, so we can figure out who in the list of attendees or the - * organizer we are sending this message on behalf of. - * - * It's possible to specify the current user as an array, in case the user - * has more than one identifying href (such as multiple emails). - * - * It $oldCalendar is specified, it is assumed that the operation is - * updating an existing event, which means that we need to look at the - * differences between events, and potentially send old attendees - * cancellations, and current attendees updates. - * - * If $calendar is null, but $oldCalendar is specified, we treat the - * operation as if the user has deleted an event. If the user was an - * organizer, this means that we need to send cancellation notices to - * people. If the user was an attendee, we need to make sure that the - * organizer gets the 'declined' message. - * - * @param VCalendar|string $calendar - * @param string|array $userHref - * @param VCalendar|string $oldCalendar - * - * @return array - */ - function parseEvent($calendar = null, $userHref, $oldCalendar = null) { - - if ($oldCalendar) { - if (is_string($oldCalendar)) { - $oldCalendar = Reader::read($oldCalendar); - } - if (!isset($oldCalendar->VEVENT)) { - // We only support events at the moment - return []; - } - - $oldEventInfo = $this->parseEventInfo($oldCalendar); - } else { - $oldEventInfo = [ - 'organizer' => null, - 'significantChangeHash' => '', - 'attendees' => [], - ]; - } - - $userHref = (array)$userHref; - - if (!is_null($calendar)) { - - if (is_string($calendar)) { - $calendar = Reader::read($calendar); - } - if (!isset($calendar->VEVENT)) { - // We only support events at the moment - return []; - } - $eventInfo = $this->parseEventInfo($calendar); - if (!$eventInfo['attendees'] && !$oldEventInfo['attendees']) { - // If there were no attendees on either side of the equation, - // we don't need to do anything. - return []; - } - if (!$eventInfo['organizer'] && !$oldEventInfo['organizer']) { - // There was no organizer before or after the change. - return []; - } - - $baseCalendar = $calendar; - - // If the new object didn't have an organizer, the organizer - // changed the object from a scheduling object to a non-scheduling - // object. We just copy the info from the old object. - if (!$eventInfo['organizer'] && $oldEventInfo['organizer']) { - $eventInfo['organizer'] = $oldEventInfo['organizer']; - $eventInfo['organizerName'] = $oldEventInfo['organizerName']; - } - - } else { - // The calendar object got deleted, we need to process this as a - // cancellation / decline. - if (!$oldCalendar) { - // No old and no new calendar, there's no thing to do. - return []; - } - - $eventInfo = $oldEventInfo; - - if (in_array($eventInfo['organizer'], $userHref)) { - // This is an organizer deleting the event. - $eventInfo['attendees'] = []; - // Increasing the sequence, but only if the organizer deleted - // the event. - $eventInfo['sequence']++; - } else { - // This is an attendee deleting the event. - foreach ($eventInfo['attendees'] as $key => $attendee) { - if (in_array($attendee['href'], $userHref)) { - $eventInfo['attendees'][$key]['instances'] = ['master' => - ['id' => 'master', 'partstat' => 'DECLINED'] - ]; - } - } - } - $baseCalendar = $oldCalendar; - - } - - if (in_array($eventInfo['organizer'], $userHref)) { - return $this->parseEventForOrganizer($baseCalendar, $eventInfo, $oldEventInfo); - } elseif ($oldCalendar) { - // We need to figure out if the user is an attendee, but we're only - // doing so if there's an oldCalendar, because we only want to - // process updates, not creation of new events. - foreach ($eventInfo['attendees'] as $attendee) { - if (in_array($attendee['href'], $userHref)) { - return $this->parseEventForAttendee($baseCalendar, $eventInfo, $oldEventInfo, $attendee['href']); - } - } - } - return []; - - } - - /** - * Processes incoming REQUEST messages. - * - * This is message from an organizer, and is either a new event - * invite, or an update to an existing one. - * - * - * @param Message $itipMessage - * @param VCalendar $existingObject - * - * @return VCalendar|null - */ - protected function processMessageRequest(Message $itipMessage, VCalendar $existingObject = null) { - - if (!$existingObject) { - // This is a new invite, and we're just going to copy over - // all the components from the invite. - $existingObject = new VCalendar(); - foreach ($itipMessage->message->getComponents() as $component) { - $existingObject->add(clone $component); - } - } else { - // We need to update an existing object with all the new - // information. We can just remove all existing components - // and create new ones. - foreach ($existingObject->getComponents() as $component) { - $existingObject->remove($component); - } - foreach ($itipMessage->message->getComponents() as $component) { - $existingObject->add(clone $component); - } - } - return $existingObject; - - } - - /** - * Processes incoming CANCEL messages. - * - * This is a message from an organizer, and means that either an - * attendee got removed from an event, or an event got cancelled - * altogether. - * - * @param Message $itipMessage - * @param VCalendar $existingObject - * - * @return VCalendar|null - */ - protected function processMessageCancel(Message $itipMessage, VCalendar $existingObject = null) { - - if (!$existingObject) { - // The event didn't exist in the first place, so we're just - // ignoring this message. - } else { - foreach ($existingObject->VEVENT as $vevent) { - $vevent->STATUS = 'CANCELLED'; - $vevent->SEQUENCE = $itipMessage->sequence; - } - } - return $existingObject; - - } - - /** - * 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. + * This method an alias of applyITipMessage, and is deprecated. * * @param Message $itipMessage * @param VCalendar $existingObject - * + * @deprecated * @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; - } - $instances = []; - $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 attendee 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, [ - '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; - } - // 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, [ - 'PARTSTAT' => $partstat - ]); - if ($itipMessage->senderName) { - $attendee['CN'] = $itipMessage->senderName; - } - } - $existingObject->add($newObject); - - } - return $existingObject; - - } - - /** - * This method is used in cases where an event got updated, and we - * potentially need to send emails to attendees to let them know of updates - * in the events. - * - * We will detect which attendees got added, which got removed and create - * specific messages for these situations. - * - * @param VCalendar $calendar - * @param array $eventInfo - * @param array $oldEventInfo - * - * @return array - */ - protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo) { - - // Merging attendee lists. - $attendees = []; - foreach ($oldEventInfo['attendees'] as $attendee) { - $attendees[$attendee['href']] = [ - 'href' => $attendee['href'], - 'oldInstances' => $attendee['instances'], - 'newInstances' => [], - 'name' => $attendee['name'], - 'forceSend' => null, - ]; - } - foreach ($eventInfo['attendees'] as $attendee) { - if (isset($attendees[$attendee['href']])) { - $attendees[$attendee['href']]['name'] = $attendee['name']; - $attendees[$attendee['href']]['newInstances'] = $attendee['instances']; - $attendees[$attendee['href']]['forceSend'] = $attendee['forceSend']; - } else { - $attendees[$attendee['href']] = [ - 'href' => $attendee['href'], - 'oldInstances' => [], - 'newInstances' => $attendee['instances'], - 'name' => $attendee['name'], - 'forceSend' => $attendee['forceSend'], - ]; - } - } - - $messages = []; - - foreach ($attendees as $attendee) { - - // An organizer can also be an attendee. We should not generate any - // messages for those. - if ($attendee['href'] === $eventInfo['organizer']) { - continue; - } - - $message = new Message(); - $message->uid = $eventInfo['uid']; - $message->component = 'VEVENT'; - $message->sequence = $eventInfo['sequence']; - $message->sender = $eventInfo['organizer']; - $message->senderName = $eventInfo['organizerName']; - $message->recipient = $attendee['href']; - $message->recipientName = $attendee['name']; - - if (!$attendee['newInstances']) { - - // If there are no instances the attendee is a part of, it - // means the attendee was removed and we need to send him a - // CANCEL. - $message->method = 'CANCEL'; - - // Creating the new iCalendar body. - $icalMsg = new VCalendar(); - $icalMsg->METHOD = $message->method; - $event = $icalMsg->add('VEVENT', [ - 'UID' => $message->uid, - 'SEQUENCE' => $message->sequence, - ]); - if (isset($calendar->VEVENT->SUMMARY)) { - $event->add('SUMMARY', $calendar->VEVENT->SUMMARY->getValue()); - } - $event->add(clone $calendar->VEVENT->DTSTART); - if (isset($calendar->VEVENT->DTEND)) { - $event->add(clone $calendar->VEVENT->DTEND); - } elseif (isset($calendar->VEVENT->DURATION)) { - $event->add(clone $calendar->VEVENT->DURATION); - } - $org = $event->add('ORGANIZER', $eventInfo['organizer']); - if ($eventInfo['organizerName']) $org['CN'] = $eventInfo['organizerName']; - $event->add('ATTENDEE', $attendee['href'], [ - 'CN' => $attendee['name'], - ]); - $message->significantChange = true; - - } else { - - // The attendee gets the updated event body - $message->method = 'REQUEST'; - - // Creating the new iCalendar body. - $icalMsg = new VCalendar(); - $icalMsg->METHOD = $message->method; - - foreach ($calendar->select('VTIMEZONE') as $timezone) { - $icalMsg->add(clone $timezone); - } - - // We need to find out that this change is significant. If it's - // not, systems may opt to not send messages. - // - // We do this based on the 'significantChangeHash' which is - // some value that changes if there's a certain set of - // properties changed in the event, or simply if there's a - // difference in instances that the attendee is invited to. - - $message->significantChange = - $attendee['forceSend'] === 'REQUEST' || - array_keys($attendee['oldInstances']) != array_keys($attendee['newInstances']) || - $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash']; - - foreach ($attendee['newInstances'] as $instanceId => $instanceInfo) { - - $currentEvent = clone $eventInfo['instances'][$instanceId]; - if ($instanceId === 'master') { - - // We need to find a list of events that the attendee - // is not a part of to add to the list of exceptions. - $exceptions = []; - foreach ($eventInfo['instances'] as $instanceId => $vevent) { - if (!isset($attendee['newInstances'][$instanceId])) { - $exceptions[] = $instanceId; - } - } - - // If there were exceptions, we need to add it to an - // existing EXDATE property, if it exists. - if ($exceptions) { - if (isset($currentEvent->EXDATE)) { - $currentEvent->EXDATE->setParts(array_merge( - $currentEvent->EXDATE->getParts(), - $exceptions - )); - } else { - $currentEvent->EXDATE = $exceptions; - } - } - - // Cleaning up any scheduling information that - // shouldn't be sent along. - unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']); - unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']); - - foreach ($currentEvent->ATTENDEE as $attendee) { - unset($attendee['SCHEDULE-FORCE-SEND']); - unset($attendee['SCHEDULE-STATUS']); - - // We're adding PARTSTAT=NEEDS-ACTION to ensure that - // iOS shows an "Inbox Item" - if (!isset($attendee['PARTSTAT'])) { - $attendee['PARTSTAT'] = 'NEEDS-ACTION'; - } - - } - - } - - $icalMsg->add($currentEvent); - - } - - } - - $message->message = $icalMsg; - $messages[] = $message; - - } + function processMessage(Message $itipMessage, VCalendar $existingObject = null) { - return $messages; + return $this->applyITipMessage($itipMessage, $existingObject); } /** - * Parse an event update for an attendee. - * - * This function figures out if we need to send a reply to an organizer. - * - * @param VCalendar $calendar - * @param array $eventInfo - * @param array $oldEventInfo - * @param string $attendee + * This method is an alias of processICalendarChange, and is deprecated. * + * @param VCalendar|string $calendar + * @param string|array $userHref + * @param VCalendar|string $oldCalendar * @return Message[] */ - protected function parseEventForAttendee(VCalendar $calendar, array $eventInfo, array $oldEventInfo, $attendee) { - - if ($this->scheduleAgentServerRules && $eventInfo['organizerScheduleAgent'] === 'CLIENT') { - return []; - } - - // Don't bother generating messages for events that have already been - // cancelled. - if ($eventInfo['status'] === 'CANCELLED') { - return []; - } - - $oldInstances = !empty($oldEventInfo['attendees'][$attendee]['instances']) ? - $oldEventInfo['attendees'][$attendee]['instances'] : - []; - - $instances = []; - foreach ($oldInstances as $instance) { - - $instances[$instance['id']] = [ - 'id' => $instance['id'], - 'oldstatus' => $instance['partstat'], - 'newstatus' => null, - ]; - - } - foreach ($eventInfo['attendees'][$attendee]['instances'] as $instance) { - - if (isset($instances[$instance['id']])) { - $instances[$instance['id']]['newstatus'] = $instance['partstat']; - } else { - $instances[$instance['id']] = [ - 'id' => $instance['id'], - 'oldstatus' => null, - 'newstatus' => $instance['partstat'], - ]; - } - - } - - // We need to also look for differences in EXDATE. If there are new - // items in EXDATE, it means that an attendee deleted instances of an - // event, which means we need to send DECLINED specifically for those - // instances. - // We only need to do that though, if the master event is not declined. - if (isset($instances['master']) && $instances['master']['newstatus'] !== 'DECLINED') { - foreach ($eventInfo['exdate'] as $exDate) { - - if (!in_array($exDate, $oldEventInfo['exdate'])) { - if (isset($instances[$exDate])) { - $instances[$exDate]['newstatus'] = 'DECLINED'; - } else { - $instances[$exDate] = [ - 'id' => $exDate, - 'oldstatus' => null, - 'newstatus' => 'DECLINED', - ]; - } - } - - } - } - - // Gathering a few extra properties for each instance. - foreach ($instances as $recurId => $instanceInfo) { - - if (isset($eventInfo['instances'][$recurId])) { - $instances[$recurId]['dtstart'] = clone $eventInfo['instances'][$recurId]->DTSTART; - } else { - $instances[$recurId]['dtstart'] = $recurId; - } + function parseEvent($calendar = null, $userHref, $oldCalendar = null) { + if (is_string($calendar)) { + $calendar = Reader::read($calendar); } - - $message = new Message(); - $message->uid = $eventInfo['uid']; - $message->method = 'REPLY'; - $message->component = 'VEVENT'; - $message->sequence = $eventInfo['sequence']; - $message->sender = $attendee; - $message->senderName = $eventInfo['attendees'][$attendee]['name']; - $message->recipient = $eventInfo['organizer']; - $message->recipientName = $eventInfo['organizerName']; - - $icalMsg = new VCalendar(); - $icalMsg->METHOD = 'REPLY'; - - $hasReply = false; - - foreach ($instances as $instance) { - - if ($instance['oldstatus'] == $instance['newstatus'] && $eventInfo['organizerForceSend'] !== 'REPLY') { - // Skip - continue; - } - - $event = $icalMsg->add('VEVENT', [ - 'UID' => $message->uid, - 'SEQUENCE' => $message->sequence, - ]); - $summary = isset($calendar->VEVENT->SUMMARY) ? $calendar->VEVENT->SUMMARY->getValue() : ''; - // Adding properties from the correct source instance - if (isset($eventInfo['instances'][$instance['id']])) { - $instanceObj = $eventInfo['instances'][$instance['id']]; - $event->add(clone $instanceObj->DTSTART); - if (isset($instanceObj->DTEND)) { - $event->add(clone $instanceObj->DTEND); - } elseif (isset($instanceObj->DURATION)) { - $event->add(clone $instanceObj->DURATION); - } - if (isset($instanceObj->SUMMARY)) { - $event->add('SUMMARY', $instanceObj->SUMMARY->getValue()); - } elseif ($summary) { - $event->add('SUMMARY', $summary); - } - } else { - // This branch of the code is reached, when a reply is - // generated for an instance of a recurring event, through the - // fact that the instance has disappeared by showing up in - // EXDATE - $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']); - // Treat is as a DATE field - if (strlen($instance['id']) <= 8) { - $event->add('DTSTART', $dt, ['VALUE' => 'DATE']); - } else { - $event->add('DTSTART', $dt); - } - if ($summary) { - $event->add('SUMMARY', $summary); - } - } - if ($instance['id'] !== 'master') { - $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']); - // Treat is as a DATE field - if (strlen($instance['id']) <= 8) { - $event->add('RECURRENCE-ID', $dt, ['VALUE' => 'DATE']); - } else { - $event->add('RECURRENCE-ID', $dt); - } - } - $organizer = $event->add('ORGANIZER', $message->recipient); - if ($message->recipientName) { - $organizer['CN'] = $message->recipientName; - } - $attendee = $event->add('ATTENDEE', $message->sender, [ - 'PARTSTAT' => $instance['newstatus'] - ]); - if ($message->senderName) { - $attendee['CN'] = $message->senderName; - } - $hasReply = true; - + if (is_string($oldCalendar)) { + $oldCalendar = Reader::read($oldCalendar); } - if ($hasReply) { - $message->message = $icalMsg; - return [$message]; - } else { - return []; - } + return $this->processICalendarChange($oldCalendar, $calendar, $userHref); } /** - * Returns attendee information and information about instances of an - * event. - * - * Returns an array with the following keys: + * Returns a Broker for the specified component type. * - * 1. uid - * 2. organizer - * 3. organizerName - * 4. organizerScheduleAgent - * 5. organizerForceSend - * 6. instances - * 7. attendees - * 8. sequence - * 9. exdate - * 10. timezone - strictly the timezone on which the recurrence rule is - * based on. - * 11. significantChangeHash - * 12. status - * @param VCalendar $calendar - * - * @return array + * @param string $componentType String such as VEVENT + * @return AbstractBroker */ - protected function parseEventInfo(VCalendar $calendar = null) { - - $uid = null; - $organizer = null; - $organizerName = null; - $organizerForceSend = null; - $sequence = null; - $timezone = null; - $status = null; - $organizerScheduleAgent = 'SERVER'; - - $significantChangeHash = ''; - - // Now we need to collect a list of attendees, and which instances they - // are a part of. - $attendees = []; - - $instances = []; - $exdate = []; - - foreach ($calendar->VEVENT as $vevent) { - - if (is_null($uid)) { - $uid = $vevent->UID->getValue(); - } else { - if ($uid !== $vevent->UID->getValue()) { - throw new ITipException('If a calendar contained more than one event, they must have the same UID.'); - } - } - - if (!isset($vevent->DTSTART)) { - throw new ITipException('An event MUST have a DTSTART property.'); - } - - if (isset($vevent->ORGANIZER)) { - if (is_null($organizer)) { - $organizer = $vevent->ORGANIZER->getNormalizedValue(); - $organizerName = isset($vevent->ORGANIZER['CN']) ? $vevent->ORGANIZER['CN'] : null; - } else { - if ($organizer !== $vevent->ORGANIZER->getNormalizedValue()) { - throw new SameOrganizerForAllComponentsException('Every instance of the event must have the same organizer.'); - } - } - $organizerForceSend = - isset($vevent->ORGANIZER['SCHEDULE-FORCE-SEND']) ? - strtoupper($vevent->ORGANIZER['SCHEDULE-FORCE-SEND']) : - null; - $organizerScheduleAgent = - isset($vevent->ORGANIZER['SCHEDULE-AGENT']) ? - strtoupper((string)$vevent->ORGANIZER['SCHEDULE-AGENT']) : - 'SERVER'; - } - if (is_null($sequence) && isset($vevent->SEQUENCE)) { - $sequence = $vevent->SEQUENCE->getValue(); - } - if (isset($vevent->EXDATE)) { - foreach ($vevent->select('EXDATE') as $val) { - $exdate = array_merge($exdate, $val->getParts()); - } - sort($exdate); - } - if (isset($vevent->STATUS)) { - $status = strtoupper($vevent->STATUS->getValue()); - } - - $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master'; - if (is_null($timezone)) { - if ($recurId === 'master') { - $timezone = $vevent->DTSTART->getDateTime()->getTimeZone(); - } else { - $timezone = $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimeZone(); - } - } - if (isset($vevent->ATTENDEE)) { - foreach ($vevent->ATTENDEE as $attendee) { - - if ($this->scheduleAgentServerRules && - isset($attendee['SCHEDULE-AGENT']) && - strtoupper($attendee['SCHEDULE-AGENT']->getValue()) === 'CLIENT' - ) { - continue; - } - $partStat = - isset($attendee['PARTSTAT']) ? - strtoupper($attendee['PARTSTAT']) : - 'NEEDS-ACTION'; + protected function getBroker($componentType) { - $forceSend = - isset($attendee['SCHEDULE-FORCE-SEND']) ? - strtoupper($attendee['SCHEDULE-FORCE-SEND']) : - null; + switch ($componentType) { - - if (isset($attendees[$attendee->getNormalizedValue()])) { - $attendees[$attendee->getNormalizedValue()]['instances'][$recurId] = [ - 'id' => $recurId, - 'partstat' => $partStat, - 'force-send' => $forceSend, - ]; - } else { - $attendees[$attendee->getNormalizedValue()] = [ - 'href' => $attendee->getNormalizedValue(), - 'instances' => [ - $recurId => [ - 'id' => $recurId, - 'partstat' => $partStat, - ], - ], - 'name' => isset($attendee['CN']) ? (string)$attendee['CN'] : null, - 'forceSend' => $forceSend, - ]; - } - - } - $instances[$recurId] = $vevent; - - } - - foreach ($this->significantChangeProperties as $prop) { - if (isset($vevent->$prop)) { - $propertyValues = $vevent->select($prop); - - $significantChangeHash .= $prop . ':'; - - if ($prop === 'EXDATE') { - - $significantChangeHash .= implode(',', $exdate) . ';'; - - } else { - - foreach ($propertyValues as $val) { - $significantChangeHash .= $val->getValue() . ';'; - } - - } - } - } + case 'VEVENT' : + $broker = new EventBroker(); + break; + case 'VTODO' : + $broker = new TodoBroker(); + break; + default : + $broker = new NullBroker(); + break; } - $significantChangeHash = md5($significantChangeHash); + $broker->scheduleAgentServerRules = $this->scheduleAgentServerRules; - return compact( - 'uid', - 'organizer', - 'organizerName', - 'organizerScheduleAgent', - 'organizerForceSend', - 'instances', - 'attendees', - 'sequence', - 'exdate', - 'timezone', - 'significantChangeHash', - 'status' - ); + return $broker; } + } diff --git a/lib/ITip/EventBroker.php b/lib/ITip/EventBroker.php new file mode 100644 index 000000000..d30c87267 --- /dev/null +++ b/lib/ITip/EventBroker.php @@ -0,0 +1,726 @@ +extractSchedulingInfo($before); + } else { + $oldEventInfo = [ + 'organizer' => null, + 'significantChangeHash' => '', + 'attendees' => [], + ]; + } + + $userUri = (array)$userUri; + + if (!is_null($after)) { + + $eventInfo = $this->extractSchedulingInfo($after); + if (!$eventInfo['attendees'] && !$oldEventInfo['attendees']) { + // If there were no attendees on either side of the equation, + // we don't need to do anything. + return []; + } + if (!$eventInfo['organizer'] && !$oldEventInfo['organizer']) { + // There was no organizer before or after the change. + return []; + } + + $baseCalendar = $after; + + // If the new object didn't have an organizer, the organizer + // changed the object from a scheduling object to a non-scheduling + // object. We just copy the info from the old object. + if (!$eventInfo['organizer'] && $oldEventInfo['organizer']) { + $eventInfo['organizer'] = $oldEventInfo['organizer']; + $eventInfo['organizerName'] = $oldEventInfo['organizerName']; + } + + } else { + // The calendar object got deleted, we need to process this as a + // cancellation / decline. + if (!$before) { + // No old and no new calendar, there's no thing to do. + return []; + } + + $eventInfo = $oldEventInfo; + + if (in_array($eventInfo['organizer'], $userUri)) { + // This is an organizer deleting the event. + $eventInfo['attendees'] = []; + // Increasing the sequence, but only if the organizer deleted + // the event. + $eventInfo['sequence']++; + } else { + // This is an attendee deleting the event. + foreach ($eventInfo['attendees'] as $key => $attendee) { + if (in_array($attendee['href'], $userUri)) { + $eventInfo['attendees'][$key]['instances'] = ['master' => + ['id' => 'master', 'partstat' => 'DECLINED'] + ]; + } + } + } + $baseCalendar = $before; + + } + + if (in_array($eventInfo['organizer'], $userUri)) { + return $this->parseEventForOrganizer($baseCalendar, $eventInfo, $oldEventInfo); + } elseif ($before) { + // We need to figure out if the user is an attendee, but we're only + // doing so if there's an oldCalendar, because we only want to + // process updates, not creation of new events. + foreach ($eventInfo['attendees'] as $attendee) { + if (in_array($attendee['href'], $userUri)) { + return $this->parseEventForAttendee($baseCalendar, $eventInfo, $oldEventInfo, $attendee['href']); + } + } + } + return []; + + } + + /** + * This messages takes an iTip message as input, and transforms an + * iCalendar message based on it's input. + * + * Some examples: + * + * 1. A user is an attendee to an event. The organizer sends an updated + * meeting using a new iTip message with METHOD:REQUEST. This function + * will process the message and update the attendee's event accordingly. + * + * 2. The organizer cancelled the event using METHOD:CANCEL. We will update + * the users event to state STATUS:CANCELLED. + * + * 3. An attendee sent a reply to an invite using METHOD:REPLY. We can + * update the organizers event to update the ATTENDEE with its correct + * PARTSTAT. + * + * The $existingObject is updated in-place. If there is no existing object + * (because it's a new invite for example) a new object will be created. + * + * If an existing object does not exist, and the method was CANCEL or + * REPLY, the message effectively gets ignored, and no 'existingObject' + * will be created. + * + * If the iTip message is not supported, this method will not return + * anything. + * + * @param Message $message + * @param VCalendar $existingObject + * @return VCalendar|null + */ + function applyITipMessage(Message $message, VCalendar $existingObject = null) { + + switch ($message->method) { + + case 'REQUEST' : + return $this->applyITipRequest($message, $existingObject); + + case 'CANCEL' : + return $this->applyITipCancel($message, $existingObject); + + case 'REPLY' : + return $this->applyITipReply($message, $existingObject); + + default : + // Unsupported iTip message + return; + + } + + return $existingObject; + + + } + + /** + * This method is used in cases where an event got updated, and we + * potentially need to send emails to attendees to let them know of updates + * in the events. + * + * We will detect which attendees got added, which got removed and create + * specific messages for these situations. + * + * @param VCalendar $calendar + * @param array $eventInfo + * @param array $oldEventInfo + * + * @return array + */ + protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo) { + + // Merging attendee lists. + $attendees = []; + foreach ($oldEventInfo['attendees'] as $attendee) { + $attendees[$attendee['href']] = [ + 'href' => $attendee['href'], + 'oldInstances' => $attendee['instances'], + 'newInstances' => [], + 'name' => $attendee['name'], + 'forceSend' => null, + ]; + } + foreach ($eventInfo['attendees'] as $attendee) { + if (isset($attendees[$attendee['href']])) { + $attendees[$attendee['href']]['name'] = $attendee['name']; + $attendees[$attendee['href']]['newInstances'] = $attendee['instances']; + $attendees[$attendee['href']]['forceSend'] = $attendee['forceSend']; + } else { + $attendees[$attendee['href']] = [ + 'href' => $attendee['href'], + 'oldInstances' => [], + 'newInstances' => $attendee['instances'], + 'name' => $attendee['name'], + 'forceSend' => $attendee['forceSend'], + ]; + } + } + + $messages = []; + + foreach ($attendees as $attendee) { + + // An organizer can also be an attendee. We should not generate any + // messages for those. + if ($attendee['href'] === $eventInfo['organizer']) { + continue; + } + + $message = new Message(); + $message->uid = $eventInfo['uid']; + $message->component = 'VEVENT'; + $message->sequence = $eventInfo['sequence']; + $message->sender = $eventInfo['organizer']; + $message->senderName = $eventInfo['organizerName']; + $message->recipient = $attendee['href']; + $message->recipientName = $attendee['name']; + + if (!$attendee['newInstances']) { + + // If there are no instances the attendee is a part of, it + // means the attendee was removed and we need to send him a + // CANCEL. + $message->method = 'CANCEL'; + + // Creating the new iCalendar body. + $icalMsg = new VCalendar(); + $icalMsg->METHOD = $message->method; + $event = $icalMsg->add('VEVENT', [ + 'UID' => $message->uid, + 'SEQUENCE' => $message->sequence, + ]); + if (isset($calendar->VEVENT->SUMMARY)) { + $event->add('SUMMARY', $calendar->VEVENT->SUMMARY->getValue()); + } + $event->add(clone $calendar->VEVENT->DTSTART); + if (isset($calendar->VEVENT->DTEND)) { + $event->add(clone $calendar->VEVENT->DTEND); + } elseif (isset($calendar->VEVENT->DURATION)) { + $event->add(clone $calendar->VEVENT->DURATION); + } + $org = $event->add('ORGANIZER', $eventInfo['organizer']); + if ($eventInfo['organizerName']) $org['CN'] = $eventInfo['organizerName']; + $event->add('ATTENDEE', $attendee['href'], [ + 'CN' => $attendee['name'], + ]); + $message->significantChange = true; + + } else { + + // The attendee gets the updated event body + $message->method = 'REQUEST'; + + // Creating the new iCalendar body. + $icalMsg = new VCalendar(); + $icalMsg->METHOD = $message->method; + + foreach ($calendar->select('VTIMEZONE') as $timezone) { + $icalMsg->add(clone $timezone); + } + + // We need to find out that this change is significant. If it's + // not, systems may opt to not send messages. + // + // We do this based on the 'significantChangeHash' which is + // some value that changes if there's a certain set of + // properties changed in the event, or simply if there's a + // difference in instances that the attendee is invited to. + + $message->significantChange = + $attendee['forceSend'] === 'REQUEST' || + array_keys($attendee['oldInstances']) != array_keys($attendee['newInstances']) || + $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash']; + + foreach ($attendee['newInstances'] as $instanceId => $instanceInfo) { + + $currentEvent = clone $eventInfo['instances'][$instanceId]; + if ($instanceId === 'master') { + + // We need to find a list of events that the attendee + // is not a part of to add to the list of exceptions. + $exceptions = []; + foreach ($eventInfo['instances'] as $instanceId => $vevent) { + if (!isset($attendee['newInstances'][$instanceId])) { + $exceptions[] = $instanceId; + } + } + + // If there were exceptions, we need to add it to an + // existing EXDATE property, if it exists. + if ($exceptions) { + if (isset($currentEvent->EXDATE)) { + $currentEvent->EXDATE->setParts(array_merge( + $currentEvent->EXDATE->getParts(), + $exceptions + )); + } else { + $currentEvent->EXDATE = $exceptions; + } + } + + // Cleaning up any scheduling information that + // shouldn't be sent along. + unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']); + unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']); + + foreach ($currentEvent->ATTENDEE as $attendee) { + unset($attendee['SCHEDULE-FORCE-SEND']); + unset($attendee['SCHEDULE-STATUS']); + + // We're adding PARTSTAT=NEEDS-ACTION to ensure that + // iOS shows an "Inbox Item" + if (!isset($attendee['PARTSTAT'])) { + $attendee['PARTSTAT'] = 'NEEDS-ACTION'; + } + + } + + } + + $icalMsg->add($currentEvent); + + } + + } + + $message->message = $icalMsg; + $messages[] = $message; + + } + + return $messages; + + } + + /** + * Parse an event update for an attendee. + * + * This function figures out if we need to send a reply to an organizer. + * + * @param VCalendar $calendar + * @param array $eventInfo + * @param array $oldEventInfo + * @param string $attendee + * + * @return Message[] + */ + protected function parseEventForAttendee(VCalendar $calendar, array $eventInfo, array $oldEventInfo, $attendee) { + + if ($this->scheduleAgentServerRules && $eventInfo['organizerScheduleAgent'] === 'CLIENT') { + return []; + } + + // Don't bother generating messages for events that have already been + // cancelled. + if ($eventInfo['status'] === 'CANCELLED') { + return []; + } + + $oldInstances = !empty($oldEventInfo['attendees'][$attendee]['instances']) ? + $oldEventInfo['attendees'][$attendee]['instances'] : + []; + + $instances = []; + foreach ($oldInstances as $instance) { + + $instances[$instance['id']] = [ + 'id' => $instance['id'], + 'oldstatus' => $instance['partstat'], + 'newstatus' => null, + ]; + + } + foreach ($eventInfo['attendees'][$attendee]['instances'] as $instance) { + + if (isset($instances[$instance['id']])) { + $instances[$instance['id']]['newstatus'] = $instance['partstat']; + } else { + $instances[$instance['id']] = [ + 'id' => $instance['id'], + 'oldstatus' => null, + 'newstatus' => $instance['partstat'], + ]; + } + + } + + // We need to also look for differences in EXDATE. If there are new + // items in EXDATE, it means that an attendee deleted instances of an + // event, which means we need to send DECLINED specifically for those + // instances. + // We only need to do that though, if the master event is not declined. + if (isset($instances['master']) && $instances['master']['newstatus'] !== 'DECLINED') { + foreach ($eventInfo['exdate'] as $exDate) { + + if (!in_array($exDate, $oldEventInfo['exdate'])) { + if (isset($instances[$exDate])) { + $instances[$exDate]['newstatus'] = 'DECLINED'; + } else { + $instances[$exDate] = [ + 'id' => $exDate, + 'oldstatus' => null, + 'newstatus' => 'DECLINED', + ]; + } + } + + } + } + + // Gathering a few extra properties for each instance. + foreach ($instances as $recurId => $instanceInfo) { + + if (isset($eventInfo['instances'][$recurId])) { + $instances[$recurId]['dtstart'] = clone $eventInfo['instances'][$recurId]->DTSTART; + } else { + $instances[$recurId]['dtstart'] = $recurId; + } + + } + + $message = new Message(); + $message->uid = $eventInfo['uid']; + $message->method = 'REPLY'; + $message->component = 'VEVENT'; + $message->sequence = $eventInfo['sequence']; + $message->sender = $attendee; + $message->senderName = $eventInfo['attendees'][$attendee]['name']; + $message->recipient = $eventInfo['organizer']; + $message->recipientName = $eventInfo['organizerName']; + + $icalMsg = new VCalendar(); + $icalMsg->METHOD = 'REPLY'; + + $hasReply = false; + + foreach ($instances as $instance) { + + if ($instance['oldstatus'] == $instance['newstatus'] && $eventInfo['organizerForceSend'] !== 'REPLY') { + // Skip + continue; + } + + $event = $icalMsg->add('VEVENT', [ + 'UID' => $message->uid, + 'SEQUENCE' => $message->sequence, + ]); + $summary = isset($calendar->VEVENT->SUMMARY) ? $calendar->VEVENT->SUMMARY->getValue() : ''; + // Adding properties from the correct source instance + if (isset($eventInfo['instances'][$instance['id']])) { + $instanceObj = $eventInfo['instances'][$instance['id']]; + $event->add(clone $instanceObj->DTSTART); + if (isset($instanceObj->DTEND)) { + $event->add(clone $instanceObj->DTEND); + } elseif (isset($instanceObj->DURATION)) { + $event->add(clone $instanceObj->DURATION); + } + if (isset($instanceObj->SUMMARY)) { + $event->add('SUMMARY', $instanceObj->SUMMARY->getValue()); + } elseif ($summary) { + $event->add('SUMMARY', $summary); + } + } else { + // This branch of the code is reached, when a reply is + // generated for an instance of a recurring event, through the + // fact that the instance has disappeared by showing up in + // EXDATE + $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']); + // Treat is as a DATE field + if (strlen($instance['id']) <= 8) { + $event->add('DTSTART', $dt, ['VALUE' => 'DATE']); + } else { + $event->add('DTSTART', $dt); + } + if ($summary) { + $event->add('SUMMARY', $summary); + } + } + if ($instance['id'] !== 'master') { + $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']); + // Treat is as a DATE field + if (strlen($instance['id']) <= 8) { + $event->add('RECURRENCE-ID', $dt, ['VALUE' => 'DATE']); + } else { + $event->add('RECURRENCE-ID', $dt); + } + } + $organizer = $event->add('ORGANIZER', $message->recipient); + if ($message->recipientName) { + $organizer['CN'] = $message->recipientName; + } + $attendee = $event->add('ATTENDEE', $message->sender, [ + 'PARTSTAT' => $instance['newstatus'] + ]); + if ($message->senderName) { + $attendee['CN'] = $message->senderName; + } + $hasReply = true; + + } + + if ($hasReply) { + $message->message = $icalMsg; + return [$message]; + } else { + return []; + } + + } + + /** + * Processes incoming REQUEST messages. + * + * This is message from an organizer, and is either a new event + * invite, or an update to an existing one. + * + * + * @param Message $itipMessage + * @param VCalendar $existingObject + * + * @return VCalendar|null + */ + protected function applyITipRequest(Message $itipMessage, VCalendar $existingObject = null) { + + if (!$existingObject) { + // This is a new invite, and we're just going to copy over + // all the components from the invite. + $existingObject = new VCalendar(); + foreach ($itipMessage->message->getComponents() as $component) { + $existingObject->add(clone $component); + } + } else { + // We need to update an existing object with all the new + // information. We can just remove all existing components + // and create new ones. + foreach ($existingObject->getComponents() as $component) { + $existingObject->remove($component); + } + foreach ($itipMessage->message->getComponents() as $component) { + $existingObject->add(clone $component); + } + } + return $existingObject; + + } + + /** + * Processes incoming CANCEL messages. + * + * This is a message from an organizer, and means that either an + * attendee got removed from an event, or an event got cancelled + * altogether. + * + * @param Message $itipMessage + * @param VCalendar $existingObject + * + * @return VCalendar|null + */ + protected function applyITipCancel(Message $itipMessage, VCalendar $existingObject = null) { + + if (!$existingObject) { + // The event didn't exist in the first place, so we're just + // ignoring this message. + } else { + foreach ($existingObject->VEVENT as $vevent) { + $vevent->STATUS = 'CANCELLED'; + $vevent->SEQUENCE = $itipMessage->sequence; + } + } + return $existingObject; + + } + + /** + * 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 applyITipReply(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; + } + $instances = []; + $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 attendee 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, [ + '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; + } + // 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, [ + 'PARTSTAT' => $partstat + ]); + if ($itipMessage->senderName) { + $attendee['CN'] = $itipMessage->senderName; + } + } + $existingObject->add($newObject); + + } + return $existingObject; + + } + +} diff --git a/lib/ITip/NullBroker.php b/lib/ITip/NullBroker.php new file mode 100644 index 000000000..bec0d93c6 --- /dev/null +++ b/lib/ITip/NullBroker.php @@ -0,0 +1,88 @@ +extractSchedulingInfo($before); + } else { + $beforeInfo = null; + } + if ($after) { + $afterInfo = $this->extractSchedulingInfo($after); + } else { + $afterInfo = null; + } + + $info = $beforeInfo ?: $afterInfo; + + + } + + /** + * This messages takes an iTip message as input, and transforms an + * iCalendar message based on it's input. + * + * Some examples: + * + * 1. A user is an attendee to an event. The organizer sends an updated + * meeting using a new iTip message with METHOD:REQUEST. This function + * will process the message and update the attendee's event accordingly. + * + * 2. The organizer cancelled the event using METHOD:CANCEL. We will update + * the users event to state STATUS:CANCELLED. + * + * 3. An attendee sent a reply to an invite using METHOD:REPLY. We can + * update the organizers event to update the ATTENDEE with its correct + * PARTSTAT. + * + * The $existingObject is updated in-place. If there is no existing object + * (because it's a new invite for example) a new object will be created. + * + * If an existing object does not exist, and the method was CANCEL or + * REPLY, the message effectively gets ignored, and no 'existingObject' + * will be created. + * + * If the iTip message is not supported, this method will not return + * anything. + * + * @param Message $message + * @param VCalendar $existingObject + * @return VCalendar|null + */ + function applyITipMessage(Message $message, VCalendar $existingObject = null) { + + return null; + + } + + + +} diff --git a/tests/VObject/ITip/BrokerTester.php b/tests/VObject/ITip/BrokerTester.php index 6dbb51749..ea24940f8 100644 --- a/tests/VObject/ITip/BrokerTester.php +++ b/tests/VObject/ITip/BrokerTester.php @@ -15,10 +15,21 @@ abstract class BrokerTester extends \PHPUnit_Framework_TestCase { use \Sabre\VObject\PHPUnitAssertions; - function parse($oldMessage, $newMessage, $expected = [], $currentUser = 'mailto:one@example.org') { + /** + * This method calls processICalendarChange and asserts whether the + * change yielded a list of iTip messsages. + */ + function assertICalendarChange($before, $after, $expected = [], $userUri = 'mailto:one@example.org') { + + if (is_string($before)) { + $before = Reader::read($before); + } + if (is_string($after)) { + $after = Reader::read($after); + } $broker = new Broker(); - $result = $broker->parseEvent($newMessage, $currentUser, $oldMessage); + $result = $broker->processICalendarChange($before, $after, $userUri); $this->assertEquals(count($expected), count($result)); diff --git a/tests/VObject/ITip/BrokerTimezoneInParseEventInfoWithoutMasterTest.php b/tests/VObject/ITip/BrokerTimezoneInParseEventInfoWithoutMasterTest.php index 255a84e8c..fcf5ba798 100644 --- a/tests/VObject/ITip/BrokerTimezoneInParseEventInfoWithoutMasterTest.php +++ b/tests/VObject/ITip/BrokerTimezoneInParseEventInfoWithoutMasterTest.php @@ -66,9 +66,9 @@ function testTimezoneInParseEventInfoWithoutMaster() ICS; $calendar = Reader::read($calendar); - $broker = new Broker(); + $broker = new EventBroker(); - $reflectionMethod = new \ReflectionMethod($broker, 'parseEventInfo'); + $reflectionMethod = new \ReflectionMethod($broker, 'extractSchedulingInfo'); $reflectionMethod->setAccessible(true); $data = $reflectionMethod->invoke($broker, $calendar); $this->assertInstanceOf('DateTimeZone', $data['timezone']); diff --git a/tests/VObject/ITip/BrokerAttendeeReplyTest.php b/tests/VObject/ITip/EventBroker/AttendeeReplyTest.php similarity index 94% rename from tests/VObject/ITip/BrokerAttendeeReplyTest.php rename to tests/VObject/ITip/EventBroker/AttendeeReplyTest.php index 9519ed368..fa8fcc981 100644 --- a/tests/VObject/ITip/BrokerAttendeeReplyTest.php +++ b/tests/VObject/ITip/EventBroker/AttendeeReplyTest.php @@ -1,8 +1,10 @@ parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -218,7 +220,7 @@ function testRecurringReply() { ]; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -361,7 +363,7 @@ function testRecurringAllDay() { ]; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -396,7 +398,7 @@ function testNoChange() { $expected = []; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -458,7 +460,7 @@ function testNoChangeForceSend() { ] ]; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -492,7 +494,7 @@ function testNoRelevantAttendee() { ICS; $expected = []; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -566,7 +568,7 @@ function testCreateReplyByException() { ], ]; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -638,7 +640,7 @@ function testCreateReplyByExceptionTz() { ], ]; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -710,7 +712,7 @@ function testCreateReplyByExceptionAllDay() { ], ]; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -775,7 +777,7 @@ function testDeclined() { ]; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -814,7 +816,7 @@ function testDeclinedCancelledEvent() { $expected = []; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -870,7 +872,7 @@ function testDontCreateReplyWhenEventWasDeclined() { $expected = []; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -906,7 +908,7 @@ function testScheduleAgentOnOrganizer() { $version = \Sabre\VObject\Version::VERSION; $expected = []; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -971,7 +973,7 @@ function testAcceptedAllDay() { ]; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -1048,7 +1050,7 @@ function testReplyNoMasterEvent() { ]; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } @@ -1140,7 +1142,7 @@ function testPartyCrasher() { ]; - $this->parse($oldMessage, $newMessage, $expected); + $this->assertICalendarChange($oldMessage, $newMessage, $expected); } } diff --git a/tests/VObject/ITip/BrokerDeleteEventTest.php b/tests/VObject/ITip/EventBroker/DeleteEventTest.php similarity index 88% rename from tests/VObject/ITip/BrokerDeleteEventTest.php rename to tests/VObject/ITip/EventBroker/DeleteEventTest.php index 935c451fe..53c32bdf3 100644 --- a/tests/VObject/ITip/BrokerDeleteEventTest.php +++ b/tests/VObject/ITip/EventBroker/DeleteEventTest.php @@ -1,8 +1,10 @@ parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -172,7 +174,7 @@ function testOrganizerDeleteWithDuration() { ], ]; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -229,7 +231,7 @@ function testAttendeeDeleteWithDtend() { ], ]; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:one@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:one@example.org'); } @@ -287,7 +289,7 @@ function testAttendeeReplyWithDuration() { ], ]; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:one@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:one@example.org'); } @@ -315,14 +317,17 @@ function testAttendeeDeleteCancelledEvent() { $expected = []; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:one@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:one@example.org'); } + /** + * @expectedException \InvalidArgumentException + */ function testNoCalendar() { - $this->parse(null, null, [], 'mailto:one@example.org'); + $this->assertICalendarChange(null, null, [], 'mailto:one@example.org'); } @@ -337,7 +342,7 @@ function testVTodo() { END:VTODO END:VCALENDAR ICS; - $this->parse($oldMessage, null, [], 'mailto:one@example.org'); + $this->assertICalendarChange($oldMessage, null, [], 'mailto:one@example.org'); } diff --git a/tests/VObject/ITip/EvolutionTest.php b/tests/VObject/ITip/EventBroker/EvolutionTest.php similarity index 99% rename from tests/VObject/ITip/EvolutionTest.php rename to tests/VObject/ITip/EventBroker/EvolutionTest.php index 3afe560d5..d2c03774c 100644 --- a/tests/VObject/ITip/EvolutionTest.php +++ b/tests/VObject/ITip/EventBroker/EvolutionTest.php @@ -1,6 +1,8 @@ $expectedICS, ] ]; - $this->parse(null, $ics, $expected, 'mailto:martin@fruux.com'); + $this->assertICalendarChange(null, $ics, $expected, 'mailto:martin@fruux.com'); } @@ -2644,7 +2646,7 @@ function testAttendeeModify() { END:VCALENDAR ICS; - $this->parse($old, $new, [], 'mailto:a1@example.org'); + $this->assertICalendarChange($old, $new, [], 'mailto:a1@example.org'); } diff --git a/tests/VObject/ITip/BrokerNewEventTest.php b/tests/VObject/ITip/EventBroker/NewEventTest.php similarity index 91% rename from tests/VObject/ITip/BrokerNewEventTest.php rename to tests/VObject/ITip/EventBroker/NewEventTest.php index 05cf452a8..2057a810a 100644 --- a/tests/VObject/ITip/BrokerNewEventTest.php +++ b/tests/VObject/ITip/EventBroker/NewEventTest.php @@ -1,8 +1,10 @@ parse(null, $message, []); + $result = $this->assertICalendarChange(null, $message, []); } @@ -30,7 +32,7 @@ function testVTODO() { END:VCALENDAR ICS; - $result = $this->parse(null, $message, []); + $result = $this->assertICalendarChange(null, $message, []); } @@ -79,7 +81,7 @@ function testSimpleInvite() { ], ]; - $this->parse(null, $message, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange(null, $message, $expected, 'mailto:strunk@example.org'); } @@ -104,7 +106,7 @@ function testBrokenEventUIDMisMatch() { END:VCALENDAR ICS; - $this->parse(null, $message, [], 'mailto:strunk@example.org'); + $this->assertICalendarChange(null, $message, [], 'mailto:strunk@example.org'); } /** @@ -128,7 +130,7 @@ function testBrokenEventOrganizerMisMatch() { END:VCALENDAR ICS; - $this->parse(null, $message, [], 'mailto:strunk@example.org'); + $this->assertICalendarChange(null, $message, [], 'mailto:strunk@example.org'); } @@ -256,7 +258,7 @@ function testRecurrenceInvite() { ], ]; - $this->parse(null, $message, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange(null, $message, $expected, 'mailto:strunk@example.org'); } @@ -384,7 +386,7 @@ function testRecurrenceInvite2() { ], ]; - $this->parse(null, $message, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange(null, $message, $expected, 'mailto:strunk@example.org'); } @@ -405,7 +407,7 @@ function testScheduleAgentClient() { $version = \Sabre\VObject\Version::VERSION; - $this->parse(null, $message, [], 'mailto:strunk@example.org'); + $this->assertICalendarChange(null, $message, [], 'mailto:strunk@example.org'); } @@ -439,7 +441,7 @@ function testMultipleUID() { ICS; $version = \Sabre\VObject\Version::VERSION; - $this->parse(null, $message, [], 'mailto:strunk@example.org'); + $this->assertICalendarChange(null, $message, [], 'mailto:strunk@example.org'); } @@ -473,7 +475,7 @@ function testChangingOrganizers() { ICS; $version = \Sabre\VObject\Version::VERSION; - $this->parse(null, $message, [], 'mailto:strunk@example.org'); + $this->assertICalendarChange(null, $message, [], 'mailto:strunk@example.org'); } function testNoOrganizerHasAttendee() { @@ -489,7 +491,7 @@ function testNoOrganizerHasAttendee() { END:VCALENDAR ICS; - $this->parse(null, $message, [], 'mailto:strunk@example.org'); + $this->assertICalendarChange(null, $message, [], 'mailto:strunk@example.org'); } diff --git a/tests/VObject/ITip/BrokerProcessMessageTest.php b/tests/VObject/ITip/EventBroker/ProcessMessageTest.php similarity index 97% rename from tests/VObject/ITip/BrokerProcessMessageTest.php rename to tests/VObject/ITip/EventBroker/ProcessMessageTest.php index 691574a89..6b08351c3 100644 --- a/tests/VObject/ITip/BrokerProcessMessageTest.php +++ b/tests/VObject/ITip/EventBroker/ProcessMessageTest.php @@ -2,7 +2,7 @@ namespace Sabre\VObject\ITip; -class BrokerProcessMessageTest extends BrokerTester { +class ProcessMessageTest extends BrokerTester { function testRequestNew() { diff --git a/tests/VObject/ITip/BrokerProcessReplyTest.php b/tests/VObject/ITip/EventBroker/ProcessReplyTest.php similarity index 99% rename from tests/VObject/ITip/BrokerProcessReplyTest.php rename to tests/VObject/ITip/EventBroker/ProcessReplyTest.php index 533fdce15..746fdb118 100644 --- a/tests/VObject/ITip/BrokerProcessReplyTest.php +++ b/tests/VObject/ITip/EventBroker/ProcessReplyTest.php @@ -2,7 +2,7 @@ namespace Sabre\VObject\ITip; -class BrokerProcessReplyTest extends BrokerTester { +class ProcessReplyTest extends BrokerTester { function testReplyNoOriginal() { diff --git a/tests/VObject/ITip/BrokerUpdateEventTest.php b/tests/VObject/ITip/EventBroker/UpdateEventTest.php similarity index 94% rename from tests/VObject/ITip/BrokerUpdateEventTest.php rename to tests/VObject/ITip/EventBroker/UpdateEventTest.php index bc109009e..5e6fac47c 100644 --- a/tests/VObject/ITip/BrokerUpdateEventTest.php +++ b/tests/VObject/ITip/EventBroker/UpdateEventTest.php @@ -1,8 +1,10 @@ parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -200,7 +202,7 @@ function testInviteChangeFromNonSchedulingToSchedulingObject() { ]; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -262,7 +264,7 @@ function testInviteChangeFromSchedulingToNonSchedulingObject() { ]; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -296,7 +298,7 @@ function testNoAttendees() { $version = \Sabre\VObject\Version::VERSION; $expected = []; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -367,7 +369,7 @@ function testRemoveInstance() { ], ]; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -507,7 +509,7 @@ function testInviteChangeSignificantChange() { ], ]; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -578,7 +580,7 @@ function testInviteNoChange() { ]; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -649,7 +651,7 @@ function testInviteNoChangeForceSend() { ]; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -749,7 +751,7 @@ function testInviteRemoveAttendees() { ], ]; - $result = $this->parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $result = $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } @@ -840,7 +842,7 @@ function testInviteChangeExdateOrder() { ], ]; - $this->parse($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); + $this->assertICalendarChange($oldMessage, $newMessage, $expected, 'mailto:strunk@example.org'); } } diff --git a/tests/VObject/ITip/TodoBroker/NewTodoTest.php b/tests/VObject/ITip/TodoBroker/NewTodoTest.php new file mode 100644 index 000000000..011bb8617 --- /dev/null +++ b/tests/VObject/ITip/TodoBroker/NewTodoTest.php @@ -0,0 +1,75 @@ +assertICalendarChange($before, $after, $expected); + + } + + function testAttendee() { + + $before = null; + $after = << 'foo', + 'component' => 'VTODO', + 'method' => 'REQUEST', + 'sequence' => 1, + 'sender' => 'mailto:one@example.org', + 'senderName' => null, + 'recipient' => 'mailto:two@example.org', + 'recipientName' => null, + 'scheduleStatus' => null, + 'significantChange' => true, + 'message' => <<assertICalendarChange($before, $after, $expected); + + + } + + + +}