1<?php 2 3namespace Sabre\VObject\Component; 4 5use DateTimeInterface; 6use DateTimeZone; 7use Sabre\VObject; 8use Sabre\VObject\Component; 9use Sabre\VObject\InvalidDataException; 10use Sabre\VObject\Property; 11use Sabre\VObject\Recur\EventIterator; 12use Sabre\VObject\Recur\NoInstancesException; 13 14/** 15 * The VCalendar component. 16 * 17 * This component adds functionality to a component, specific for a VCALENDAR. 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 */ 23class VCalendar extends VObject\Document { 24 25 /** 26 * The default name for this component. 27 * 28 * This should be 'VCALENDAR' or 'VCARD'. 29 * 30 * @var string 31 */ 32 static $defaultName = 'VCALENDAR'; 33 34 /** 35 * This is a list of components, and which classes they should map to. 36 * 37 * @var array 38 */ 39 static $componentMap = [ 40 'VCALENDAR' => 'Sabre\\VObject\\Component\\VCalendar', 41 'VALARM' => 'Sabre\\VObject\\Component\\VAlarm', 42 'VEVENT' => 'Sabre\\VObject\\Component\\VEvent', 43 'VFREEBUSY' => 'Sabre\\VObject\\Component\\VFreeBusy', 44 'VAVAILABILITY' => 'Sabre\\VObject\\Component\\VAvailability', 45 'AVAILABLE' => 'Sabre\\VObject\\Component\\Available', 46 'VJOURNAL' => 'Sabre\\VObject\\Component\\VJournal', 47 'VTIMEZONE' => 'Sabre\\VObject\\Component\\VTimeZone', 48 'VTODO' => 'Sabre\\VObject\\Component\\VTodo', 49 ]; 50 51 /** 52 * List of value-types, and which classes they map to. 53 * 54 * @var array 55 */ 56 static $valueMap = [ 57 'BINARY' => 'Sabre\\VObject\\Property\\Binary', 58 'BOOLEAN' => 'Sabre\\VObject\\Property\\Boolean', 59 'CAL-ADDRESS' => 'Sabre\\VObject\\Property\\ICalendar\\CalAddress', 60 'DATE' => 'Sabre\\VObject\\Property\\ICalendar\\Date', 61 'DATE-TIME' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 62 'DURATION' => 'Sabre\\VObject\\Property\\ICalendar\\Duration', 63 'FLOAT' => 'Sabre\\VObject\\Property\\FloatValue', 64 'INTEGER' => 'Sabre\\VObject\\Property\\IntegerValue', 65 'PERIOD' => 'Sabre\\VObject\\Property\\ICalendar\\Period', 66 'RECUR' => 'Sabre\\VObject\\Property\\ICalendar\\Recur', 67 'TEXT' => 'Sabre\\VObject\\Property\\Text', 68 'TIME' => 'Sabre\\VObject\\Property\\Time', 69 'UNKNOWN' => 'Sabre\\VObject\\Property\\Unknown', // jCard / jCal-only. 70 'URI' => 'Sabre\\VObject\\Property\\Uri', 71 'UTC-OFFSET' => 'Sabre\\VObject\\Property\\UtcOffset', 72 ]; 73 74 /** 75 * List of properties, and which classes they map to. 76 * 77 * @var array 78 */ 79 static $propertyMap = [ 80 // Calendar properties 81 'CALSCALE' => 'Sabre\\VObject\\Property\\FlatText', 82 'METHOD' => 'Sabre\\VObject\\Property\\FlatText', 83 'PRODID' => 'Sabre\\VObject\\Property\\FlatText', 84 'VERSION' => 'Sabre\\VObject\\Property\\FlatText', 85 86 // Component properties 87 'ATTACH' => 'Sabre\\VObject\\Property\\Uri', 88 'CATEGORIES' => 'Sabre\\VObject\\Property\\Text', 89 'CLASS' => 'Sabre\\VObject\\Property\\FlatText', 90 'COMMENT' => 'Sabre\\VObject\\Property\\FlatText', 91 'DESCRIPTION' => 'Sabre\\VObject\\Property\\FlatText', 92 'GEO' => 'Sabre\\VObject\\Property\\FloatValue', 93 'LOCATION' => 'Sabre\\VObject\\Property\\FlatText', 94 'PERCENT-COMPLETE' => 'Sabre\\VObject\\Property\\IntegerValue', 95 'PRIORITY' => 'Sabre\\VObject\\Property\\IntegerValue', 96 'RESOURCES' => 'Sabre\\VObject\\Property\\Text', 97 'STATUS' => 'Sabre\\VObject\\Property\\FlatText', 98 'SUMMARY' => 'Sabre\\VObject\\Property\\FlatText', 99 100 // Date and Time Component Properties 101 'COMPLETED' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 102 'DTEND' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 103 'DUE' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 104 'DTSTART' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 105 'DURATION' => 'Sabre\\VObject\\Property\\ICalendar\\Duration', 106 'FREEBUSY' => 'Sabre\\VObject\\Property\\ICalendar\\Period', 107 'TRANSP' => 'Sabre\\VObject\\Property\\FlatText', 108 109 // Time Zone Component Properties 110 'TZID' => 'Sabre\\VObject\\Property\\FlatText', 111 'TZNAME' => 'Sabre\\VObject\\Property\\FlatText', 112 'TZOFFSETFROM' => 'Sabre\\VObject\\Property\\UtcOffset', 113 'TZOFFSETTO' => 'Sabre\\VObject\\Property\\UtcOffset', 114 'TZURL' => 'Sabre\\VObject\\Property\\Uri', 115 116 // Relationship Component Properties 117 'ATTENDEE' => 'Sabre\\VObject\\Property\\ICalendar\\CalAddress', 118 'CONTACT' => 'Sabre\\VObject\\Property\\FlatText', 119 'ORGANIZER' => 'Sabre\\VObject\\Property\\ICalendar\\CalAddress', 120 'RECURRENCE-ID' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 121 'RELATED-TO' => 'Sabre\\VObject\\Property\\FlatText', 122 'URL' => 'Sabre\\VObject\\Property\\Uri', 123 'UID' => 'Sabre\\VObject\\Property\\FlatText', 124 125 // Recurrence Component Properties 126 'EXDATE' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 127 'RDATE' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 128 'RRULE' => 'Sabre\\VObject\\Property\\ICalendar\\Recur', 129 'EXRULE' => 'Sabre\\VObject\\Property\\ICalendar\\Recur', // Deprecated since rfc5545 130 131 // Alarm Component Properties 132 'ACTION' => 'Sabre\\VObject\\Property\\FlatText', 133 'REPEAT' => 'Sabre\\VObject\\Property\\IntegerValue', 134 'TRIGGER' => 'Sabre\\VObject\\Property\\ICalendar\\Duration', 135 136 // Change Management Component Properties 137 'CREATED' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 138 'DTSTAMP' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 139 'LAST-MODIFIED' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 140 'SEQUENCE' => 'Sabre\\VObject\\Property\\IntegerValue', 141 142 // Request Status 143 'REQUEST-STATUS' => 'Sabre\\VObject\\Property\\Text', 144 145 // Additions from draft-daboo-valarm-extensions-04 146 'ALARM-AGENT' => 'Sabre\\VObject\\Property\\Text', 147 'ACKNOWLEDGED' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime', 148 'PROXIMITY' => 'Sabre\\VObject\\Property\\Text', 149 'DEFAULT-ALARM' => 'Sabre\\VObject\\Property\\Boolean', 150 151 // Additions from draft-daboo-calendar-availability-05 152 'BUSYTYPE' => 'Sabre\\VObject\\Property\\Text', 153 154 ]; 155 156 /** 157 * Returns the current document type. 158 * 159 * @return int 160 */ 161 function getDocumentType() { 162 163 return self::ICALENDAR20; 164 165 } 166 167 /** 168 * Returns a list of all 'base components'. For instance, if an Event has 169 * a recurrence rule, and one instance is overridden, the overridden event 170 * will have the same UID, but will be excluded from this list. 171 * 172 * VTIMEZONE components will always be excluded. 173 * 174 * @param string $componentName filter by component name 175 * 176 * @return VObject\Component[] 177 */ 178 function getBaseComponents($componentName = null) { 179 180 $isBaseComponent = function($component) { 181 182 if (!$component instanceof VObject\Component) { 183 return false; 184 } 185 if ($component->name === 'VTIMEZONE') { 186 return false; 187 } 188 if (isset($component->{'RECURRENCE-ID'})) { 189 return false; 190 } 191 return true; 192 193 }; 194 195 if ($componentName) { 196 // Early exit 197 return array_filter( 198 $this->select($componentName), 199 $isBaseComponent 200 ); 201 } 202 203 $components = []; 204 foreach ($this->children as $childGroup) { 205 206 foreach ($childGroup as $child) { 207 208 if (!$child instanceof Component) { 209 // If one child is not a component, they all are so we skip 210 // the entire group. 211 continue 2; 212 } 213 if ($isBaseComponent($child)) { 214 $components[] = $child; 215 } 216 217 } 218 219 } 220 return $components; 221 222 } 223 224 /** 225 * Returns the first component that is not a VTIMEZONE, and does not have 226 * an RECURRENCE-ID. 227 * 228 * If there is no such component, null will be returned. 229 * 230 * @param string $componentName filter by component name 231 * 232 * @return VObject\Component|null 233 */ 234 function getBaseComponent($componentName = null) { 235 236 $isBaseComponent = function($component) { 237 238 if (!$component instanceof VObject\Component) { 239 return false; 240 } 241 if ($component->name === 'VTIMEZONE') { 242 return false; 243 } 244 if (isset($component->{'RECURRENCE-ID'})) { 245 return false; 246 } 247 return true; 248 249 }; 250 251 if ($componentName) { 252 foreach ($this->select($componentName) as $child) { 253 if ($isBaseComponent($child)) { 254 return $child; 255 } 256 } 257 return null; 258 } 259 260 // Searching all components 261 foreach ($this->children as $childGroup) { 262 foreach ($childGroup as $child) { 263 if ($isBaseComponent($child)) { 264 return $child; 265 } 266 } 267 268 } 269 return null; 270 271 } 272 273 /** 274 * Expand all events in this VCalendar object and return a new VCalendar 275 * with the expanded events. 276 * 277 * If this calendar object, has events with recurrence rules, this method 278 * can be used to expand the event into multiple sub-events. 279 * 280 * Each event will be stripped from it's recurrence information, and only 281 * the instances of the event in the specified timerange will be left 282 * alone. 283 * 284 * In addition, this method will cause timezone information to be stripped, 285 * and normalized to UTC. 286 * 287 * @param DateTimeInterface $start 288 * @param DateTimeInterface $end 289 * @param DateTimeZone $timeZone reference timezone for floating dates and 290 * times. 291 * @return VCalendar 292 */ 293 function expand(DateTimeInterface $start, DateTimeInterface $end, DateTimeZone $timeZone = null) { 294 295 $newChildren = []; 296 $recurringEvents = []; 297 298 if (!$timeZone) { 299 $timeZone = new DateTimeZone('UTC'); 300 } 301 302 $stripTimezones = function(Component $component) use ($timeZone, &$stripTimezones) { 303 304 foreach ($component->children() as $componentChild) { 305 if ($componentChild instanceof Property\ICalendar\DateTime && $componentChild->hasTime()) { 306 307 $dt = $componentChild->getDateTimes($timeZone); 308 // We only need to update the first timezone, because 309 // setDateTimes will match all other timezones to the 310 // first. 311 $dt[0] = $dt[0]->setTimeZone(new DateTimeZone('UTC')); 312 $componentChild->setDateTimes($dt); 313 } elseif ($componentChild instanceof Component) { 314 $stripTimezones($componentChild); 315 } 316 317 } 318 return $component; 319 320 }; 321 322 foreach ($this->children() as $child) { 323 324 if ($child instanceof Property && $child->name !== 'PRODID') { 325 // We explictly want to ignore PRODID, because we want to 326 // overwrite it with our own. 327 $newChildren[] = clone $child; 328 } elseif ($child instanceof Component && $child->name !== 'VTIMEZONE') { 329 330 // We're also stripping all VTIMEZONE objects because we're 331 // converting everything to UTC. 332 if ($child->name === 'VEVENT' && (isset($child->{'RECURRENCE-ID'}) || isset($child->RRULE) || isset($child->RDATE))) { 333 // Handle these a bit later. 334 $uid = (string)$child->UID; 335 if (!$uid) { 336 throw new InvalidDataException('Every VEVENT object must have a UID property'); 337 } 338 if (isset($recurringEvents[$uid])) { 339 $recurringEvents[$uid][] = clone $child; 340 } else { 341 $recurringEvents[$uid] = [clone $child]; 342 } 343 } elseif ($child->name === 'VEVENT' && $child->isInTimeRange($start, $end)) { 344 $newChildren[] = $stripTimezones(clone $child); 345 } 346 347 } 348 349 } 350 351 foreach ($recurringEvents as $events) { 352 353 try { 354 $it = new EventIterator($events, $timeZone); 355 356 } catch (NoInstancesException $e) { 357 // This event is recurring, but it doesn't have a single 358 // instance. We are skipping this event from the output 359 // entirely. 360 continue; 361 } 362 $it->fastForward($start); 363 364 while ($it->valid() && $it->getDTStart() < $end) { 365 366 if ($it->getDTEnd() > $start) { 367 368 $newChildren[] = $stripTimezones($it->getEventObject()); 369 370 } 371 $it->next(); 372 373 } 374 375 } 376 377 return new self($newChildren); 378 379 } 380 381 /** 382 * This method should return a list of default property values. 383 * 384 * @return array 385 */ 386 protected function getDefaults() { 387 388 return [ 389 'VERSION' => '2.0', 390 'PRODID' => '-//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN', 391 'CALSCALE' => 'GREGORIAN', 392 ]; 393 394 } 395 396 /** 397 * A simple list of validation rules. 398 * 399 * This is simply a list of properties, and how many times they either 400 * must or must not appear. 401 * 402 * Possible values per property: 403 * * 0 - Must not appear. 404 * * 1 - Must appear exactly once. 405 * * + - Must appear at least once. 406 * * * - Can appear any number of times. 407 * * ? - May appear, but not more than once. 408 * 409 * @var array 410 */ 411 function getValidationRules() { 412 413 return [ 414 'PRODID' => 1, 415 'VERSION' => 1, 416 417 'CALSCALE' => '?', 418 'METHOD' => '?', 419 ]; 420 421 } 422 423 /** 424 * Validates the node for correctness. 425 * 426 * The following options are supported: 427 * Node::REPAIR - May attempt to automatically repair the problem. 428 * Node::PROFILE_CARDDAV - Validate the vCard for CardDAV purposes. 429 * Node::PROFILE_CALDAV - Validate the iCalendar for CalDAV purposes. 430 * 431 * This method returns an array with detected problems. 432 * Every element has the following properties: 433 * 434 * * level - problem level. 435 * * message - A human-readable string describing the issue. 436 * * node - A reference to the problematic node. 437 * 438 * The level means: 439 * 1 - The issue was repaired (only happens if REPAIR was turned on). 440 * 2 - A warning. 441 * 3 - An error. 442 * 443 * @param int $options 444 * 445 * @return array 446 */ 447 function validate($options = 0) { 448 449 $warnings = parent::validate($options); 450 451 if ($ver = $this->VERSION) { 452 if ((string)$ver !== '2.0') { 453 $warnings[] = [ 454 'level' => 3, 455 'message' => 'Only iCalendar version 2.0 as defined in rfc5545 is supported.', 456 'node' => $this, 457 ]; 458 } 459 460 } 461 462 $uidList = []; 463 $componentsFound = 0; 464 $componentTypes = []; 465 466 foreach ($this->children() as $child) { 467 if ($child instanceof Component) { 468 $componentsFound++; 469 470 if (!in_array($child->name, ['VEVENT', 'VTODO', 'VJOURNAL'])) { 471 continue; 472 } 473 $componentTypes[] = $child->name; 474 475 $uid = (string)$child->UID; 476 $isMaster = isset($child->{'RECURRENCE-ID'}) ? 0 : 1; 477 if (isset($uidList[$uid])) { 478 $uidList[$uid]['count']++; 479 if ($isMaster && $uidList[$uid]['hasMaster']) { 480 $warnings[] = [ 481 'level' => 3, 482 'message' => 'More than one master object was found for the object with UID ' . $uid, 483 'node' => $this, 484 ]; 485 } 486 $uidList[$uid]['hasMaster'] += $isMaster; 487 } else { 488 $uidList[$uid] = [ 489 'count' => 1, 490 'hasMaster' => $isMaster, 491 ]; 492 } 493 494 } 495 } 496 497 if ($componentsFound === 0) { 498 $warnings[] = [ 499 'level' => 3, 500 'message' => 'An iCalendar object must have at least 1 component.', 501 'node' => $this, 502 ]; 503 } 504 505 if ($options & self::PROFILE_CALDAV) { 506 if (count($uidList) > 1) { 507 $warnings[] = [ 508 'level' => 3, 509 'message' => 'A calendar object on a CalDAV server may only have components with the same UID.', 510 'node' => $this, 511 ]; 512 } 513 if (count($componentTypes) === 0) { 514 $warnings[] = [ 515 'level' => 3, 516 'message' => 'A calendar object on a CalDAV server must have at least 1 component (VTODO, VEVENT, VJOURNAL).', 517 'node' => $this, 518 ]; 519 } 520 if (count(array_unique($componentTypes)) > 1) { 521 $warnings[] = [ 522 'level' => 3, 523 'message' => 'A calendar object on a CalDAV server may only have 1 type of component (VEVENT, VTODO or VJOURNAL).', 524 'node' => $this, 525 ]; 526 } 527 528 if (isset($this->METHOD)) { 529 $warnings[] = [ 530 'level' => 3, 531 'message' => 'A calendar object on a CalDAV server MUST NOT have a METHOD property.', 532 'node' => $this, 533 ]; 534 } 535 } 536 537 return $warnings; 538 539 } 540 541 /** 542 * Returns all components with a specific UID value. 543 * 544 * @return array 545 */ 546 function getByUID($uid) { 547 548 return array_filter($this->getComponents(), function($item) use ($uid) { 549 550 if (!$itemUid = $item->select('UID')) { 551 return false; 552 } 553 $itemUid = current($itemUid)->getValue(); 554 return $uid === $itemUid; 555 556 }); 557 558 } 559 560 561} 562