| 1: | <?php |
| 2: | declare(strict_types=1); |
| 3: | |
| 4: | namespace om\ICal; |
| 5: | |
| 6: | use DateInterval; |
| 7: | use DateTimeInterface; |
| 8: | use Generator; |
| 9: | use om\ICal\Exception\ResourceLimitException; |
| 10: | use om\ICal\Value\CalAddress; |
| 11: | use om\ICal\Value\Conference; |
| 12: | use om\ICal\Value\DateTimeValue; |
| 13: | use om\ICal\Value\Image; |
| 14: | use om\ICal\Value\Link; |
| 15: | use om\ICal\Value\Relation; |
| 16: | use om\ICal\Value\ValueParser; |
| 17: | use om\RRule\RecurrenceSet; |
| 18: | use om\RRule\Rule; |
| 19: | |
| 20: | |
| 21: | |
| 22: | |
| 23: | |
| 24: | |
| 25: | |
| 26: | |
| 27: | abstract class Item { |
| 28: | |
| 29: | private array $memo = []; |
| 30: | |
| 31: | |
| 32: | |
| 33: | |
| 34: | public function __construct( |
| 35: | public readonly Component $component, |
| 36: | protected readonly Calendar $calendar, |
| 37: | private readonly array $overrides = [], |
| 38: | ) { |
| 39: | } |
| 40: | |
| 41: | |
| 42: | |
| 43: | |
| 44: | abstract public function duration(): DateInterval; |
| 45: | |
| 46: | |
| 47: | |
| 48: | |
| 49: | public function end(): ?DateTimeValue { |
| 50: | return $this->start()?->add($this->duration()); |
| 51: | } |
| 52: | |
| 53: | public function property(string $name): ?Property { |
| 54: | return $this->component->property($name); |
| 55: | } |
| 56: | |
| 57: | |
| 58: | |
| 59: | |
| 60: | public function properties(string $name): array { |
| 61: | return $this->component->properties($name); |
| 62: | } |
| 63: | |
| 64: | |
| 65: | |
| 66: | |
| 67: | public function value(string $name): mixed { |
| 68: | $property = $this->property($name); |
| 69: | return $property === null ? null : $this->calendar->values()->value($property); |
| 70: | } |
| 71: | |
| 72: | public function calendar(): Calendar { |
| 73: | return $this->calendar; |
| 74: | } |
| 75: | |
| 76: | public function uid(): ?string { |
| 77: | return $this->text('UID'); |
| 78: | } |
| 79: | |
| 80: | public function summary(): ?string { |
| 81: | return $this->text('SUMMARY'); |
| 82: | } |
| 83: | |
| 84: | public function description(): ?string { |
| 85: | return $this->text('DESCRIPTION'); |
| 86: | } |
| 87: | |
| 88: | |
| 89: | |
| 90: | |
| 91: | public function location(): ?string { |
| 92: | return $this->text('LOCATION'); |
| 93: | } |
| 94: | |
| 95: | |
| 96: | |
| 97: | |
| 98: | |
| 99: | |
| 100: | public function locations(): array { |
| 101: | return array_map(fn(Component $component): Location => new Location($component, $this->calendar), $this->component->components('VLOCATION')); |
| 102: | } |
| 103: | |
| 104: | |
| 105: | |
| 106: | |
| 107: | public function status(): ?string { |
| 108: | $status = $this->text('STATUS'); |
| 109: | return $status === null ? null : strtoupper($status); |
| 110: | } |
| 111: | |
| 112: | public function isCancelled(): bool { |
| 113: | return $this->remember('cancelled', fn() => $this->status() === 'CANCELLED'); |
| 114: | } |
| 115: | |
| 116: | |
| 117: | |
| 118: | |
| 119: | public function classification(): string { |
| 120: | return strtoupper($this->text('CLASS') ?? 'PUBLIC'); |
| 121: | } |
| 122: | |
| 123: | public function url(): ?string { |
| 124: | return $this->property('URL')?->value; |
| 125: | } |
| 126: | |
| 127: | public function sequence(): int { |
| 128: | return $this->integer('SEQUENCE') ?? 0; |
| 129: | } |
| 130: | |
| 131: | public function priority(): ?int { |
| 132: | return $this->integer('PRIORITY'); |
| 133: | } |
| 134: | |
| 135: | |
| 136: | |
| 137: | |
| 138: | public function categories(): array { |
| 139: | $categories = []; |
| 140: | foreach ($this->properties('CATEGORIES') as $property) { |
| 141: | array_push($categories, ...$this->calendar->values()->texts($property)); |
| 142: | } |
| 143: | return $categories; |
| 144: | } |
| 145: | |
| 146: | |
| 147: | |
| 148: | |
| 149: | public function color(): ?string { |
| 150: | return $this->text('COLOR'); |
| 151: | } |
| 152: | |
| 153: | |
| 154: | |
| 155: | |
| 156: | |
| 157: | |
| 158: | public function images(): array { |
| 159: | return array_values(array_filter(array_map($this->calendar->values()->image(...), $this->properties('IMAGE')))); |
| 160: | } |
| 161: | |
| 162: | |
| 163: | |
| 164: | |
| 165: | |
| 166: | |
| 167: | public function conferences(): array { |
| 168: | return array_map(fn(Property $property): Conference => new Conference($this->calendar->values()->uri($property), $property->parameters), $this->properties('CONFERENCE')); |
| 169: | } |
| 170: | |
| 171: | |
| 172: | |
| 173: | |
| 174: | |
| 175: | |
| 176: | public function links(): array { |
| 177: | return array_map(fn(Property $property): Link => new Link($this->reference($property), $property->parameters), $this->properties('LINK')); |
| 178: | } |
| 179: | |
| 180: | |
| 181: | |
| 182: | |
| 183: | |
| 184: | |
| 185: | public function relatedTo(): array { |
| 186: | return array_map(fn(Property $property): Relation => new Relation($this->reference($property), $property->parameters), $this->properties('RELATED-TO')); |
| 187: | } |
| 188: | |
| 189: | public function created(): ?DateTimeValue { |
| 190: | return $this->date('CREATED'); |
| 191: | } |
| 192: | |
| 193: | public function lastModified(): ?DateTimeValue { |
| 194: | return $this->date('LAST-MODIFIED'); |
| 195: | } |
| 196: | |
| 197: | public function stamp(): ?DateTimeValue { |
| 198: | return $this->date('DTSTAMP'); |
| 199: | } |
| 200: | |
| 201: | public function organizer(): ?CalAddress { |
| 202: | $property = $this->property('ORGANIZER'); |
| 203: | return $property === null ? null : $this->calendar->values()->calAddress($property); |
| 204: | } |
| 205: | |
| 206: | |
| 207: | |
| 208: | |
| 209: | public function attendees(): array { |
| 210: | return array_map(fn(Property $property): CalAddress => $this->calendar->values()->calAddress($property), $this->properties('ATTENDEE')); |
| 211: | } |
| 212: | |
| 213: | public function start(): ?DateTimeValue { |
| 214: | return $this->remember('start', fn() => $this->date('DTSTART')); |
| 215: | } |
| 216: | |
| 217: | public function isAllDay(): bool { |
| 218: | return $this->start()?->isDate() ?? false; |
| 219: | } |
| 220: | |
| 221: | |
| 222: | |
| 223: | |
| 224: | public function alarms(): array { |
| 225: | return array_map(fn(Component $component): Alarm => new Alarm($component, $this->calendar), $this->component->components('VALARM')); |
| 226: | } |
| 227: | |
| 228: | public function recurrenceRule(): ?Rule { |
| 229: | return $this->recurrenceRules()[0] ?? null; |
| 230: | } |
| 231: | |
| 232: | |
| 233: | |
| 234: | |
| 235: | |
| 236: | |
| 237: | public function recurrenceRules(): array { |
| 238: | $rules = []; |
| 239: | foreach ($this->properties('RRULE') as $property) { |
| 240: | if (($rule = $this->calendar->values()->recur($property)) !== null) { |
| 241: | $rules[] = $rule; |
| 242: | } |
| 243: | } |
| 244: | return $rules; |
| 245: | } |
| 246: | |
| 247: | |
| 248: | |
| 249: | |
| 250: | |
| 251: | |
| 252: | public function recurrenceDates(): array { |
| 253: | return $this->dates('RDATE'); |
| 254: | } |
| 255: | |
| 256: | |
| 257: | |
| 258: | |
| 259: | |
| 260: | |
| 261: | public function exceptionDates(): array { |
| 262: | return $this->dates('EXDATE'); |
| 263: | } |
| 264: | |
| 265: | public function isRecurring(): bool { |
| 266: | return $this->has('RRULE') || $this->has('RDATE'); |
| 267: | } |
| 268: | |
| 269: | public function recurrenceId(): ?DateTimeValue { |
| 270: | return $this->remember('recurrenceId', fn() => $this->date('RECURRENCE-ID')); |
| 271: | } |
| 272: | |
| 273: | |
| 274: | |
| 275: | |
| 276: | public function isOverride(): bool { |
| 277: | return $this->has('RECURRENCE-ID'); |
| 278: | } |
| 279: | |
| 280: | |
| 281: | |
| 282: | |
| 283: | public function isThisAndFuture(): bool { |
| 284: | return strtoupper($this->property('RECURRENCE-ID')?->parameter('RANGE') ?? '') === 'THISANDFUTURE'; |
| 285: | } |
| 286: | |
| 287: | |
| 288: | |
| 289: | |
| 290: | |
| 291: | |
| 292: | public function overrides(): array { |
| 293: | return $this->overrides; |
| 294: | } |
| 295: | |
| 296: | |
| 297: | |
| 298: | |
| 299: | |
| 300: | |
| 301: | |
| 302: | |
| 303: | |
| 304: | public function occurrencesBetween(DateTimeInterface $from, DateTimeInterface $to, bool $includeCancelled = false): Generator { |
| 305: | return $this->expand($from, $to, $includeCancelled, PHP_INT_MAX); |
| 306: | } |
| 307: | |
| 308: | |
| 309: | |
| 310: | |
| 311: | |
| 312: | |
| 313: | public function occurrences(int $limit = 1000, ?DateTimeInterface $from = null, bool $includeCancelled = false): Generator { |
| 314: | return $this->expand($from, null, $includeCancelled, $limit); |
| 315: | } |
| 316: | |
| 317: | |
| 318: | |
| 319: | |
| 320: | private function expand(?DateTimeInterface $from, ?DateTimeInterface $to, bool $includeCancelled, int $limit): Generator { |
| 321: | $start = $this->start(); |
| 322: | if ($start === null || $limit < 1) { |
| 323: | return; |
| 324: | } |
| 325: | $space = new TimeSpace($start); |
| 326: | $fromTs = $from === null ? null : $space->window($from); |
| 327: | $toTs = $to === null ? null : $space->window($to); |
| 328: | |
| 329: | |
| 330: | if (!$this->isRecurring() && $this->overrides === []) { |
| 331: | $occurrence = new Occurrence($start, $start->add($this->duration()), $this, $this); |
| 332: | if (($includeCancelled || !$this->isCancelled()) && $space->overlaps($occurrence, $fromTs, $toTs)) { |
| 333: | yield $occurrence; |
| 334: | } |
| 335: | return; |
| 336: | } |
| 337: | |
| 338: | |
| 339: | $singles = $replaced = $replacedDays = $ranges = []; |
| 340: | $earliest = 0; |
| 341: | foreach ($this->overrides as $override) { |
| 342: | $id = $override->recurrenceId(); |
| 343: | if ($id === null) { |
| 344: | continue; |
| 345: | } |
| 346: | $overrideStart = $override->start(); |
| 347: | $durationOf = $overrideStart === null ? $this : $override; |
| 348: | if ($override->isThisAndFuture()) { |
| 349: | |
| 350: | $shift = $overrideStart === null ? 0 : $this->localTime($space, $overrideStart) - $this->localTime($space, $id); |
| 351: | $ranges[$space->toBase($id)] = [$override, $shift, $durationOf]; |
| 352: | $earliest = max($earliest, -$shift); |
| 353: | continue; |
| 354: | } |
| 355: | $id->isDate() && !$start->isDate() ? $replacedDays[$id->format('Ymd')] = true : $replaced[$space->toBase($id)] = true; |
| 356: | $overrideStart ??= $space->fromBase($space->toBase($id)); |
| 357: | $occurrence = new Occurrence($overrideStart, $overrideStart->add($durationOf->duration()), $override, $this, $id); |
| 358: | if (($includeCancelled || !$override->isCancelled()) && $space->overlaps($occurrence, $fromTs, $toTs)) { |
| 359: | $singles[] = $occurrence; |
| 360: | } |
| 361: | } |
| 362: | ksort($ranges); |
| 363: | |
| 364: | $slack = $earliest === 0 ? 0 : $earliest + 7200; |
| 365: | |
| 366: | $exdays = $exdates = []; |
| 367: | foreach ($this->exceptionDates() as $date) { |
| 368: | if ($date->isDate() && !$start->isDate()) { |
| 369: | $exdays[] = $date->format('Ymd'); |
| 370: | } else { |
| 371: | $exdates[] = $space->toBase($date); |
| 372: | } |
| 373: | } |
| 374: | $limits = $this->calendar->recurrenceLimits(); |
| 375: | $set = new RecurrenceSet( |
| 376: | $space->start(), |
| 377: | |
| 378: | array_values(array_filter($this->recurrenceRules(), static fn(Rule $rule): bool => $rule->isGregorian())), |
| 379: | rdates: array_map($space->toBase(...), $this->recurrenceDates()), |
| 380: | exdates: $exdates, |
| 381: | exdays: $exdays, |
| 382: | until: $toTs === null ? null : $toTs - 1 + $slack, |
| 383: | maxIterations: $limits->maxIterations, |
| 384: | ); |
| 385: | |
| 386: | |
| 387: | $buffer = new OccurrenceBuffer($space, $singles); |
| 388: | $count = 0; |
| 389: | foreach ($set as $timestamp) { |
| 390: | if ($toTs !== null && $timestamp >= $toTs + $slack) { |
| 391: | break; |
| 392: | } |
| 393: | $id = $space->fromBase($timestamp); |
| 394: | if (!isset($replaced[$timestamp]) && ($replacedDays === [] || !isset($replacedDays[$id->format('Ymd')]))) { |
| 395: | [$item, $occurrenceStart, $durationOf] = [$this, $id, $this]; |
| 396: | foreach ($ranges as $rangeId => [$range, $shift, $rangeDuration]) { |
| 397: | if ($rangeId > $timestamp) { |
| 398: | break; |
| 399: | } |
| 400: | [$item, $occurrenceStart, $durationOf] = [$range, $space->shift($id, $shift), $rangeDuration]; |
| 401: | } |
| 402: | $occurrence = new Occurrence($occurrenceStart, $occurrenceStart->add($durationOf->duration()), $item, $this, $id); |
| 403: | if (($includeCancelled || !$item->isCancelled()) && $space->overlaps($occurrence, $fromTs, $toTs)) { |
| 404: | $buffer->add($occurrence); |
| 405: | } |
| 406: | } |
| 407: | foreach ($buffer->ready($timestamp - $slack) as $occurrence) { |
| 408: | yield $occurrence; |
| 409: | if (++$count >= $limit) { |
| 410: | return; |
| 411: | } |
| 412: | if ($count > $limits->maxInstances) { |
| 413: | throw ResourceLimitException::create('recurrence.limit', "Recurrence occurrence limit of {$limits->maxInstances} exceeded."); |
| 414: | } |
| 415: | } |
| 416: | } |
| 417: | foreach ($buffer->ready(PHP_INT_MAX) as $occurrence) { |
| 418: | yield $occurrence; |
| 419: | if (++$count >= $limit) { |
| 420: | return; |
| 421: | } |
| 422: | if ($count > $limits->maxInstances) { |
| 423: | throw ResourceLimitException::create('recurrence.limit', "Recurrence occurrence limit of {$limits->maxInstances} exceeded."); |
| 424: | } |
| 425: | } |
| 426: | } |
| 427: | |
| 428: | private function localTime(TimeSpace $space, DateTimeValue $value): int { |
| 429: | return $space->fromBase($space->toBase($value))->wallClockAsUtc()->getTimestamp(); |
| 430: | } |
| 431: | |
| 432: | |
| 433: | |
| 434: | |
| 435: | |
| 436: | |
| 437: | protected function remember(string $key, callable $calculate): mixed { |
| 438: | if (!array_key_exists($key, $this->memo)) { |
| 439: | $this->memo[$key] = $calculate(); |
| 440: | } |
| 441: | return $this->memo[$key]; |
| 442: | } |
| 443: | |
| 444: | protected function has(string $name): bool { |
| 445: | return $this->component->has($name); |
| 446: | } |
| 447: | |
| 448: | protected function text(string $name): ?string { |
| 449: | $property = $this->property($name); |
| 450: | return $property === null ? null : $this->calendar->values()->text($property); |
| 451: | } |
| 452: | |
| 453: | |
| 454: | |
| 455: | |
| 456: | private function reference(Property $property): string { |
| 457: | $values = $this->calendar->values(); |
| 458: | return in_array(ValueParser::type($property), ['URI', 'XML-REFERENCE'], true) ? $values->uri($property) : $values->text($property); |
| 459: | } |
| 460: | |
| 461: | protected function integer(string $name): ?int { |
| 462: | $property = $this->property($name); |
| 463: | return $property === null ? null : $this->calendar->values()->integer($property); |
| 464: | } |
| 465: | |
| 466: | protected function date(string $name): ?DateTimeValue { |
| 467: | $property = $this->property($name); |
| 468: | return $property === null ? null : $this->calendar->values()->dateTime($property); |
| 469: | } |
| 470: | |
| 471: | |
| 472: | |
| 473: | |
| 474: | protected function dates(string $name): array { |
| 475: | $dates = []; |
| 476: | foreach ($this->properties($name) as $property) { |
| 477: | array_push($dates, ...$this->calendar->values()->dateTimes($property)); |
| 478: | } |
| 479: | return $dates; |
| 480: | } |
| 481: | |
| 482: | |
| 483: | |
| 484: | |
| 485: | |
| 486: | protected static function between(DateTimeValue $start, DateTimeValue $end): DateInterval { |
| 487: | if ($start->isDate() || $start->isFloating()) { |
| 488: | return $start->wallClockAsUtc()->diff($end->wallClockAsUtc()); |
| 489: | } |
| 490: | $seconds = $end->toDateTime(null, $start->timezone())->getTimestamp() - $start->toDateTime()->getTimestamp(); |
| 491: | |
| 492: | $absolute = abs($seconds); |
| 493: | $interval = new DateInterval(sprintf('PT%dH%dM%dS', intdiv($absolute, 3600), intdiv($absolute % 3600, 60), $absolute % 60)); |
| 494: | $interval->invert = $seconds < 0 ? 1 : 0; |
| 495: | return $interval; |
| 496: | } |
| 497: | } |
| 498: | |