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