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: * Common part of events, tasks, journal entries and free/busy components.
22: *
23: * All properties stay available through property(); getters return typed values.
24: * A recurring item knows its overrides (components with the same UID and a RECURRENCE-ID),
25: * so occurrencesBetween() returns the complete series.
26: */
27: abstract class Item {
28: /** @var array<string, mixed> typed values already converted (the component is immutable) */
29: private array $memo = [];
30: /**
31: * @param list<Item> $overrides
32: * @internal use Calendar::events() and similar methods
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: * Duration of one occurrence.
43: */
44: abstract public function duration(): DateInterval;
45:
46: /**
47: * End of the (first) occurrence.
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: * @return list<Property>
59: */
60: public function properties(string $name): array {
61: return $this->component->properties($name);
62: }
63:
64: /**
65: * Typed value of the first property with the name, see ValueParser::value().
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: * LOCATION text; see locations() for VLOCATION components.
90: */
91: public function location(): ?string {
92: return $this->text('LOCATION');
93: }
94:
95: /**
96: * VLOCATION components (RFC 9073).
97: *
98: * @return list<Location>
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: * STATUS in upper case, e.g. CONFIRMED, TENTATIVE, CANCELLED, NEEDS-ACTION, COMPLETED.
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: * CLASS: PUBLIC (default), PRIVATE or CONFIDENTIAL.
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: * @return list<string>
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: * COLOR (RFC 7986): a CSS3 color name.
148: */
149: public function color(): ?string {
150: return $this->text('COLOR');
151: }
152:
153: /**
154: * IMAGE properties (RFC 7986); images with invalid binary data are skipped.
155: *
156: * @return list<Image>
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: * CONFERENCE properties (RFC 7986).
164: *
165: * @return list<Conference>
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: * LINK properties (RFC 9253).
173: *
174: * @return list<Link>
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: * RELATED-TO properties with the relation types and GAP of RFC 9253.
182: *
183: * @return list<Relation>
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: * @return list<CalAddress>
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: * @return list<Alarm>
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: * All valid RRULE properties; several rules are combined (RFC 2445 allowed that).
234: *
235: * @return list<Rule>
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: * RDATE values.
249: *
250: * @return list<DateTimeValue>
251: */
252: public function recurrenceDates(): array {
253: return $this->dates('RDATE');
254: }
255:
256: /**
257: * EXDATE values.
258: *
259: * @return list<DateTimeValue>
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: * True for a component modifying an instance of a recurring item (it has a RECURRENCE-ID).
275: */
276: public function isOverride(): bool {
277: return $this->has('RECURRENCE-ID');
278: }
279:
280: /**
281: * RANGE=THISANDFUTURE: the override applies to its instance and all later ones.
282: */
283: public function isThisAndFuture(): bool {
284: return strtoupper($this->property('RECURRENCE-ID')?->parameter('RANGE') ?? '') === 'THISANDFUTURE';
285: }
286:
287: /**
288: * Components modifying instances of this recurring item.
289: *
290: * @return list<Item>
291: */
292: public function overrides(): array {
293: return $this->overrides;
294: }
295:
296: /**
297: * Occurrences overlapping [$from, $to), in chronological order.
298: *
299: * Dates and floating times are compared with the local time of $from and $to.
300: * Cancelled instances (STATUS:CANCELLED overrides) are skipped unless requested.
301: *
302: * @return Generator<int, Occurrence>
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: * The first occurrences, optionally from the given moment.
310: *
311: * @return Generator<int, Occurrence>
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: * @return Generator<int, Occurrence>
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: // a single item without overrides; with overrides it is a recurrence set of DTSTART alone
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: // overrides: single ones replace their instance, RANGE=THISANDFUTURE ones also change later instances
339: $singles = $replaced = $replacedDays = $ranges = [];
340: $earliest = 0; // the largest shift of a range to an earlier time, in seconds
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; // without DTSTART the instance keeps its time and length
348: if ($override->isThisAndFuture()) {
349: // the shift of the local time in the timezone of the series (RECURRENCE-ID may be in UTC)
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: // an instance may move into the window from its end (DST changes add at most a few hours)
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: // a rule of another calendar system (RFC 7529) is not expanded, the item keeps DTSTART and RDATE
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: // occurrences are buffered while a later instance may still move before them
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: * @template T
434: * @param callable(): T $calculate
435: * @return T
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: * A URI or an XML reference, otherwise a UID or text (LINK and RELATED-TO of RFC 9253).
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: * @return list<DateTimeValue>
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: * Duration from DTSTART to DTEND (or DUE): the exact elapsed time for UTC and zoned values,
484: * the wall-clock difference for dates and floating times (RFC 5545, section 3.8.5.3).
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: // hours, minutes and seconds are elapsed time, days would keep the local time instead
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: