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