1: <?php
2: declare(strict_types=1);
3:
4: namespace om\ICal;
5:
6: use DateInterval;
7: use DateTimeImmutable;
8: use DateTimeInterface;
9: use DateTimeZone;
10: use InvalidArgumentException;
11: use om\ICal\Value\CalAddress;
12: use om\ICal\Value\DateTimeValue;
13: use om\ICal\Value\Duration;
14: use om\ICal\Value\PropertyFactory;
15:
16: /**
17: * VALARM (RFC 5545, section 3.6.6, with UID and ACKNOWLEDGED of RFC 9074).
18: *
19: * The trigger of the factories is a duration relative to the start of the event or task
20: * (e.g. "-PT15M", or to its end with related: 'END'), or an absolute time written in UTC.
21: * An alarm repeats $repeat more times after $duration; both are given or neither.
22: *
23: * @phpstan-import-type PropertyList from PropertyFactory
24: */
25: final class Alarm {
26: /**
27: * @internal use Item::alarms() or the factories
28: */
29: public function __construct(
30: public readonly Component $component,
31: private readonly Calendar $calendar,
32: ) {
33: }
34:
35: /**
36: * An alarm displaying the description.
37: *
38: * @param DateInterval|DateTimeInterface|DateTimeValue|string $trigger
39: * @param 'START'|'END'|'start'|'end' $related
40: * @param PropertyList $properties other properties, see Event::new()
41: * @throws InvalidArgumentException
42: */
43: public static function display(
44: string $description,
45: DateInterval|DateTimeInterface|DateTimeValue|string $trigger,
46: string $related = 'START',
47: ?int $repeat = null,
48: DateInterval|string|null $duration = null,
49: ?string $uid = null,
50: array $properties = [],
51: ): self {
52: return self::build('DISPLAY', $trigger, $related, $repeat, $duration, $uid, $properties, static fn(ComponentBuilder $builder) => $builder->text('DESCRIPTION', $description));
53: }
54:
55: /**
56: * An alarm playing a sound, the default one of the program when $sound (a URI) is null.
57: *
58: * @param DateInterval|DateTimeInterface|DateTimeValue|string $trigger
59: * @param 'START'|'END'|'start'|'end' $related
60: * @param PropertyList $properties
61: * @throws InvalidArgumentException
62: */
63: public static function audio(
64: DateInterval|DateTimeInterface|DateTimeValue|string $trigger,
65: ?string $sound = null,
66: string $related = 'START',
67: ?int $repeat = null,
68: DateInterval|string|null $duration = null,
69: ?string $uid = null,
70: array $properties = [],
71: ): self {
72: return self::build('AUDIO', $trigger, $related, $repeat, $duration, $uid, $properties, static fn(ComponentBuilder $builder) => $builder->uri('ATTACH', $sound));
73: }
74:
75: /**
76: * An alarm sending an e-mail with the summary as the subject and the description as the body
77: * to at least one attendee.
78: *
79: * @param iterable<CalAddress|string> $attendees
80: * @param DateInterval|DateTimeInterface|DateTimeValue|string $trigger
81: * @param iterable<string> $attachments URIs
82: * @param 'START'|'END'|'start'|'end' $related
83: * @param PropertyList $properties
84: * @throws InvalidArgumentException
85: */
86: public static function email(
87: string $summary,
88: string $description,
89: iterable $attendees,
90: DateInterval|DateTimeInterface|DateTimeValue|string $trigger,
91: iterable $attachments = [],
92: string $related = 'START',
93: ?int $repeat = null,
94: DateInterval|string|null $duration = null,
95: ?string $uid = null,
96: array $properties = [],
97: ): self {
98: return self::build('EMAIL', $trigger, $related, $repeat, $duration, $uid, $properties, static function (ComponentBuilder $builder) use ($summary, $description, $attendees, $attachments): void {
99: $builder->text('DESCRIPTION', $description)->text('SUMMARY', $summary);
100: $count = 0;
101: foreach ($attendees as $attendee) {
102: $builder->add(PropertyFactory::calAddress('ATTENDEE', $attendee));
103: $count++;
104: }
105: if ($count === 0) {
106: throw new InvalidArgumentException('An EMAIL alarm requires at least one attendee.');
107: }
108: foreach ($attachments as $attachment) {
109: $builder->uri('ATTACH', $attachment);
110: }
111: });
112: }
113:
114: public function property(string $name): ?Property {
115: return $this->component->property($name);
116: }
117:
118: /**
119: * UID of the alarm (RFC 9074, section 4).
120: */
121: public function uid(): ?string {
122: $property = $this->property('UID');
123: return $property === null ? null : $this->calendar->values()->text($property);
124: }
125:
126: /**
127: * ACTION: AUDIO, DISPLAY or EMAIL.
128: */
129: public function action(): ?string {
130: $action = $this->property('ACTION')?->value;
131: return $action === null ? null : strtoupper(trim($action));
132: }
133:
134: /**
135: * TRIGGER: a duration relative to the start (or end, see related()), or an absolute time.
136: */
137: public function trigger(): DateInterval|DateTimeValue|null {
138: $property = $this->property('TRIGGER');
139: if ($property === null) {
140: return null;
141: }
142: $value = $this->calendar->values()->value($property);
143: return $value instanceof DateInterval || $value instanceof DateTimeValue ? $value : null;
144: }
145:
146: /**
147: * RELATED parameter of a relative trigger: START (default) or END.
148: */
149: public function related(): string {
150: return strtoupper($this->property('TRIGGER')?->parameter('RELATED') ?? 'START');
151: }
152:
153: /**
154: * ACKNOWLEDGED (RFC 9074, section 6): when the alarm was last acknowledged (or sent), in UTC.
155: */
156: public function acknowledged(): ?DateTimeValue {
157: $property = $this->property('ACKNOWLEDGED');
158: return $property === null ? null : $this->calendar->values()->dateTime($property);
159: }
160:
161: /**
162: * When the alarm of the occurrence goes off.
163: */
164: public function triggerTime(Occurrence $occurrence, ?DateTimeZone $timezone = null): ?DateTimeImmutable {
165: $trigger = $this->trigger();
166: if ($trigger instanceof DateTimeValue) {
167: return $trigger->toDateTime($timezone, $this->calendar->floatingTimezone());
168: }
169: if ($trigger === null) {
170: return null;
171: }
172: $base = $this->related() === 'END' ? $occurrence->endTime($timezone) : $occurrence->startTime($timezone);
173: return $base->add($trigger);
174: }
175:
176: public function description(): ?string {
177: $property = $this->property('DESCRIPTION');
178: return $property === null ? null : $this->calendar->values()->text($property);
179: }
180:
181: public function summary(): ?string {
182: $property = $this->property('SUMMARY');
183: return $property === null ? null : $this->calendar->values()->text($property);
184: }
185:
186: /** REPEAT: number of additional repetitions. */
187: public function repeat(): int {
188: $property = $this->property('REPEAT');
189: return ($property === null ? null : $this->calendar->values()->integer($property)) ?? 0;
190: }
191:
192: /** DURATION: delay between repetitions. */
193: public function duration(): ?DateInterval {
194: $property = $this->property('DURATION');
195: return $property === null ? null : $this->calendar->values()->duration($property);
196: }
197:
198: /**
199: * @return list<CalAddress>
200: */
201: public function attendees(): array {
202: return array_map(fn(Property $property): CalAddress => $this->calendar->values()->calAddress($property), $this->component->properties('ATTENDEE'));
203: }
204:
205: /**
206: * @param PropertyList $properties
207: * @param callable(ComponentBuilder): mixed $content
208: */
209: private static function build(
210: string $action,
211: DateInterval|DateTimeInterface|DateTimeValue|string $trigger,
212: string $related,
213: ?int $repeat,
214: DateInterval|string|null $duration,
215: ?string $uid,
216: array $properties,
217: callable $content,
218: ): self {
219: $builder = (new ComponentBuilder('VALARM'))->text('UID', $uid === null ? null : ComponentBuilder::uid($uid));
220: $builder->add(Property::create('ACTION', $action), self::triggerProperty($trigger, strtoupper($related)));
221: $content($builder);
222: if (($repeat === null) !== ($duration === null)) {
223: throw new InvalidArgumentException('REPEAT and DURATION of an alarm must be given together.');
224: }
225: if ($repeat !== null && $duration !== null) {
226: $builder->integer('REPEAT', $repeat, 1, PHP_INT_MAX);
227: $delay = PropertyFactory::duration($duration);
228: if (Duration::parse($delay)?->invert) {
229: throw new InvalidArgumentException("DURATION between repetitions must not be negative, $delay given.");
230: }
231: $builder->add(Property::create('DURATION', $delay));
232: }
233: return new self($builder->build($properties), new Calendar());
234: }
235:
236: private static function triggerProperty(DateInterval|DateTimeInterface|DateTimeValue|string $trigger, string $related): Property {
237: if ($related !== 'START' && $related !== 'END') {
238: throw new InvalidArgumentException("RELATED must be START or END, $related given.");
239: }
240: if ($trigger instanceof DateInterval || (is_string($trigger) && Duration::parse($trigger) !== null)) {
241: return Property::create('TRIGGER', PropertyFactory::duration($trigger), $related === 'END' ? ['RELATED' => 'END'] : []);
242: }
243: if ($related === 'END') {
244: throw new InvalidArgumentException('RELATED=END is allowed only for a relative trigger.');
245: }
246: if (is_string($trigger) && !preg_match('/^\d{8}T\d{6}Z$/Di', trim($trigger))) {
247: throw new InvalidArgumentException("TRIGGER must be a duration or a UTC time, $trigger given.");
248: }
249: return Property::create('TRIGGER', (string) PropertyFactory::utcValue($trigger, 'TRIGGER'), ['VALUE' => 'DATE-TIME']);
250: }
251: }
252: