FreeBusyGenerator.php 19 KB

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