1<?php 2 3namespace Sabre\VObject; 4 5use DateTimeImmutable; 6use DateTimeInterface; 7use DateTimeZone; 8use Sabre\VObject\Component\VCalendar; 9use Sabre\VObject\Recur\EventIterator; 10use Sabre\VObject\Recur\NoInstancesException; 11 12/** 13 * This class helps with generating FREEBUSY reports based on existing sets of 14 * objects. 15 * 16 * It only looks at VEVENT and VFREEBUSY objects from the sourcedata, and 17 * generates a single VFREEBUSY object. 18 * 19 * VFREEBUSY components are described in RFC5545, The rules for what should 20 * go in a single freebusy report is taken from RFC4791, section 7.10. 21 * 22 * @copyright Copyright (C) fruux GmbH (https://fruux.com/) 23 * @author Evert Pot (http://evertpot.com/) 24 * @license http://sabre.io/license/ Modified BSD License 25 */ 26class FreeBusyGenerator 27{ 28 /** 29 * Input objects. 30 * 31 * @var array 32 */ 33 protected $objects = []; 34 35 /** 36 * Start of range. 37 * 38 * @var DateTimeInterface|null 39 */ 40 protected $start; 41 42 /** 43 * End of range. 44 * 45 * @var DateTimeInterface|null 46 */ 47 protected $end; 48 49 /** 50 * VCALENDAR object. 51 * 52 * @var Document 53 */ 54 protected $baseObject; 55 56 /** 57 * Reference timezone. 58 * 59 * When we are calculating busy times, and we come across so-called 60 * floating times (times without a timezone), we use the reference timezone 61 * instead. 62 * 63 * This is also used for all-day events. 64 * 65 * This defaults to UTC. 66 * 67 * @var DateTimeZone 68 */ 69 protected $timeZone; 70 71 /** 72 * A VAVAILABILITY document. 73 * 74 * If this is set, its information will be included when calculating 75 * freebusy time. 76 * 77 * @var Document 78 */ 79 protected $vavailability; 80 81 /** 82 * Creates the generator. 83 * 84 * Check the setTimeRange and setObjects methods for details about the 85 * arguments. 86 * 87 * @param DateTimeInterface $start 88 * @param DateTimeInterface $end 89 * @param mixed $objects 90 * @param DateTimeZone $timeZone 91 */ 92 public function __construct(DateTimeInterface $start = null, DateTimeInterface $end = null, $objects = null, DateTimeZone $timeZone = null) 93 { 94 $this->setTimeRange($start, $end); 95 96 if ($objects) { 97 $this->setObjects($objects); 98 } 99 if (is_null($timeZone)) { 100 $timeZone = new DateTimeZone('UTC'); 101 } 102 $this->setTimeZone($timeZone); 103 } 104 105 /** 106 * Sets the VCALENDAR object. 107 * 108 * If this is set, it will not be generated for you. You are responsible 109 * for setting things like the METHOD, CALSCALE, VERSION, etc.. 110 * 111 * The VFREEBUSY object will be automatically added though. 112 */ 113 public function setBaseObject(Document $vcalendar) 114 { 115 $this->baseObject = $vcalendar; 116 } 117 118 /** 119 * Sets a VAVAILABILITY document. 120 */ 121 public function setVAvailability(Document $vcalendar) 122 { 123 $this->vavailability = $vcalendar; 124 } 125 126 /** 127 * Sets the input objects. 128 * 129 * You must either specify a vcalendar object as a string, or as the parse 130 * Component. 131 * It's also possible to specify multiple objects as an array. 132 * 133 * @param mixed $objects 134 */ 135 public function setObjects($objects) 136 { 137 if (!is_array($objects)) { 138 $objects = [$objects]; 139 } 140 141 $this->objects = []; 142 foreach ($objects as $object) { 143 if (is_string($object) || is_resource($object)) { 144 $this->objects[] = Reader::read($object); 145 } elseif ($object instanceof Component) { 146 $this->objects[] = $object; 147 } else { 148 throw new \InvalidArgumentException('You can only pass strings or \\Sabre\\VObject\\Component arguments to setObjects'); 149 } 150 } 151 } 152 153 /** 154 * Sets the time range. 155 * 156 * Any freebusy object falling outside of this time range will be ignored. 157 * 158 * @param DateTimeInterface $start 159 * @param DateTimeInterface $end 160 */ 161 public function setTimeRange(DateTimeInterface $start = null, DateTimeInterface $end = null) 162 { 163 if (!$start) { 164 $start = new DateTimeImmutable(Settings::$minDate); 165 } 166 if (!$end) { 167 $end = new DateTimeImmutable(Settings::$maxDate); 168 } 169 $this->start = $start; 170 $this->end = $end; 171 } 172 173 /** 174 * Sets the reference timezone for floating times. 175 */ 176 public function setTimeZone(DateTimeZone $timeZone) 177 { 178 $this->timeZone = $timeZone; 179 } 180 181 /** 182 * Parses the input data and returns a correct VFREEBUSY object, wrapped in 183 * a VCALENDAR. 184 * 185 * @return Component 186 */ 187 public function getResult() 188 { 189 $fbData = new FreeBusyData( 190 $this->start->getTimeStamp(), 191 $this->end->getTimeStamp() 192 ); 193 if ($this->vavailability) { 194 $this->calculateAvailability($fbData, $this->vavailability); 195 } 196 197 $this->calculateBusy($fbData, $this->objects); 198 199 return $this->generateFreeBusyCalendar($fbData); 200 } 201 202 /** 203 * This method takes a VAVAILABILITY component and figures out all the 204 * available times. 205 */ 206 protected function calculateAvailability(FreeBusyData $fbData, VCalendar $vavailability) 207 { 208 $vavailComps = iterator_to_array($vavailability->VAVAILABILITY); 209 usort( 210 $vavailComps, 211 function ($a, $b) { 212 // We need to order the components by priority. Priority 1 213 // comes first, up until priority 9. Priority 0 comes after 214 // priority 9. No priority implies priority 0. 215 // 216 // Yes, I'm serious. 217 $priorityA = isset($a->PRIORITY) ? (int) $a->PRIORITY->getValue() : 0; 218 $priorityB = isset($b->PRIORITY) ? (int) $b->PRIORITY->getValue() : 0; 219 220 if (0 === $priorityA) { 221 $priorityA = 10; 222 } 223 if (0 === $priorityB) { 224 $priorityB = 10; 225 } 226 227 return $priorityA - $priorityB; 228 } 229 ); 230 231 // Now we go over all the VAVAILABILITY components and figure if 232 // there's any we don't need to consider. 233 // 234 // This is can be because of one of two reasons: either the 235 // VAVAILABILITY component falls outside the time we are interested in, 236 // or a different VAVAILABILITY component with a higher priority has 237 // already completely covered the time-range. 238 $old = $vavailComps; 239 $new = []; 240 241 foreach ($old as $vavail) { 242 list($compStart, $compEnd) = $vavail->getEffectiveStartEnd(); 243 244 // We don't care about datetimes that are earlier or later than the 245 // start and end of the freebusy report, so this gets normalized 246 // first. 247 if (is_null($compStart) || $compStart < $this->start) { 248 $compStart = $this->start; 249 } 250 if (is_null($compEnd) || $compEnd > $this->end) { 251 $compEnd = $this->end; 252 } 253 254 // If the item fell out of the timerange, we can just skip it. 255 if ($compStart > $this->end || $compEnd < $this->start) { 256 continue; 257 } 258 259 // Going through our existing list of components to see if there's 260 // a higher priority component that already fully covers this one. 261 foreach ($new as $higherVavail) { 262 list($higherStart, $higherEnd) = $higherVavail->getEffectiveStartEnd(); 263 if ( 264 (is_null($higherStart) || $higherStart < $compStart) && 265 (is_null($higherEnd) || $higherEnd > $compEnd) 266 ) { 267 // Component is fully covered by a higher priority 268 // component. We can skip this component. 269 continue 2; 270 } 271 } 272 273 // We're keeping it! 274 $new[] = $vavail; 275 } 276 277 // Lastly, we need to traverse the remaining components and fill in the 278 // freebusydata slots. 279 // 280 // We traverse the components in reverse, because we want the higher 281 // priority components to override the lower ones. 282 foreach (array_reverse($new) as $vavail) { 283 $busyType = isset($vavail->BUSYTYPE) ? strtoupper($vavail->BUSYTYPE) : 'BUSY-UNAVAILABLE'; 284 list($vavailStart, $vavailEnd) = $vavail->getEffectiveStartEnd(); 285 286 // Making the component size no larger than the requested free-busy 287 // report range. 288 if (!$vavailStart || $vavailStart < $this->start) { 289 $vavailStart = $this->start; 290 } 291 if (!$vavailEnd || $vavailEnd > $this->end) { 292 $vavailEnd = $this->end; 293 } 294 295 // Marking the entire time range of the VAVAILABILITY component as 296 // busy. 297 $fbData->add( 298 $vavailStart->getTimeStamp(), 299 $vavailEnd->getTimeStamp(), 300 $busyType 301 ); 302 303 // Looping over the AVAILABLE components. 304 if (isset($vavail->AVAILABLE)) { 305 foreach ($vavail->AVAILABLE as $available) { 306 list($availStart, $availEnd) = $available->getEffectiveStartEnd(); 307 $fbData->add( 308 $availStart->getTimeStamp(), 309 $availEnd->getTimeStamp(), 310 'FREE' 311 ); 312 313 if ($available->RRULE) { 314 // Our favourite thing: recurrence!! 315 316 $rruleIterator = new Recur\RRuleIterator( 317 $available->RRULE->getValue(), 318 $availStart 319 ); 320 $rruleIterator->fastForward($vavailStart); 321 322 $startEndDiff = $availStart->diff($availEnd); 323 324 while ($rruleIterator->valid()) { 325 $recurStart = $rruleIterator->current(); 326 $recurEnd = $recurStart->add($startEndDiff); 327 328 if ($recurStart > $vavailEnd) { 329 // We're beyond the legal timerange. 330 break; 331 } 332 333 if ($recurEnd > $vavailEnd) { 334 // Truncating the end if it exceeds the 335 // VAVAILABILITY end. 336 $recurEnd = $vavailEnd; 337 } 338 339 $fbData->add( 340 $recurStart->getTimeStamp(), 341 $recurEnd->getTimeStamp(), 342 'FREE' 343 ); 344 345 $rruleIterator->next(); 346 } 347 } 348 } 349 } 350 } 351 } 352 353 /** 354 * This method takes an array of iCalendar objects and applies its busy 355 * times on fbData. 356 * 357 * @param VCalendar[] $objects 358 */ 359 protected function calculateBusy(FreeBusyData $fbData, array $objects) 360 { 361 foreach ($objects as $key => $object) { 362 foreach ($object->getBaseComponents() as $component) { 363 switch ($component->name) { 364 case 'VEVENT': 365 $FBTYPE = 'BUSY'; 366 if (isset($component->TRANSP) && ('TRANSPARENT' === strtoupper($component->TRANSP))) { 367 break; 368 } 369 if (isset($component->STATUS)) { 370 $status = strtoupper($component->STATUS); 371 if ('CANCELLED' === $status) { 372 break; 373 } 374 if ('TENTATIVE' === $status) { 375 $FBTYPE = 'BUSY-TENTATIVE'; 376 } 377 } 378 379 $times = []; 380 381 if ($component->RRULE) { 382 try { 383 $iterator = new EventIterator($object, (string) $component->UID, $this->timeZone); 384 } catch (NoInstancesException $e) { 385 // This event is recurring, but it doesn't have a single 386 // instance. We are skipping this event from the output 387 // entirely. 388 unset($this->objects[$key]); 389 break; 390 } 391 392 if ($this->start) { 393 $iterator->fastForward($this->start); 394 } 395 396 $maxRecurrences = Settings::$maxRecurrences; 397 398 while ($iterator->valid() && --$maxRecurrences) { 399 $startTime = $iterator->getDTStart(); 400 if ($this->end && $startTime > $this->end) { 401 break; 402 } 403 $times[] = [ 404 $iterator->getDTStart(), 405 $iterator->getDTEnd(), 406 ]; 407 408 $iterator->next(); 409 } 410 } else { 411 $startTime = $component->DTSTART->getDateTime($this->timeZone); 412 if ($this->end && $startTime > $this->end) { 413 break; 414 } 415 $endTime = null; 416 if (isset($component->DTEND)) { 417 $endTime = $component->DTEND->getDateTime($this->timeZone); 418 } elseif (isset($component->DURATION)) { 419 $duration = DateTimeParser::parseDuration((string) $component->DURATION); 420 $endTime = clone $startTime; 421 $endTime = $endTime->add($duration); 422 } elseif (!$component->DTSTART->hasTime()) { 423 $endTime = clone $startTime; 424 $endTime = $endTime->modify('+1 day'); 425 } else { 426 // The event had no duration (0 seconds) 427 break; 428 } 429 430 $times[] = [$startTime, $endTime]; 431 } 432 433 foreach ($times as $time) { 434 if ($this->end && $time[0] > $this->end) { 435 break; 436 } 437 if ($this->start && $time[1] < $this->start) { 438 break; 439 } 440 441 $fbData->add( 442 $time[0]->getTimeStamp(), 443 $time[1]->getTimeStamp(), 444 $FBTYPE 445 ); 446 } 447 break; 448 449 case 'VFREEBUSY': 450 foreach ($component->FREEBUSY as $freebusy) { 451 $fbType = isset($freebusy['FBTYPE']) ? strtoupper($freebusy['FBTYPE']) : 'BUSY'; 452 453 // Skipping intervals marked as 'free' 454 if ('FREE' === $fbType) { 455 continue; 456 } 457 458 $values = explode(',', $freebusy); 459 foreach ($values as $value) { 460 list($startTime, $endTime) = explode('/', $value); 461 $startTime = DateTimeParser::parseDateTime($startTime); 462 463 if ('P' === substr($endTime, 0, 1) || '-P' === substr($endTime, 0, 2)) { 464 $duration = DateTimeParser::parseDuration($endTime); 465 $endTime = clone $startTime; 466 $endTime = $endTime->add($duration); 467 } else { 468 $endTime = DateTimeParser::parseDateTime($endTime); 469 } 470 471 if ($this->start && $this->start > $endTime) { 472 continue; 473 } 474 if ($this->end && $this->end < $startTime) { 475 continue; 476 } 477 $fbData->add( 478 $startTime->getTimeStamp(), 479 $endTime->getTimeStamp(), 480 $fbType 481 ); 482 } 483 } 484 break; 485 } 486 } 487 } 488 } 489 490 /** 491 * This method takes a FreeBusyData object and generates the VCALENDAR 492 * object associated with it. 493 * 494 * @return VCalendar 495 */ 496 protected function generateFreeBusyCalendar(FreeBusyData $fbData) 497 { 498 if ($this->baseObject) { 499 $calendar = $this->baseObject; 500 } else { 501 $calendar = new VCalendar(); 502 } 503 504 $vfreebusy = $calendar->createComponent('VFREEBUSY'); 505 $calendar->add($vfreebusy); 506 507 if ($this->start) { 508 $dtstart = $calendar->createProperty('DTSTART'); 509 $dtstart->setDateTime($this->start); 510 $vfreebusy->add($dtstart); 511 } 512 if ($this->end) { 513 $dtend = $calendar->createProperty('DTEND'); 514 $dtend->setDateTime($this->end); 515 $vfreebusy->add($dtend); 516 } 517 518 $tz = new \DateTimeZone('UTC'); 519 $dtstamp = $calendar->createProperty('DTSTAMP'); 520 $dtstamp->setDateTime(new DateTimeImmutable('now', $tz)); 521 $vfreebusy->add($dtstamp); 522 523 foreach ($fbData->getData() as $busyTime) { 524 $busyType = strtoupper($busyTime['type']); 525 526 // Ignoring all the FREE parts, because those are already assumed. 527 if ('FREE' === $busyType) { 528 continue; 529 } 530 531 $busyTime[0] = new \DateTimeImmutable('@'.$busyTime['start'], $tz); 532 $busyTime[1] = new \DateTimeImmutable('@'.$busyTime['end'], $tz); 533 534 $prop = $calendar->createProperty( 535 'FREEBUSY', 536 $busyTime[0]->format('Ymd\\THis\\Z').'/'.$busyTime[1]->format('Ymd\\THis\\Z') 537 ); 538 539 // Only setting FBTYPE if it's not BUSY, because BUSY is the 540 // default anyway. 541 if ('BUSY' !== $busyType) { 542 $prop['FBTYPE'] = $busyType; 543 } 544 $vfreebusy->add($prop); 545 } 546 547 return $calendar; 548 } 549} 550