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