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