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) 2011-2015 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                $org = $event->add('ORGANIZER', $eventInfo['organizer']);
529                if ($eventInfo['organizerName']) $org['CN'] = $eventInfo['organizerName'];
530                $event->add('ATTENDEE', $attendee['href'], array(
531                    'CN' => $attendee['name'],
532                ));
533                $message->significantChange = true;
534
535            } else {
536
537                // The attendee gets the updated event body
538                $message->method = 'REQUEST';
539
540                // Creating the new iCalendar body.
541                $icalMsg = new VCalendar();
542                $icalMsg->METHOD = $message->method;
543
544                foreach($calendar->select('VTIMEZONE') as $timezone) {
545                    $icalMsg->add(clone $timezone);
546                }
547
548                // We need to find out that this change is significant. If it's
549                // not, systems may opt to not send messages.
550                //
551                // We do this based on the 'significantChangeHash' which is
552                // some value that changes if there's a certain set of
553                // properties changed in the event, or simply if there's a
554                // difference in instances that the attendee is invited to.
555
556                $message->significantChange =
557                    $attendee['forceSend'] === 'REQUEST' ||
558                    array_keys($attendee['oldInstances']) != array_keys($attendee['newInstances']) ||
559                    $oldEventInfo['significantChangeHash']!==$eventInfo['significantChangeHash'];
560
561                foreach($attendee['newInstances'] as $instanceId => $instanceInfo) {
562
563                    $currentEvent = clone $eventInfo['instances'][$instanceId];
564                    if ($instanceId === 'master') {
565
566                        // We need to find a list of events that the attendee
567                        // is not a part of to add to the list of exceptions.
568                        $exceptions = array();
569                        foreach($eventInfo['instances'] as $instanceId=>$vevent) {
570                            if (!isset($attendee['newInstances'][$instanceId])) {
571                                $exceptions[] = $instanceId;
572                            }
573                        }
574
575                        // If there were exceptions, we need to add it to an
576                        // existing EXDATE property, if it exists.
577                        if ($exceptions) {
578                            if (isset($currentEvent->EXDATE)) {
579                                $currentEvent->EXDATE->setParts(array_merge(
580                                    $currentEvent->EXDATE->getParts(),
581                                    $exceptions
582                                ));
583                            } else {
584                                $currentEvent->EXDATE = $exceptions;
585                            }
586                        }
587
588                        // Cleaning up any scheduling information that
589                        // shouldn't be sent along.
590                        unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']);
591                        unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']);
592
593                        foreach($currentEvent->ATTENDEE as $attendee) {
594                            unset($attendee['SCHEDULE-FORCE-SEND']);
595                            unset($attendee['SCHEDULE-STATUS']);
596
597                            // We're adding PARTSTAT=NEEDS-ACTION to ensure that
598                            // iOS shows an "Inbox Item"
599                            if (!isset($attendee['PARTSTAT'])) {
600                                $attendee['PARTSTAT'] = 'NEEDS-ACTION';
601                            }
602
603                        }
604
605                    }
606
607                    $icalMsg->add($currentEvent);
608
609                }
610
611            }
612
613            $message->message = $icalMsg;
614            $messages[] = $message;
615
616        }
617
618        return $messages;
619
620    }
621
622    /**
623     * Parse an event update for an attendee.
624     *
625     * This function figures out if we need to send a reply to an organizer.
626     *
627     * @param VCalendar $calendar
628     * @param array $eventInfo
629     * @param array $oldEventInfo
630     * @param string $attendee
631     * @return Message[]
632     */
633    protected function parseEventForAttendee(VCalendar $calendar, array $eventInfo, array $oldEventInfo, $attendee) {
634
635        if ($this->scheduleAgentServerRules && $eventInfo['organizerScheduleAgent']==='CLIENT') {
636            return array();
637        }
638
639        // Don't bother generating messages for events that have already been
640        // cancelled.
641        if ($eventInfo['status']==='CANCELLED') {
642            return array();
643        }
644
645        $oldInstances = !empty($oldEventInfo['attendees'][$attendee]['instances']) ?
646            $oldEventInfo['attendees'][$attendee]['instances'] :
647            array();
648
649        $instances = array();
650        foreach($oldInstances as $instance) {
651
652            $instances[$instance['id']] = array(
653                'id' => $instance['id'],
654                'oldstatus' => $instance['partstat'],
655                'newstatus' => null,
656            );
657
658        }
659        foreach($eventInfo['attendees'][$attendee]['instances'] as $instance) {
660
661            if (isset($instances[$instance['id']])) {
662                $instances[$instance['id']]['newstatus'] = $instance['partstat'];
663            } else {
664                $instances[$instance['id']] = array(
665                    'id' => $instance['id'],
666                    'oldstatus' => null,
667                    'newstatus' => $instance['partstat'],
668                );
669            }
670
671        }
672
673        // We need to also look for differences in EXDATE. If there are new
674        // items in EXDATE, it means that an attendee deleted instances of an
675        // event, which means we need to send DECLINED specifically for those
676        // instances.
677        // We only need to do that though, if the master event is not declined.
678        if (isset($instances['master']) && $instances['master']['newstatus'] !== 'DECLINED') {
679            foreach($eventInfo['exdate'] as $exDate) {
680
681                if (!in_array($exDate, $oldEventInfo['exdate'])) {
682                    if (isset($instances[$exDate])) {
683                        $instances[$exDate]['newstatus'] = 'DECLINED';
684                    } else {
685                        $instances[$exDate] = array(
686                            'id' => $exDate,
687                            'oldstatus' => null,
688                            'newstatus' => 'DECLINED',
689                        );
690                    }
691                }
692
693            }
694        }
695
696        // Gathering a few extra properties for each instance.
697        foreach($instances as $recurId=>$instanceInfo) {
698
699            if (isset($eventInfo['instances'][$recurId])) {
700                $instances[$recurId]['dtstart'] = clone $eventInfo['instances'][$recurId]->DTSTART;
701            } else {
702                $instances[$recurId]['dtstart'] = $recurId;
703            }
704
705        }
706
707        $message = new Message();
708        $message->uid = $eventInfo['uid'];
709        $message->method = 'REPLY';
710        $message->component = 'VEVENT';
711        $message->sequence = $eventInfo['sequence'];
712        $message->sender = $attendee;
713        $message->senderName = $eventInfo['attendees'][$attendee]['name'];
714        $message->recipient = $eventInfo['organizer'];
715        $message->recipientName = $eventInfo['organizerName'];
716
717        $icalMsg = new VCalendar();
718        $icalMsg->METHOD = 'REPLY';
719
720        $hasReply = false;
721
722        foreach($instances as $instance) {
723
724            if ($instance['oldstatus']==$instance['newstatus'] && $eventInfo['organizerForceSend'] !== 'REPLY') {
725                // Skip
726                continue;
727            }
728
729            $event = $icalMsg->add('VEVENT', array(
730                'UID' => $message->uid,
731                'SEQUENCE' => $message->sequence,
732            ));
733            $summary = isset($calendar->VEVENT->SUMMARY)?$calendar->VEVENT->SUMMARY->getValue():'';
734            // Adding properties from the correct source instance
735            if (isset($eventInfo['instances'][$instance['id']])) {
736                $instanceObj = $eventInfo['instances'][$instance['id']];
737                $event->add(clone $instanceObj->DTSTART);
738                if (isset($instanceObj->SUMMARY)) {
739                    $event->add('SUMMARY', $instanceObj->SUMMARY->getValue());
740                } elseif ($summary) {
741                    $event->add('SUMMARY', $summary);
742                }
743            } else {
744                // This branch of the code is reached, when a reply is
745                // generated for an instance of a recurring event, through the
746                // fact that the instance has disappeared by showing up in
747                // EXDATE
748                $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']);
749                // Treat is as a DATE field
750                if (strlen($instance['id']) <= 8) {
751                    $recur = $event->add('DTSTART', $dt, array('VALUE' => 'DATE'));
752                } else {
753                    $recur = $event->add('DTSTART', $dt);
754                }
755                if ($summary) {
756                    $event->add('SUMMARY', $summary);
757                }
758            }
759            if ($instance['id'] !== 'master') {
760                $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']);
761                // Treat is as a DATE field
762                if (strlen($instance['id']) <= 8) {
763                    $recur = $event->add('RECURRENCE-ID', $dt, array('VALUE' => 'DATE'));
764                } else {
765                    $recur = $event->add('RECURRENCE-ID', $dt);
766                }
767            }
768            $organizer = $event->add('ORGANIZER', $message->recipient);
769            if ($message->recipientName) {
770                $organizer['CN'] = $message->recipientName;
771            }
772            $attendee = $event->add('ATTENDEE', $message->sender, array(
773                'PARTSTAT' => $instance['newstatus']
774            ));
775            if ($message->senderName) {
776                $attendee['CN'] = $message->senderName;
777            }
778            $hasReply = true;
779
780        }
781
782        if ($hasReply) {
783            $message->message = $icalMsg;
784            return array($message);
785        } else {
786            return array();
787        }
788
789    }
790
791    /**
792     * Returns attendee information and information about instances of an
793     * event.
794     *
795     * Returns an array with the following keys:
796     *
797     * 1. uid
798     * 2. organizer
799     * 3. organizerName
800     * 4. attendees
801     * 5. instances
802     *
803     * @param VCalendar $calendar
804     * @return array
805     */
806    protected function parseEventInfo(VCalendar $calendar = null) {
807
808        $uid = null;
809        $organizer = null;
810        $organizerName = null;
811        $organizerForceSend = null;
812        $sequence = null;
813        $timezone = null;
814        $status = null;
815        $organizerScheduleAgent = 'SERVER';
816
817        $significantChangeHash = '';
818
819        // Now we need to collect a list of attendees, and which instances they
820        // are a part of.
821        $attendees = array();
822
823        $instances = array();
824        $exdate = array();
825
826        foreach($calendar->VEVENT as $vevent) {
827
828            if (is_null($uid)) {
829                $uid = $vevent->UID->getValue();
830            } else {
831                if ($uid !== $vevent->UID->getValue()) {
832                    throw new ITipException('If a calendar contained more than one event, they must have the same UID.');
833                }
834            }
835
836            if (!isset($vevent->DTSTART)) {
837                throw new ITipException('An event MUST have a DTSTART property.');
838            }
839
840            if (isset($vevent->ORGANIZER)) {
841                if (is_null($organizer)) {
842                    $organizer = $vevent->ORGANIZER->getNormalizedValue();
843                    $organizerName = isset($vevent->ORGANIZER['CN'])?$vevent->ORGANIZER['CN']:null;
844                } else {
845                    if ($organizer !== $vevent->ORGANIZER->getNormalizedValue()) {
846                        throw new SameOrganizerForAllComponentsException('Every instance of the event must have the same organizer.');
847                    }
848                }
849                $organizerForceSend =
850                    isset($vevent->ORGANIZER['SCHEDULE-FORCE-SEND']) ?
851                    strtoupper($vevent->ORGANIZER['SCHEDULE-FORCE-SEND']) :
852                    null;
853                $organizerScheduleAgent =
854                    isset($vevent->ORGANIZER['SCHEDULE-AGENT']) ?
855                    strtoupper((string)$vevent->ORGANIZER['SCHEDULE-AGENT']) :
856                    'SERVER';
857            }
858            if (is_null($sequence) && isset($vevent->SEQUENCE)) {
859                $sequence = $vevent->SEQUENCE->getValue();
860            }
861            if (isset($vevent->EXDATE)) {
862                foreach ($vevent->select('EXDATE') as $val) {
863                    $exdate = array_merge($exdate, $val->getParts());
864                }
865                sort($exdate);
866            }
867            if (isset($vevent->STATUS)) {
868                $status = strtoupper($vevent->STATUS->getValue());
869            }
870
871            $recurId = isset($vevent->{'RECURRENCE-ID'})?$vevent->{'RECURRENCE-ID'}->getValue():'master';
872            if ($recurId==='master') {
873                $timezone = $vevent->DTSTART->getDateTime()->getTimeZone();
874            }
875            if(isset($vevent->ATTENDEE)) {
876                foreach($vevent->ATTENDEE as $attendee) {
877
878                    if ($this->scheduleAgentServerRules &&
879                        isset($attendee['SCHEDULE-AGENT']) &&
880                        strtoupper($attendee['SCHEDULE-AGENT']->getValue()) === 'CLIENT'
881                    ) {
882                        continue;
883                    }
884                    $partStat =
885                        isset($attendee['PARTSTAT']) ?
886                        strtoupper($attendee['PARTSTAT']) :
887                        'NEEDS-ACTION';
888
889                    $forceSend =
890                        isset($attendee['SCHEDULE-FORCE-SEND']) ?
891                        strtoupper($attendee['SCHEDULE-FORCE-SEND']) :
892                        null;
893
894
895                    if (isset($attendees[$attendee->getNormalizedValue()])) {
896                        $attendees[$attendee->getNormalizedValue()]['instances'][$recurId] = array(
897                            'id' => $recurId,
898                            'partstat' => $partStat,
899                            'force-send' => $forceSend,
900                        );
901                    } else {
902                        $attendees[$attendee->getNormalizedValue()] = array(
903                            'href' => $attendee->getNormalizedValue(),
904                            'instances' => array(
905                                $recurId => array(
906                                    'id' => $recurId,
907                                    'partstat' => $partStat,
908                                ),
909                            ),
910                            'name' => isset($attendee['CN'])?(string)$attendee['CN']:null,
911                            'forceSend' => $forceSend,
912                        );
913                    }
914
915                }
916                $instances[$recurId] = $vevent;
917
918            }
919
920            foreach($this->significantChangeProperties as $prop) {
921                if (isset($vevent->$prop)) {
922                    $propertyValues = $vevent->select($prop);
923
924                    $significantChangeHash.=$prop.':';
925
926                    if ($prop === 'EXDATE') {
927
928                        $significantChangeHash.= implode(',', $exdate).';';
929
930                    } else {
931
932                        foreach($propertyValues as $val) {
933                            $significantChangeHash.= $val->getValue().';';
934                        }
935
936                    }
937                }
938            }
939
940        }
941        $significantChangeHash = md5($significantChangeHash);
942
943        return compact(
944            'uid',
945            'organizer',
946            'organizerName',
947            'organizerScheduleAgent',
948            'organizerForceSend',
949            'instances',
950            'attendees',
951            'sequence',
952            'exdate',
953            'timezone',
954            'significantChangeHash',
955            'status'
956        );
957
958    }
959
960}
961