Broker.php 37 KB

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