1<?php
2
3namespace Sabre\VObject\ITip;
4
5use Sabre\VObject\Component\VCalendar;
6use Sabre\VObject\DateTimeParser;
7use Sabre\VObject\Reader;
8use Sabre\VObject\Recur\EventIterator;
9
10/**
11 * The ITip\Broker class is a utility class that helps with processing
12 * so-called iTip messages.
13 *
14 * iTip is defined in rfc5546, stands for iCalendar Transport-Independent
15 * Interoperability Protocol, and describes the underlying mechanism for
16 * using iCalendar for scheduling for for example through email (also known as
17 * IMip) and CalDAV Scheduling.
18 *
19 * This class helps by:
20 *
21 * 1. Creating individual invites based on an iCalendar event for each
22 *    attendee.
23 * 2. Generating invite updates based on an iCalendar update. This may result
24 *    in new invites, updates and cancellations for attendees, if that list
25 *    changed.
26 * 3. On the receiving end, it can create a local iCalendar event based on
27 *    a received invite.
28 * 4. It can also process an invite update on a local event, ensuring that any
29 *    overridden properties from attendees are retained.
30 * 5. It can create a accepted or declined iTip reply based on an invite.
31 * 6. It can process a reply from an invite and update an events attendee
32 *     status based on a reply.
33 *
34 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
35 * @author Evert Pot (http://evertpot.com/)
36 * @license http://sabre.io/license/ Modified BSD License
37 */
38class Broker {
39
40    /**
41     * This setting determines whether the rules for the SCHEDULE-AGENT
42     * parameter should be followed.
43     *
44     * This is a parameter defined on ATTENDEE properties, introduced by RFC
45     * 6638. This parameter allows a caldav client to tell the server 'Don't do
46     * any scheduling operations'.
47     *
48     * If this setting is turned on, any attendees with SCHEDULE-AGENT set to
49     * CLIENT will be ignored. This is the desired behavior for a CalDAV
50     * server, but if you're writing an iTip application that doesn't deal with
51     * CalDAV, you may want to ignore this parameter.
52     *
53     * @var bool
54     */
55    public $scheduleAgentServerRules = true;
56
57    /**
58     * The broker will try during 'parseEvent' figure out whether the change
59     * was significant.
60     *
61     * It uses a few different ways to do this. One of these ways is seeing if
62     * certain properties changed values. This list of specified here.
63     *
64     * This list is taken from:
65     * * http://tools.ietf.org/html/rfc5546#section-2.1.4
66     *
67     * @var string[]
68     */
69    public $significantChangeProperties = array(
70        'DTSTART',
71        'DTEND',
72        'DURATION',
73        'DUE',
74        'RRULE',
75        'RDATE',
76        'EXDATE',
77        'STATUS',
78    );
79
80    /**
81     * This method is used to process an incoming itip message.
82     *
83     * Examples:
84     *
85     * 1. A user is an attendee to an event. The organizer sends an updated
86     * meeting using a new iTip message with METHOD:REQUEST. This function
87     * will process the message and update the attendee's event accordingly.
88     *
89     * 2. The organizer cancelled the event using METHOD:CANCEL. We will update
90     * the users event to state STATUS:CANCELLED.
91     *
92     * 3. An attendee sent a reply to an invite using METHOD:REPLY. We can
93     * update the organizers event to update the ATTENDEE with its correct
94     * PARTSTAT.
95     *
96     * The $existingObject is updated in-place. If there is no existing object
97     * (because it's a new invite for example) a new object will be created.
98     *
99     * If an existing object does not exist, and the method was CANCEL or
100     * REPLY, the message effectively gets ignored, and no 'existingObject'
101     * will be created.
102     *
103     * The updated $existingObject is also returned from this function.
104     *
105     * If the iTip message was not supported, we will always return false.
106     *
107     * @param Message $itipMessage
108     * @param VCalendar $existingObject
109     * @return VCalendar|null
110     */
111    public function processMessage(Message $itipMessage, VCalendar $existingObject = null) {
112
113        // We only support events at the moment.
114        if ($itipMessage->component !== 'VEVENT') {
115            return false;
116        }
117
118        switch($itipMessage->method) {
119
120            case 'REQUEST' :
121                return $this->processMessageRequest($itipMessage, $existingObject);
122
123            case 'CANCEL' :
124                return $this->processMessageCancel($itipMessage, $existingObject);
125
126            case 'REPLY' :
127                return $this->processMessageReply($itipMessage, $existingObject);
128
129            default :
130                // Unsupported iTip message
131                return null;
132
133        }
134
135        return $existingObject;
136
137    }
138
139    /**
140     * This function parses a VCALENDAR object and figure out if any messages
141     * need to be sent.
142     *
143     * A VCALENDAR object will be created from the perspective of either an
144     * attendee, or an organizer. You must pass a string identifying the
145     * current user, so we can figure out who in the list of attendees or the
146     * organizer we are sending this message on behalf of.
147     *
148     * It's possible to specify the current user as an array, in case the user
149     * has more than one identifying href (such as multiple emails).
150     *
151     * It $oldCalendar is specified, it is assumed that the operation is
152     * updating an existing event, which means that we need to look at the
153     * differences between events, and potentially send old attendees
154     * cancellations, and current attendees updates.
155     *
156     * If $calendar is null, but $oldCalendar is specified, we treat the
157     * operation as if the user has deleted an event. If the user was an
158     * organizer, this means that we need to send cancellation notices to
159     * people. If the user was an attendee, we need to make sure that the
160     * organizer gets the 'declined' message.
161     *
162     * @param VCalendar|string $calendar
163     * @param string|array $userHref
164     * @param VCalendar|string $oldCalendar
165     * @return array
166     */
167    public function parseEvent($calendar = null, $userHref, $oldCalendar = null) {
168
169        if ($oldCalendar) {
170            if (is_string($oldCalendar)) {
171                $oldCalendar = Reader::read($oldCalendar);
172            }
173            if (!isset($oldCalendar->VEVENT)) {
174                // We only support events at the moment
175                return array();
176            }
177
178            $oldEventInfo = $this->parseEventInfo($oldCalendar);
179        } else {
180            $oldEventInfo = array(
181                'organizer' => null,
182                'significantChangeHash' => '',
183                'attendees' => array(),
184            );
185        }
186
187        $userHref = (array)$userHref;
188
189        if (!is_null($calendar)) {
190
191            if (is_string($calendar)) {
192                $calendar = Reader::read($calendar);
193            }
194            if (!isset($calendar->VEVENT)) {
195                // We only support events at the moment
196                return array();
197            }
198            $eventInfo = $this->parseEventInfo($calendar);
199            if (!$eventInfo['attendees'] && !$oldEventInfo['attendees']) {
200                // If there were no attendees on either side of the equation,
201                // we don't need to do anything.
202                return array();
203            }
204            if (!$eventInfo['organizer'] && !$oldEventInfo['organizer']) {
205                // There was no organizer before or after the change.
206                return array();
207            }
208
209            $baseCalendar = $calendar;
210
211            // If the new object didn't have an organizer, the organizer
212            // changed the object from a scheduling object to a non-scheduling
213            // object. We just copy the info from the old object.
214            if (!$eventInfo['organizer'] && $oldEventInfo['organizer']) {
215                $eventInfo['organizer'] = $oldEventInfo['organizer'];
216                $eventInfo['organizerName'] = $oldEventInfo['organizerName'];
217            }
218
219        } else {
220            // The calendar object got deleted, we need to process this as a
221            // cancellation / decline.
222            if (!$oldCalendar) {
223                // No old and no new calendar, there's no thing to do.
224                return array();
225            }
226
227            $eventInfo = $oldEventInfo;
228
229            if (in_array($eventInfo['organizer'], $userHref)) {
230                // This is an organizer deleting the event.
231                $eventInfo['attendees'] = array();
232                // Increasing the sequence, but only if the organizer deleted
233                // the event.
234                $eventInfo['sequence']++;
235            } else {
236                // This is an attendee deleting the event.
237                foreach($eventInfo['attendees'] as $key=>$attendee) {
238                    if (in_array($attendee['href'], $userHref)) {
239                        $eventInfo['attendees'][$key]['instances'] = array('master' =>
240                            array('id'=>'master', 'partstat' => 'DECLINED')
241                        );
242                    }
243                }
244            }
245            $baseCalendar = $oldCalendar;
246
247        }
248
249        if (in_array($eventInfo['organizer'], $userHref)) {
250            return $this->parseEventForOrganizer($baseCalendar, $eventInfo, $oldEventInfo);
251        } elseif ($oldCalendar) {
252            // We need to figure out if the user is an attendee, but we're only
253            // doing so if there's an oldCalendar, because we only want to
254            // process updates, not creation of new events.
255            foreach($eventInfo['attendees'] as $attendee) {
256                if (in_array($attendee['href'], $userHref)) {
257                    return $this->parseEventForAttendee($baseCalendar, $eventInfo, $oldEventInfo, $attendee['href']);
258                }
259            }
260        }
261        return array();
262
263    }
264
265    /**
266     * Processes incoming REQUEST messages.
267     *
268     * This is message from an organizer, and is either a new event
269     * invite, or an update to an existing one.
270     *
271     *
272     * @param Message $itipMessage
273     * @param VCalendar $existingObject
274     * @return VCalendar|null
275     */
276    protected function processMessageRequest(Message $itipMessage, VCalendar $existingObject = null) {
277
278        if (!$existingObject) {
279            // This is a new invite, and we're just going to copy over
280            // all the components from the invite.
281            $existingObject = new VCalendar();
282            foreach($itipMessage->message->getComponents() as $component) {
283                $existingObject->add(clone $component);
284            }
285        } else {
286            // We need to update an existing object with all the new
287            // information. We can just remove all existing components
288            // and create new ones.
289            foreach($existingObject->getComponents() as $component) {
290                $existingObject->remove($component);
291            }
292            foreach($itipMessage->message->getComponents() as $component) {
293                $existingObject->add(clone $component);
294            }
295        }
296        return $existingObject;
297
298    }
299
300    /**
301     * Processes incoming CANCEL messages.
302     *
303     * This is a message from an organizer, and means that either an
304     * attendee got removed from an event, or an event got cancelled
305     * altogether.
306     *
307     * @param Message $itipMessage
308     * @param VCalendar $existingObject
309     * @return VCalendar|null
310     */
311    protected function processMessageCancel(Message $itipMessage, VCalendar $existingObject = null) {
312
313        if (!$existingObject) {
314            // The event didn't exist in the first place, so we're just
315            // ignoring this message.
316        } else {
317            foreach($existingObject->VEVENT as $vevent) {
318                $vevent->STATUS = 'CANCELLED';
319                $vevent->SEQUENCE = $itipMessage->sequence;
320            }
321        }
322        return $existingObject;
323
324    }
325
326    /**
327     * Processes incoming REPLY messages.
328     *
329     * The message is a reply. This is for example an attendee telling
330     * an organizer he accepted the invite, or declined it.
331     *
332     * @param Message $itipMessage
333     * @param VCalendar $existingObject
334     * @return VCalendar|null
335     */
336    protected function processMessageReply(Message $itipMessage, VCalendar $existingObject = null) {
337
338        // A reply can only be processed based on an existing object.
339        // If the object is not available, the reply is ignored.
340        if (!$existingObject) {
341            return null;
342        }
343        $instances = array();
344        $requestStatus = '2.0';
345
346        // Finding all the instances the attendee replied to.
347        foreach($itipMessage->message->VEVENT as $vevent) {
348            $recurId = isset($vevent->{'RECURRENCE-ID'})?$vevent->{'RECURRENCE-ID'}->getValue():'master';
349            $attendee = $vevent->ATTENDEE;
350            $instances[$recurId] = $attendee['PARTSTAT']->getValue();
351            if (isset($vevent->{'REQUEST-STATUS'})) {
352                $requestStatus = $vevent->{'REQUEST-STATUS'}->getValue();
353                list($requestStatus) = explode(';', $requestStatus);
354            }
355        }
356
357        // Now we need to loop through the original organizer event, to find
358        // all the instances where we have a reply for.
359        $masterObject = null;
360        foreach($existingObject->VEVENT as $vevent) {
361            $recurId = isset($vevent->{'RECURRENCE-ID'})?$vevent->{'RECURRENCE-ID'}->getValue():'master';
362            if ($recurId==='master') {
363                $masterObject = $vevent;
364            }
365            if (isset($instances[$recurId])) {
366                $attendeeFound = false;
367                if (isset($vevent->ATTENDEE)) {
368                    foreach($vevent->ATTENDEE as $attendee) {
369                        if ($attendee->getValue() === $itipMessage->sender) {
370                            $attendeeFound = true;
371                            $attendee['PARTSTAT'] = $instances[$recurId];
372                            $attendee['SCHEDULE-STATUS'] = $requestStatus;
373                            // Un-setting the RSVP status, because we now know
374                            // that the attende already replied.
375                            unset($attendee['RSVP']);
376                            break;
377                        }
378                    }
379                }
380                if (!$attendeeFound) {
381                    // Adding a new attendee. The iTip documentation calls this
382                    // a party crasher.
383                    $attendee = $vevent->add('ATTENDEE', $itipMessage->sender, array(
384                        'PARTSTAT' => $instances[$recurId]
385                    ));
386                    if ($itipMessage->senderName) $attendee['CN'] = $itipMessage->senderName;
387                }
388                unset($instances[$recurId]);
389            }
390        }
391
392        if(!$masterObject) {
393            // No master object, we can't add new instances.
394            return null;
395        }
396        // If we got replies to instances that did not exist in the
397        // original list, it means that new exceptions must be created.
398        foreach($instances as $recurId=>$partstat) {
399
400            $recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
401            $found = false;
402            $iterations = 1000;
403            do {
404
405                $newObject = $recurrenceIterator->getEventObject();
406                $recurrenceIterator->next();
407
408                if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getValue()===$recurId) {
409                    $found = true;
410                }
411                $iterations--;
412
413            } while($recurrenceIterator->valid() && !$found && $iterations);
414
415            // Invalid recurrence id. Skipping this object.
416            if (!$found) continue;
417
418            unset(
419                $newObject->RRULE,
420                $newObject->EXDATE,
421                $newObject->RDATE
422            );
423            $attendeeFound = false;
424            if (isset($newObject->ATTENDEE)) {
425                foreach($newObject->ATTENDEE as $attendee) {
426                    if ($attendee->getValue() === $itipMessage->sender) {
427                        $attendeeFound = true;
428                        $attendee['PARTSTAT'] = $partstat;
429                        break;
430                    }
431                }
432            }
433            if (!$attendeeFound) {
434                // Adding a new attendee
435                $attendee = $newObject->add('ATTENDEE', $itipMessage->sender, array(
436                    'PARTSTAT' => $partstat
437                ));
438                if ($itipMessage->senderName) {
439                    $attendee['CN'] = $itipMessage->senderName;
440                }
441            }
442            $existingObject->add($newObject);
443
444        }
445        return $existingObject;
446
447    }
448
449    /**
450     * This method is used in cases where an event got updated, and we
451     * potentially need to send emails to attendees to let them know of updates
452     * in the events.
453     *
454     * We will detect which attendees got added, which got removed and create
455     * specific messages for these situations.
456     *
457     * @param VCalendar $calendar
458     * @param array $eventInfo
459     * @param array $oldEventInfo
460     * @return array
461     */
462    protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo) {
463
464        // Merging attendee lists.
465        $attendees = array();
466        foreach($oldEventInfo['attendees'] as $attendee) {
467            $attendees[$attendee['href']] = array(
468                'href' => $attendee['href'],
469                'oldInstances' => $attendee['instances'],
470                'newInstances' => array(),
471                'name' => $attendee['name'],
472                'forceSend' => null,
473            );
474        }
475        foreach($eventInfo['attendees'] as $attendee) {
476            if (isset($attendees[$attendee['href']])) {
477                $attendees[$attendee['href']]['name'] = $attendee['name'];
478                $attendees[$attendee['href']]['newInstances'] = $attendee['instances'];
479                $attendees[$attendee['href']]['forceSend'] = $attendee['forceSend'];
480            } else {
481                $attendees[$attendee['href']] = array(
482                    'href' => $attendee['href'],
483                    'oldInstances' => array(),
484                    'newInstances' => $attendee['instances'],
485                    'name' => $attendee['name'],
486                    'forceSend' => $attendee['forceSend'],
487                );
488            }
489        }
490
491        $messages = array();
492
493        foreach($attendees as $attendee) {
494
495            // An organizer can also be an attendee. We should not generate any
496            // messages for those.
497            if ($attendee['href']===$eventInfo['organizer']) {
498                continue;
499            }
500
501            $message = new Message();
502            $message->uid = $eventInfo['uid'];
503            $message->component = 'VEVENT';
504            $message->sequence = $eventInfo['sequence'];
505            $message->sender = $eventInfo['organizer'];
506            $message->senderName = $eventInfo['organizerName'];
507            $message->recipient = $attendee['href'];
508            $message->recipientName = $attendee['name'];
509
510            if (!$attendee['newInstances']) {
511
512                // If there are no instances the attendee is a part of, it
513                // means the attendee was removed and we need to send him a
514                // CANCEL.
515                $message->method = 'CANCEL';
516
517                // Creating the new iCalendar body.
518                $icalMsg = new VCalendar();
519                $icalMsg->METHOD = $message->method;
520                $event = $icalMsg->add('VEVENT', array(
521                    'UID' => $message->uid,
522                    'SEQUENCE' => $message->sequence,
523                ));
524                if (isset($calendar->VEVENT->SUMMARY)) {
525                    $event->add('SUMMARY', $calendar->VEVENT->SUMMARY->getValue());
526                }
527                $event->add(clone $calendar->VEVENT->DTSTART);
528                if (isset($calendar->VEVENT->DTEND)) {
529                    $event->add(clone $calendar->VEVENT->DTEND);
530                } elseif (isset($calendar->VEVENT->DURATION)) {
531                    $event->add(clone $calendar->VEVENT->DURATION);
532                }
533                $org = $event->add('ORGANIZER', $eventInfo['organizer']);
534                if ($eventInfo['organizerName']) $org['CN'] = $eventInfo['organizerName'];
535                $event->add('ATTENDEE', $attendee['href'], array(
536                    'CN' => $attendee['name'],
537                ));
538                $message->significantChange = true;
539
540            } else {
541
542                // The attendee gets the updated event body
543                $message->method = 'REQUEST';
544
545                // Creating the new iCalendar body.
546                $icalMsg = new VCalendar();
547                $icalMsg->METHOD = $message->method;
548
549                foreach($calendar->select('VTIMEZONE') as $timezone) {
550                    $icalMsg->add(clone $timezone);
551                }
552
553                // We need to find out that this change is significant. If it's
554                // not, systems may opt to not send messages.
555                //
556                // We do this based on the 'significantChangeHash' which is
557                // some value that changes if there's a certain set of
558                // properties changed in the event, or simply if there's a
559                // difference in instances that the attendee is invited to.
560
561                $message->significantChange =
562                    $attendee['forceSend'] === 'REQUEST' ||
563                    array_keys($attendee['oldInstances']) != array_keys($attendee['newInstances']) ||
564                    $oldEventInfo['significantChangeHash']!==$eventInfo['significantChangeHash'];
565
566                foreach($attendee['newInstances'] as $instanceId => $instanceInfo) {
567
568                    $currentEvent = clone $eventInfo['instances'][$instanceId];
569                    if ($instanceId === 'master') {
570
571                        // We need to find a list of events that the attendee
572                        // is not a part of to add to the list of exceptions.
573                        $exceptions = array();
574                        foreach($eventInfo['instances'] as $instanceId=>$vevent) {
575                            if (!isset($attendee['newInstances'][$instanceId])) {
576                                $exceptions[] = $instanceId;
577                            }
578                        }
579
580                        // If there were exceptions, we need to add it to an
581                        // existing EXDATE property, if it exists.
582                        if ($exceptions) {
583                            if (isset($currentEvent->EXDATE)) {
584                                $currentEvent->EXDATE->setParts(array_merge(
585                                    $currentEvent->EXDATE->getParts(),
586                                    $exceptions
587                                ));
588                            } else {
589                                $currentEvent->EXDATE = $exceptions;
590                            }
591                        }
592
593                        // Cleaning up any scheduling information that
594                        // shouldn't be sent along.
595                        unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']);
596                        unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']);
597
598                        foreach($currentEvent->ATTENDEE as $attendee) {
599                            unset($attendee['SCHEDULE-FORCE-SEND']);
600                            unset($attendee['SCHEDULE-STATUS']);
601
602                            // We're adding PARTSTAT=NEEDS-ACTION to ensure that
603                            // iOS shows an "Inbox Item"
604                            if (!isset($attendee['PARTSTAT'])) {
605                                $attendee['PARTSTAT'] = 'NEEDS-ACTION';
606                            }
607
608                        }
609
610                    }
611
612                    $icalMsg->add($currentEvent);
613
614                }
615
616            }
617
618            $message->message = $icalMsg;
619            $messages[] = $message;
620
621        }
622
623        return $messages;
624
625    }
626
627    /**
628     * Parse an event update for an attendee.
629     *
630     * This function figures out if we need to send a reply to an organizer.
631     *
632     * @param VCalendar $calendar
633     * @param array $eventInfo
634     * @param array $oldEventInfo
635     * @param string $attendee
636     * @return Message[]
637     */
638    protected function parseEventForAttendee(VCalendar $calendar, array $eventInfo, array $oldEventInfo, $attendee) {
639
640        if ($this->scheduleAgentServerRules && $eventInfo['organizerScheduleAgent']==='CLIENT') {
641            return array();
642        }
643
644        // Don't bother generating messages for events that have already been
645        // cancelled.
646        if ($eventInfo['status']==='CANCELLED') {
647            return array();
648        }
649
650        $oldInstances = !empty($oldEventInfo['attendees'][$attendee]['instances']) ?
651            $oldEventInfo['attendees'][$attendee]['instances'] :
652            array();
653
654        $instances = array();
655        foreach($oldInstances as $instance) {
656
657            $instances[$instance['id']] = array(
658                'id' => $instance['id'],
659                'oldstatus' => $instance['partstat'],
660                'newstatus' => null,
661            );
662
663        }
664        foreach($eventInfo['attendees'][$attendee]['instances'] as $instance) {
665
666            if (isset($instances[$instance['id']])) {
667                $instances[$instance['id']]['newstatus'] = $instance['partstat'];
668            } else {
669                $instances[$instance['id']] = array(
670                    'id' => $instance['id'],
671                    'oldstatus' => null,
672                    'newstatus' => $instance['partstat'],
673                );
674            }
675
676        }
677
678        // We need to also look for differences in EXDATE. If there are new
679        // items in EXDATE, it means that an attendee deleted instances of an
680        // event, which means we need to send DECLINED specifically for those
681        // instances.
682        // We only need to do that though, if the master event is not declined.
683        if (isset($instances['master']) && $instances['master']['newstatus'] !== 'DECLINED') {
684            foreach($eventInfo['exdate'] as $exDate) {
685
686                if (!in_array($exDate, $oldEventInfo['exdate'])) {
687                    if (isset($instances[$exDate])) {
688                        $instances[$exDate]['newstatus'] = 'DECLINED';
689                    } else {
690                        $instances[$exDate] = array(
691                            'id' => $exDate,
692                            'oldstatus' => null,
693                            'newstatus' => 'DECLINED',
694                        );
695                    }
696                }
697
698            }
699        }
700
701        // Gathering a few extra properties for each instance.
702        foreach($instances as $recurId=>$instanceInfo) {
703
704            if (isset($eventInfo['instances'][$recurId])) {
705                $instances[$recurId]['dtstart'] = clone $eventInfo['instances'][$recurId]->DTSTART;
706            } else {
707                $instances[$recurId]['dtstart'] = $recurId;
708            }
709
710        }
711
712        $message = new Message();
713        $message->uid = $eventInfo['uid'];
714        $message->method = 'REPLY';
715        $message->component = 'VEVENT';
716        $message->sequence = $eventInfo['sequence'];
717        $message->sender = $attendee;
718        $message->senderName = $eventInfo['attendees'][$attendee]['name'];
719        $message->recipient = $eventInfo['organizer'];
720        $message->recipientName = $eventInfo['organizerName'];
721
722        $icalMsg = new VCalendar();
723        $icalMsg->METHOD = 'REPLY';
724
725        $hasReply = false;
726
727        foreach($instances as $instance) {
728
729            if ($instance['oldstatus']==$instance['newstatus'] && $eventInfo['organizerForceSend'] !== 'REPLY') {
730                // Skip
731                continue;
732            }
733
734            $event = $icalMsg->add('VEVENT', array(
735                'UID' => $message->uid,
736                'SEQUENCE' => $message->sequence,
737            ));
738            $summary = isset($calendar->VEVENT->SUMMARY)?$calendar->VEVENT->SUMMARY->getValue():'';
739            // Adding properties from the correct source instance
740            if (isset($eventInfo['instances'][$instance['id']])) {
741                $instanceObj = $eventInfo['instances'][$instance['id']];
742                $event->add(clone $instanceObj->DTSTART);
743                if (isset($instanceObj->DTEND)) {
744                    $event->add(clone $instanceObj->DTEND);
745                } elseif (isset($instanceObj->DURATION)) {
746                    $event->add(clone $instanceObj->DURATION);
747                }
748                if (isset($instanceObj->SUMMARY)) {
749                    $event->add('SUMMARY', $instanceObj->SUMMARY->getValue());
750                } elseif ($summary) {
751                    $event->add('SUMMARY', $summary);
752                }
753            } else {
754                // This branch of the code is reached, when a reply is
755                // generated for an instance of a recurring event, through the
756                // fact that the instance has disappeared by showing up in
757                // EXDATE
758                $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']);
759                // Treat is as a DATE field
760                if (strlen($instance['id']) <= 8) {
761                    $recur = $event->add('DTSTART', $dt, array('VALUE' => 'DATE'));
762                } else {
763                    $recur = $event->add('DTSTART', $dt);
764                }
765                if ($summary) {
766                    $event->add('SUMMARY', $summary);
767                }
768            }
769            if ($instance['id'] !== 'master') {
770                $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']);
771                // Treat is as a DATE field
772                if (strlen($instance['id']) <= 8) {
773                    $recur = $event->add('RECURRENCE-ID', $dt, array('VALUE' => 'DATE'));
774                } else {
775                    $recur = $event->add('RECURRENCE-ID', $dt);
776                }
777            }
778            $organizer = $event->add('ORGANIZER', $message->recipient);
779            if ($message->recipientName) {
780                $organizer['CN'] = $message->recipientName;
781            }
782            $attendee = $event->add('ATTENDEE', $message->sender, array(
783                'PARTSTAT' => $instance['newstatus']
784            ));
785            if ($message->senderName) {
786                $attendee['CN'] = $message->senderName;
787            }
788            $hasReply = true;
789
790        }
791
792        if ($hasReply) {
793            $message->message = $icalMsg;
794            return array($message);
795        } else {
796            return array();
797        }
798
799    }
800
801    /**
802     * Returns attendee information and information about instances of an
803     * event.
804     *
805     * Returns an array with the following keys:
806     *
807     * 1. uid
808     * 2. organizer
809     * 3. organizerName
810     * 4. organizerScheduleAgent
811     * 5. organizerForceSend
812     * 6. instances
813     * 7. attendees
814     * 8. sequence
815     * 9. exdate
816     * 10. timezone - strictly the timezone on which the recurrence rule is
817     *                based on.
818     * 11. significantChangeHash
819     * 12. status
820     * @param VCalendar $calendar
821     * @return array
822     */
823    protected function parseEventInfo(VCalendar $calendar = null) {
824
825        $uid = null;
826        $organizer = null;
827        $organizerName = null;
828        $organizerForceSend = null;
829        $sequence = null;
830        $timezone = null;
831        $status = null;
832        $organizerScheduleAgent = 'SERVER';
833
834        $significantChangeHash = '';
835
836        // Now we need to collect a list of attendees, and which instances they
837        // are a part of.
838        $attendees = array();
839
840        $instances = array();
841        $exdate = array();
842
843        foreach($calendar->VEVENT as $vevent) {
844
845            if (is_null($uid)) {
846                $uid = $vevent->UID->getValue();
847            } else {
848                if ($uid !== $vevent->UID->getValue()) {
849                    throw new ITipException('If a calendar contained more than one event, they must have the same UID.');
850                }
851            }
852
853            if (!isset($vevent->DTSTART)) {
854                throw new ITipException('An event MUST have a DTSTART property.');
855            }
856
857            if (isset($vevent->ORGANIZER)) {
858                if (is_null($organizer)) {
859                    $organizer = $vevent->ORGANIZER->getNormalizedValue();
860                    $organizerName = isset($vevent->ORGANIZER['CN'])?$vevent->ORGANIZER['CN']:null;
861                } else {
862                    if ($organizer !== $vevent->ORGANIZER->getNormalizedValue()) {
863                        throw new SameOrganizerForAllComponentsException('Every instance of the event must have the same organizer.');
864                    }
865                }
866                $organizerForceSend =
867                    isset($vevent->ORGANIZER['SCHEDULE-FORCE-SEND']) ?
868                    strtoupper($vevent->ORGANIZER['SCHEDULE-FORCE-SEND']) :
869                    null;
870                $organizerScheduleAgent =
871                    isset($vevent->ORGANIZER['SCHEDULE-AGENT']) ?
872                    strtoupper((string)$vevent->ORGANIZER['SCHEDULE-AGENT']) :
873                    'SERVER';
874            }
875            if (is_null($sequence) && isset($vevent->SEQUENCE)) {
876                $sequence = $vevent->SEQUENCE->getValue();
877            }
878            if (isset($vevent->EXDATE)) {
879                foreach ($vevent->select('EXDATE') as $val) {
880                    $exdate = array_merge($exdate, $val->getParts());
881                }
882                sort($exdate);
883            }
884            if (isset($vevent->STATUS)) {
885                $status = strtoupper($vevent->STATUS->getValue());
886            }
887
888            $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master';
889            if (is_null($timezone)) {
890                if ($recurId === 'master') {
891                    $timezone = $vevent->DTSTART->getDateTime()->getTimeZone();
892                } else {
893                    $timezone = $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimeZone();
894                }
895            }
896            if(isset($vevent->ATTENDEE)) {
897                foreach($vevent->ATTENDEE as $attendee) {
898
899                    if ($this->scheduleAgentServerRules &&
900                        isset($attendee['SCHEDULE-AGENT']) &&
901                        strtoupper($attendee['SCHEDULE-AGENT']->getValue()) === 'CLIENT'
902                    ) {
903                        continue;
904                    }
905                    $partStat =
906                        isset($attendee['PARTSTAT']) ?
907                        strtoupper($attendee['PARTSTAT']) :
908                        'NEEDS-ACTION';
909
910                    $forceSend =
911                        isset($attendee['SCHEDULE-FORCE-SEND']) ?
912                        strtoupper($attendee['SCHEDULE-FORCE-SEND']) :
913                        null;
914
915
916                    if (isset($attendees[$attendee->getNormalizedValue()])) {
917                        $attendees[$attendee->getNormalizedValue()]['instances'][$recurId] = array(
918                            'id' => $recurId,
919                            'partstat' => $partStat,
920                            'force-send' => $forceSend,
921                        );
922                    } else {
923                        $attendees[$attendee->getNormalizedValue()] = array(
924                            'href' => $attendee->getNormalizedValue(),
925                            'instances' => array(
926                                $recurId => array(
927                                    'id' => $recurId,
928                                    'partstat' => $partStat,
929                                ),
930                            ),
931                            'name' => isset($attendee['CN'])?(string)$attendee['CN']:null,
932                            'forceSend' => $forceSend,
933                        );
934                    }
935
936                }
937                $instances[$recurId] = $vevent;
938
939            }
940
941            foreach($this->significantChangeProperties as $prop) {
942                if (isset($vevent->$prop)) {
943                    $propertyValues = $vevent->select($prop);
944
945                    $significantChangeHash.=$prop.':';
946
947                    if ($prop === 'EXDATE') {
948
949                        $significantChangeHash.= implode(',', $exdate).';';
950
951                    } else {
952
953                        foreach($propertyValues as $val) {
954                            $significantChangeHash.= $val->getValue().';';
955                        }
956
957                    }
958                }
959            }
960
961        }
962        $significantChangeHash = md5($significantChangeHash);
963
964        return compact(
965            'uid',
966            'organizer',
967            'organizerName',
968            'organizerScheduleAgent',
969            'organizerForceSend',
970            'instances',
971            'attendees',
972            'sequence',
973            'exdate',
974            'timezone',
975            'significantChangeHash',
976            'status'
977        );
978
979    }
980
981}
982