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 public 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 public 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 public 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 public 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 * Returns the current document type. 157 * 158 * @return int 159 */ 160 public function getDocumentType() 161 { 162 return self::ICALENDAR20; 163 } 164 165 /** 166 * Returns a list of all 'base components'. For instance, if an Event has 167 * a recurrence rule, and one instance is overridden, the overridden event 168 * will have the same UID, but will be excluded from this list. 169 * 170 * VTIMEZONE components will always be excluded. 171 * 172 * @param string $componentName filter by component name 173 * 174 * @return VObject\Component[] 175 */ 176 public function getBaseComponents($componentName = null) 177 { 178 $isBaseComponent = function ($component) { 179 if (!$component instanceof VObject\Component) { 180 return false; 181 } 182 if ('VTIMEZONE' === $component->name) { 183 return false; 184 } 185 if (isset($component->{'RECURRENCE-ID'})) { 186 return false; 187 } 188 189 return true; 190 }; 191 192 if ($componentName) { 193 // Early exit 194 return array_filter( 195 $this->select($componentName), 196 $isBaseComponent 197 ); 198 } 199 200 $components = []; 201 foreach ($this->children as $childGroup) { 202 foreach ($childGroup as $child) { 203 if (!$child instanceof Component) { 204 // If one child is not a component, they all are so we skip 205 // the entire group. 206 continue 2; 207 } 208 if ($isBaseComponent($child)) { 209 $components[] = $child; 210 } 211 } 212 } 213 214 return $components; 215 } 216 217 /** 218 * Returns the first component that is not a VTIMEZONE, and does not have 219 * an RECURRENCE-ID. 220 * 221 * If there is no such component, null will be returned. 222 * 223 * @param string $componentName filter by component name 224 * 225 * @return VObject\Component|null 226 */ 227 public function getBaseComponent($componentName = null) 228 { 229 $isBaseComponent = function ($component) { 230 if (!$component instanceof VObject\Component) { 231 return false; 232 } 233 if ('VTIMEZONE' === $component->name) { 234 return false; 235 } 236 if (isset($component->{'RECURRENCE-ID'})) { 237 return false; 238 } 239 240 return true; 241 }; 242 243 if ($componentName) { 244 foreach ($this->select($componentName) as $child) { 245 if ($isBaseComponent($child)) { 246 return $child; 247 } 248 } 249 250 return null; 251 } 252 253 // Searching all components 254 foreach ($this->children as $childGroup) { 255 foreach ($childGroup as $child) { 256 if ($isBaseComponent($child)) { 257 return $child; 258 } 259 } 260 } 261 262 return null; 263 } 264 265 /** 266 * Expand all events in this VCalendar object and return a new VCalendar 267 * with the expanded events. 268 * 269 * If this calendar object, has events with recurrence rules, this method 270 * can be used to expand the event into multiple sub-events. 271 * 272 * Each event will be stripped from its recurrence information, and only 273 * the instances of the event in the specified timerange will be left 274 * alone. 275 * 276 * In addition, this method will cause timezone information to be stripped, 277 * and normalized to UTC. 278 * 279 * @param DateTimeInterface $start 280 * @param DateTimeInterface $end 281 * @param DateTimeZone $timeZone reference timezone for floating dates and 282 * times 283 * 284 * @return VCalendar 285 */ 286 public function expand(DateTimeInterface $start, DateTimeInterface $end, DateTimeZone $timeZone = null) 287 { 288 $newChildren = []; 289 $recurringEvents = []; 290 291 if (!$timeZone) { 292 $timeZone = new DateTimeZone('UTC'); 293 } 294 295 $stripTimezones = function (Component $component) use ($timeZone, &$stripTimezones) { 296 foreach ($component->children() as $componentChild) { 297 if ($componentChild instanceof Property\ICalendar\DateTime && $componentChild->hasTime()) { 298 $dt = $componentChild->getDateTimes($timeZone); 299 // We only need to update the first timezone, because 300 // setDateTimes will match all other timezones to the 301 // first. 302 $dt[0] = $dt[0]->setTimeZone(new DateTimeZone('UTC')); 303 $componentChild->setDateTimes($dt); 304 } elseif ($componentChild instanceof Component) { 305 $stripTimezones($componentChild); 306 } 307 } 308 309 return $component; 310 }; 311 312 foreach ($this->children() as $child) { 313 if ($child instanceof Property && 'PRODID' !== $child->name) { 314 // We explictly want to ignore PRODID, because we want to 315 // overwrite it with our own. 316 $newChildren[] = clone $child; 317 } elseif ($child instanceof Component && 'VTIMEZONE' !== $child->name) { 318 // We're also stripping all VTIMEZONE objects because we're 319 // converting everything to UTC. 320 if ('VEVENT' === $child->name && (isset($child->{'RECURRENCE-ID'}) || isset($child->RRULE) || isset($child->RDATE))) { 321 // Handle these a bit later. 322 $uid = (string) $child->UID; 323 if (!$uid) { 324 throw new InvalidDataException('Every VEVENT object must have a UID property'); 325 } 326 if (isset($recurringEvents[$uid])) { 327 $recurringEvents[$uid][] = clone $child; 328 } else { 329 $recurringEvents[$uid] = [clone $child]; 330 } 331 } elseif ('VEVENT' === $child->name && $child->isInTimeRange($start, $end)) { 332 $newChildren[] = $stripTimezones(clone $child); 333 } 334 } 335 } 336 337 foreach ($recurringEvents as $events) { 338 try { 339 $it = new EventIterator($events, null, $timeZone); 340 } catch (NoInstancesException $e) { 341 // This event is recurring, but it doesn't have a single 342 // instance. We are skipping this event from the output 343 // entirely. 344 continue; 345 } 346 $it->fastForward($start); 347 348 while ($it->valid() && $it->getDTStart() < $end) { 349 if ($it->getDTEnd() > $start) { 350 $newChildren[] = $stripTimezones($it->getEventObject()); 351 } 352 $it->next(); 353 } 354 } 355 356 return new self($newChildren); 357 } 358 359 /** 360 * This method should return a list of default property values. 361 * 362 * @return array 363 */ 364 protected function getDefaults() 365 { 366 return [ 367 'VERSION' => '2.0', 368 'PRODID' => '-//Sabre//Sabre VObject '.VObject\Version::VERSION.'//EN', 369 'CALSCALE' => 'GREGORIAN', 370 ]; 371 } 372 373 /** 374 * A simple list of validation rules. 375 * 376 * This is simply a list of properties, and how many times they either 377 * must or must not appear. 378 * 379 * Possible values per property: 380 * * 0 - Must not appear. 381 * * 1 - Must appear exactly once. 382 * * + - Must appear at least once. 383 * * * - Can appear any number of times. 384 * * ? - May appear, but not more than once. 385 * 386 * @var array 387 */ 388 public function getValidationRules() 389 { 390 return [ 391 'PRODID' => 1, 392 'VERSION' => 1, 393 394 'CALSCALE' => '?', 395 'METHOD' => '?', 396 ]; 397 } 398 399 /** 400 * Validates the node for correctness. 401 * 402 * The following options are supported: 403 * Node::REPAIR - May attempt to automatically repair the problem. 404 * Node::PROFILE_CARDDAV - Validate the vCard for CardDAV purposes. 405 * Node::PROFILE_CALDAV - Validate the iCalendar for CalDAV purposes. 406 * 407 * This method returns an array with detected problems. 408 * Every element has the following properties: 409 * 410 * * level - problem level. 411 * * message - A human-readable string describing the issue. 412 * * node - A reference to the problematic node. 413 * 414 * The level means: 415 * 1 - The issue was repaired (only happens if REPAIR was turned on). 416 * 2 - A warning. 417 * 3 - An error. 418 * 419 * @param int $options 420 * 421 * @return array 422 */ 423 public function validate($options = 0) 424 { 425 $warnings = parent::validate($options); 426 427 if ($ver = $this->VERSION) { 428 if ('2.0' !== (string) $ver) { 429 $warnings[] = [ 430 'level' => 3, 431 'message' => 'Only iCalendar version 2.0 as defined in rfc5545 is supported.', 432 'node' => $this, 433 ]; 434 } 435 } 436 437 $uidList = []; 438 $componentsFound = 0; 439 $componentTypes = []; 440 441 foreach ($this->children() as $child) { 442 if ($child instanceof Component) { 443 ++$componentsFound; 444 445 if (!in_array($child->name, ['VEVENT', 'VTODO', 'VJOURNAL'])) { 446 continue; 447 } 448 $componentTypes[] = $child->name; 449 450 $uid = (string) $child->UID; 451 $isMaster = isset($child->{'RECURRENCE-ID'}) ? 0 : 1; 452 if (isset($uidList[$uid])) { 453 ++$uidList[$uid]['count']; 454 if ($isMaster && $uidList[$uid]['hasMaster']) { 455 $warnings[] = [ 456 'level' => 3, 457 'message' => 'More than one master object was found for the object with UID '.$uid, 458 'node' => $this, 459 ]; 460 } 461 $uidList[$uid]['hasMaster'] += $isMaster; 462 } else { 463 $uidList[$uid] = [ 464 'count' => 1, 465 'hasMaster' => $isMaster, 466 ]; 467 } 468 } 469 } 470 471 if (0 === $componentsFound) { 472 $warnings[] = [ 473 'level' => 3, 474 'message' => 'An iCalendar object must have at least 1 component.', 475 'node' => $this, 476 ]; 477 } 478 479 if ($options & self::PROFILE_CALDAV) { 480 if (count($uidList) > 1) { 481 $warnings[] = [ 482 'level' => 3, 483 'message' => 'A calendar object on a CalDAV server may only have components with the same UID.', 484 'node' => $this, 485 ]; 486 } 487 if (0 === count($componentTypes)) { 488 $warnings[] = [ 489 'level' => 3, 490 'message' => 'A calendar object on a CalDAV server must have at least 1 component (VTODO, VEVENT, VJOURNAL).', 491 'node' => $this, 492 ]; 493 } 494 if (count(array_unique($componentTypes)) > 1) { 495 $warnings[] = [ 496 'level' => 3, 497 'message' => 'A calendar object on a CalDAV server may only have 1 type of component (VEVENT, VTODO or VJOURNAL).', 498 'node' => $this, 499 ]; 500 } 501 502 if (isset($this->METHOD)) { 503 $warnings[] = [ 504 'level' => 3, 505 'message' => 'A calendar object on a CalDAV server MUST NOT have a METHOD property.', 506 'node' => $this, 507 ]; 508 } 509 } 510 511 return $warnings; 512 } 513 514 /** 515 * Returns all components with a specific UID value. 516 * 517 * @return array 518 */ 519 public function getByUID($uid) 520 { 521 return array_filter($this->getComponents(), function ($item) use ($uid) { 522 if (!$itemUid = $item->select('UID')) { 523 return false; 524 } 525 $itemUid = current($itemUid)->getValue(); 526 527 return $uid === $itemUid; 528 }); 529 } 530} 531