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