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: * VCALENDAR (RFC 5545, section 3.4) with typed access to its components.
21: *
22: * Components with the same UID form a series: events(), todos() and journals() return the
23: * recurring (or single) items, their overrides (RECURRENCE-ID) are available through
24: * Item::overrides() and are applied by the occurrence methods.
25: *
26: * @phpstan-import-type PropertyList from PropertyFactory
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: /** @var array<string, list<Item>> */
33: private array $items = [];
34:
35: /**
36: * @param ?DateTimeZone $floatingTimezone timezone of dates and floating times; X-WR-TIMEZONE when null
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: * A new calendar (VERSION 2.0 and PRODID) with the given properties and components.
50: *
51: * Calendar::create('-//example//team//EN', name: 'Team', events: [Event::new(summary: 'Standup', start: ...)])
52: *
53: * A VTIMEZONE is added for every IANA TZID used by the components (see VTimezoneBuilder::forComponents())
54: * unless $timezones is false or $components already define it. Components are written in the order:
55: * VTIMEZONE, events, tasks, journal entries, other components.
56: *
57: * @param ?string $name NAME (RFC 7986), also written as X-WR-CALNAME for older programs
58: * @param ?string $description DESCRIPTION (RFC 7986), also written as X-WR-CALDESC
59: * @param ?string $color COLOR (RFC 7986), a CSS3 color name
60: * @param ?string $method METHOD (RFC 5546), e.g. PUBLISH or REQUEST
61: * @param iterable<Event|Component> $events VEVENT components, see Event::new()
62: * @param iterable<Todo|Component> $todos VTODO components, see Todo::new()
63: * @param iterable<Journal|Component> $journals VJOURNAL components, see Journal::new()
64: * @param iterable<Item|Component> $components other components, e.g. VTIMEZONE or VFREEBUSY
65: * @param PropertyList $properties other properties, see Event::new()
66: * @param bool $timezones add a VTIMEZONE for every TZID used
67: * @throws InvalidArgumentException for an invalid value or a component of another type
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: * Write the calendar to a file.
110: *
111: * @throws RuntimeException when the file cannot be written
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: * NAME (RFC 7986) or X-WR-CALNAME.
142: */
143: public function name(): ?string {
144: return $this->text('NAME') ?? $this->text('X-WR-CALNAME');
145: }
146:
147: /**
148: * DESCRIPTION (RFC 7986) or X-WR-CALDESC.
149: */
150: public function description(): ?string {
151: return $this->text('DESCRIPTION') ?? $this->text('X-WR-CALDESC');
152: }
153:
154: /**
155: * COLOR (RFC 7986): a CSS3 color name.
156: */
157: public function color(): ?string {
158: return $this->text('COLOR');
159: }
160:
161: /**
162: * IMAGE properties (RFC 7986); images with invalid binary data are skipped.
163: *
164: * @return list<Image>
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: * SOURCE (RFC 7986): the URI the calendar data can be refreshed from.
172: */
173: public function source(): ?string {
174: $property = $this->property('SOURCE');
175: return $property === null ? null : $this->values->uri($property);
176: }
177:
178: /**
179: * REFRESH-INTERVAL (RFC 7986): the suggested minimum polling interval.
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: /** METHOD (RFC 5546), e.g. PUBLISH or REQUEST. */
195: public function method(): ?string {
196: $method = $this->text('METHOD');
197: return $method === null ? null : strtoupper($method);
198: }
199:
200: /**
201: * Timezone declared by X-WR-TIMEZONE.
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: * Timezone used for dates and floating times: the configured one or X-WR-TIMEZONE.
210: */
211: public function floatingTimezone(): ?DateTimeZone {
212: return $this->floatingTimezone ?? $this->timezone();
213: }
214:
215: /**
216: * @return list<Event>
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: * @return list<Todo>
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: * @return list<Journal>
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: * @return list<FreeBusy>
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: * @return list<TimezoneDefinition>
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: * Occurrences of all events overlapping [$from, $to), sorted by start.
252: *
253: * @return list<Occurrence>
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: // sort by the instant, dates and floating times in the floating timezone (or the one of $from)
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: * A copy with another component (event, task, VTIMEZONE, ...).
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: * Serialize to iCalendar data, see Serializer.
279: */
280: public function serialize(): string {
281: return Serializer::serialize($this->component);
282: }
283:
284: /**
285: * Typed item of a component (without overrides), null for other components.
286: *
287: * @internal
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: * Items of one component type grouped into series by UID.
296: *
297: * @return list<Item>
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: // overrides without a recurring item are single items
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: * @param iterable<Item|Component> $components
335: * @return list<Component>
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: