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