| 1: | <?php |
| 2: | declare(strict_types=1); |
| 3: | |
| 4: | namespace om\ICal; |
| 5: | |
| 6: | use DateInterval; |
| 7: | use DateTimeInterface; |
| 8: | use DateTimeZone; |
| 9: | use InvalidArgumentException; |
| 10: | use om\ICal\Timezone\TimezoneResolver; |
| 11: | use om\ICal\Timezone\VTimezoneBuilder; |
| 12: | use om\ICal\Value\Image; |
| 13: | use om\ICal\Value\PropertyFactory; |
| 14: | use om\ICal\Value\Text; |
| 15: | use om\ICal\Value\ValueParser; |
| 16: | use om\RRule\RecurrenceLimits; |
| 17: | use RuntimeException; |
| 18: | |
| 19: | |
| 20: | |
| 21: | |
| 22: | |
| 23: | |
| 24: | |
| 25: | |
| 26: | |
| 27: | |
| 28: | final class Calendar { |
| 29: | private const array ITEMS = ['VEVENT' => Event::class, 'VTODO' => Todo::class, 'VJOURNAL' => Journal::class, 'VFREEBUSY' => FreeBusy::class]; |
| 30: | |
| 31: | private readonly ValueParser $values; |
| 32: | |
| 33: | private array $items = []; |
| 34: | |
| 35: | |
| 36: | |
| 37: | |
| 38: | public function __construct( |
| 39: | public readonly Component $component = new Component('VCALENDAR'), |
| 40: | private readonly ?TimezoneResolver $timezoneResolver = null, |
| 41: | private readonly ?DateTimeZone $floatingTimezone = null, |
| 42: | private readonly RecurrenceLimits $recurrenceLimits = new RecurrenceLimits(), |
| 43: | private readonly bool $strict = false, |
| 44: | ) { |
| 45: | $this->values = new ValueParser($component, $timezoneResolver, $strict); |
| 46: | } |
| 47: | |
| 48: | |
| 49: | |
| 50: | |
| 51: | |
| 52: | |
| 53: | |
| 54: | |
| 55: | |
| 56: | |
| 57: | |
| 58: | |
| 59: | |
| 60: | |
| 61: | |
| 62: | |
| 63: | |
| 64: | |
| 65: | |
| 66: | |
| 67: | |
| 68: | |
| 69: | public static function create( |
| 70: | string $productId = '-//om//icalparser//EN', |
| 71: | ?string $name = null, |
| 72: | ?string $description = null, |
| 73: | ?string $color = null, |
| 74: | ?string $method = null, |
| 75: | iterable $events = [], |
| 76: | iterable $todos = [], |
| 77: | iterable $journals = [], |
| 78: | iterable $components = [], |
| 79: | array $properties = [], |
| 80: | bool $timezones = true, |
| 81: | ): self { |
| 82: | if ($method !== null && !preg_match('/^[A-Za-z0-9-]+$/D', $method)) { |
| 83: | throw new InvalidArgumentException("Invalid METHOD value: $method"); |
| 84: | } |
| 85: | $calendar = (new ComponentBuilder('VCALENDAR')) |
| 86: | ->add(Property::create('VERSION', '2.0')) |
| 87: | ->text('PRODID', $productId) |
| 88: | ->add($method === null ? null : Property::create('METHOD', strtoupper($method))) |
| 89: | ->text('NAME', $name) |
| 90: | ->text('X-WR-CALNAME', $name) |
| 91: | ->text('DESCRIPTION', $description) |
| 92: | ->text('X-WR-CALDESC', $description) |
| 93: | ->text('COLOR', $color) |
| 94: | ->build($properties); |
| 95: | |
| 96: | $items = [...self::components($events, 'VEVENT'), ...self::components($todos, 'VTODO'), ...self::components($journals, 'VJOURNAL')]; |
| 97: | $defined = $others = []; |
| 98: | foreach (self::components($components, null) as $component) { |
| 99: | $component->name === 'VTIMEZONE' ? $defined[] = $component : $others[] = $component; |
| 100: | } |
| 101: | $generated = $timezones ? VTimezoneBuilder::forComponents([...$items, ...$others], array_map( |
| 102: | static fn(Component $definition): string => Text::unescape($definition->property('TZID')->value ?? ''), |
| 103: | $defined, |
| 104: | )) : []; |
| 105: | return new self($calendar->withComponents([...$defined, ...$generated, ...$items, ...$others])); |
| 106: | } |
| 107: | |
| 108: | |
| 109: | |
| 110: | |
| 111: | |
| 112: | |
| 113: | public function writeFile(string $file): void { |
| 114: | if (@file_put_contents($file, $this->serialize(), LOCK_EX) === false) { |
| 115: | throw new RuntimeException("Unable to write the file $file."); |
| 116: | } |
| 117: | } |
| 118: | |
| 119: | public function values(): ValueParser { |
| 120: | return $this->values; |
| 121: | } |
| 122: | |
| 123: | public function timezoneResolver(): ?TimezoneResolver { |
| 124: | return $this->timezoneResolver; |
| 125: | } |
| 126: | |
| 127: | public function recurrenceLimits(): RecurrenceLimits { |
| 128: | return $this->recurrenceLimits; |
| 129: | } |
| 130: | |
| 131: | public function property(string $name): ?Property { |
| 132: | return $this->component->property($name); |
| 133: | } |
| 134: | |
| 135: | public function value(string $name): mixed { |
| 136: | $property = $this->property($name); |
| 137: | return $property === null ? null : $this->values->value($property); |
| 138: | } |
| 139: | |
| 140: | |
| 141: | |
| 142: | |
| 143: | public function name(): ?string { |
| 144: | return $this->text('NAME') ?? $this->text('X-WR-CALNAME'); |
| 145: | } |
| 146: | |
| 147: | |
| 148: | |
| 149: | |
| 150: | public function description(): ?string { |
| 151: | return $this->text('DESCRIPTION') ?? $this->text('X-WR-CALDESC'); |
| 152: | } |
| 153: | |
| 154: | |
| 155: | |
| 156: | |
| 157: | public function color(): ?string { |
| 158: | return $this->text('COLOR'); |
| 159: | } |
| 160: | |
| 161: | |
| 162: | |
| 163: | |
| 164: | |
| 165: | |
| 166: | public function images(): array { |
| 167: | return array_values(array_filter(array_map($this->values->image(...), $this->component->properties('IMAGE')))); |
| 168: | } |
| 169: | |
| 170: | |
| 171: | |
| 172: | |
| 173: | public function source(): ?string { |
| 174: | $property = $this->property('SOURCE'); |
| 175: | return $property === null ? null : $this->values->uri($property); |
| 176: | } |
| 177: | |
| 178: | |
| 179: | |
| 180: | |
| 181: | public function refreshInterval(): ?DateInterval { |
| 182: | $property = $this->property('REFRESH-INTERVAL'); |
| 183: | return $property === null ? null : $this->values->duration($property); |
| 184: | } |
| 185: | |
| 186: | public function productId(): ?string { |
| 187: | return $this->text('PRODID'); |
| 188: | } |
| 189: | |
| 190: | public function version(): ?string { |
| 191: | return $this->text('VERSION'); |
| 192: | } |
| 193: | |
| 194: | |
| 195: | public function method(): ?string { |
| 196: | $method = $this->text('METHOD'); |
| 197: | return $method === null ? null : strtoupper($method); |
| 198: | } |
| 199: | |
| 200: | |
| 201: | |
| 202: | |
| 203: | public function timezone(): ?DateTimeZone { |
| 204: | $tzid = $this->text('X-WR-TIMEZONE'); |
| 205: | return $tzid === null ? null : $this->values->timezone($tzid)?->timezone; |
| 206: | } |
| 207: | |
| 208: | |
| 209: | |
| 210: | |
| 211: | public function floatingTimezone(): ?DateTimeZone { |
| 212: | return $this->floatingTimezone ?? $this->timezone(); |
| 213: | } |
| 214: | |
| 215: | |
| 216: | |
| 217: | |
| 218: | public function events(): array { |
| 219: | return array_values(array_filter($this->items('VEVENT'), static fn(Item $item): bool => $item instanceof Event)); |
| 220: | } |
| 221: | |
| 222: | |
| 223: | |
| 224: | |
| 225: | public function todos(): array { |
| 226: | return array_values(array_filter($this->items('VTODO'), static fn(Item $item): bool => $item instanceof Todo)); |
| 227: | } |
| 228: | |
| 229: | |
| 230: | |
| 231: | |
| 232: | public function journals(): array { |
| 233: | return array_values(array_filter($this->items('VJOURNAL'), static fn(Item $item): bool => $item instanceof Journal)); |
| 234: | } |
| 235: | |
| 236: | |
| 237: | |
| 238: | |
| 239: | public function freeBusy(): array { |
| 240: | return array_values(array_filter($this->items('VFREEBUSY'), static fn(Item $item): bool => $item instanceof FreeBusy)); |
| 241: | } |
| 242: | |
| 243: | |
| 244: | |
| 245: | |
| 246: | public function timezones(): array { |
| 247: | return array_map(fn(Component $component): TimezoneDefinition => new TimezoneDefinition($component, $this), $this->component->components('VTIMEZONE')); |
| 248: | } |
| 249: | |
| 250: | |
| 251: | |
| 252: | |
| 253: | |
| 254: | |
| 255: | public function occurrencesBetween(DateTimeInterface $from, DateTimeInterface $to, bool $includeCancelled = false): array { |
| 256: | $occurrences = []; |
| 257: | foreach ($this->events() as $event) { |
| 258: | foreach ($event->occurrencesBetween($from, $to, $includeCancelled) as $occurrence) { |
| 259: | $occurrences[] = $occurrence; |
| 260: | } |
| 261: | } |
| 262: | |
| 263: | $timezone = $this->floatingTimezone() ?? $from->getTimezone(); |
| 264: | $keys = array_map(static fn(Occurrence $occurrence): int => $occurrence->start->toDateTime($timezone, $timezone)->getTimestamp(), $occurrences); |
| 265: | array_multisort($keys, SORT_NUMERIC, array_keys($occurrences), $occurrences); |
| 266: | return $occurrences; |
| 267: | } |
| 268: | |
| 269: | |
| 270: | |
| 271: | |
| 272: | public function withComponent(Component|Item $component): self { |
| 273: | $component = $component instanceof Item ? $component->component : $component; |
| 274: | return new self($this->component->withComponent($component), $this->timezoneResolver, $this->floatingTimezone, $this->recurrenceLimits, $this->strict); |
| 275: | } |
| 276: | |
| 277: | |
| 278: | |
| 279: | |
| 280: | public function serialize(): string { |
| 281: | return Serializer::serialize($this->component); |
| 282: | } |
| 283: | |
| 284: | |
| 285: | |
| 286: | |
| 287: | |
| 288: | |
| 289: | public function itemOf(Component $component): ?Item { |
| 290: | $class = self::ITEMS[$component->name] ?? null; |
| 291: | return $class === null ? null : new $class($component, $this); |
| 292: | } |
| 293: | |
| 294: | |
| 295: | |
| 296: | |
| 297: | |
| 298: | |
| 299: | private function items(string $name): array { |
| 300: | if (isset($this->items[$name])) { |
| 301: | return $this->items[$name]; |
| 302: | } |
| 303: | $class = self::ITEMS[$name]; |
| 304: | $masters = $overrides = $result = []; |
| 305: | foreach ($this->component->components($name) as $component) { |
| 306: | $uid = $component->property('UID')?->value; |
| 307: | if ($uid !== null && $component->has('RECURRENCE-ID')) { |
| 308: | $overrides[$uid][] = $component; |
| 309: | } elseif ($uid !== null && !isset($masters[$uid])) { |
| 310: | $masters[$uid] = count($result); |
| 311: | $result[] = $component; |
| 312: | } else { |
| 313: | $result[] = $component; |
| 314: | } |
| 315: | } |
| 316: | |
| 317: | $items = []; |
| 318: | foreach ($result as $component) { |
| 319: | $uid = $component->property('UID')?->value; |
| 320: | $ownOverrides = $uid !== null && ($masters[$uid] ?? null) === count($items) ? $overrides[$uid] ?? [] : []; |
| 321: | unset($overrides[$uid ?? '']); |
| 322: | $items[] = new $class($component, $this, array_map(fn(Component $override): Item => new $class($override, $this), $ownOverrides)); |
| 323: | } |
| 324: | |
| 325: | foreach ($overrides as $orphans) { |
| 326: | foreach ($orphans as $component) { |
| 327: | $items[] = new $class($component, $this); |
| 328: | } |
| 329: | } |
| 330: | return $this->items[$name] = $items; |
| 331: | } |
| 332: | |
| 333: | |
| 334: | |
| 335: | |
| 336: | |
| 337: | private static function components(iterable $components, ?string $name): array { |
| 338: | $result = []; |
| 339: | foreach ($components as $component) { |
| 340: | $component = $component instanceof Item ? $component->component : $component; |
| 341: | if ($name !== null && $component->name !== $name) { |
| 342: | throw new InvalidArgumentException("Expected a $name component, $component->name given."); |
| 343: | } |
| 344: | if ($component->name === 'VCALENDAR') { |
| 345: | throw new InvalidArgumentException('A VCALENDAR cannot be a component of a calendar.'); |
| 346: | } |
| 347: | $result[] = $component; |
| 348: | } |
| 349: | return $result; |
| 350: | } |
| 351: | |
| 352: | private function text(string $name): ?string { |
| 353: | $property = $this->property($name); |
| 354: | return $property === null ? null : $this->values->text($property); |
| 355: | } |
| 356: | } |
| 357: | |