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