Test Failed
Push — main ( c8394f...8477f1 )
by Rafael
66:21
created

Broker::processMessageReply()   F

Complexity

Conditions 27
Paths 7421

Size

Total Lines 111
Code Lines 65

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 27
eloc 65
nc 7421
nop 2
dl 0
loc 111
rs 0
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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