Completed
Push — master ( 496404...ac4e82 )
by Daniel
26:01
created
apps/dav/lib/CalDAV/Schedule/IMipService.php 1 patch
Indentation   +1283 added lines, -1283 removed lines patch added patch discarded remove patch
@@ -29,1287 +29,1287 @@
 block discarded – undo
29 29
 
30 30
 class IMipService {
31 31
 
32
-	private IL10N $l10n;
33
-
34
-	/** @var string[] */
35
-	private const STRING_DIFF = [
36
-		'meeting_title' => 'SUMMARY',
37
-		'meeting_description' => 'DESCRIPTION',
38
-		'meeting_url' => 'URL',
39
-		'meeting_location' => 'LOCATION'
40
-	];
41
-
42
-	public function __construct(
43
-		private URLGenerator $urlGenerator,
44
-		private IConfig $config,
45
-		private IDBConnection $db,
46
-		private ISecureRandom $random,
47
-		private L10NFactory $l10nFactory,
48
-		private ITimeFactory $timeFactory,
49
-		private readonly IUserManager $userManager,
50
-	) {
51
-		$language = $this->l10nFactory->findGenericLanguage();
52
-		$locale = $this->l10nFactory->findLocale($language);
53
-		$this->l10n = $this->l10nFactory->get('dav', $language, $locale);
54
-	}
55
-
56
-	/**
57
-	 * @param string|null $senderName
58
-	 * @param string $default
59
-	 * @return string
60
-	 */
61
-	public function getFrom(?string $senderName, string $default): string {
62
-		if ($senderName === null) {
63
-			return $default;
64
-		}
65
-
66
-		return $this->l10n->t('%1$s via %2$s', [$senderName, $default]);
67
-	}
68
-
69
-	public static function readPropertyWithDefault(VEvent $vevent, string $property, string $default) {
70
-		if (isset($vevent->$property)) {
71
-			$value = $vevent->$property->getValue();
72
-			if (!empty($value)) {
73
-				return $value;
74
-			}
75
-		}
76
-		return $default;
77
-	}
78
-
79
-	private function generateDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
80
-		$strikethrough = "<span style='text-decoration: line-through'>%s</span><br />%s";
81
-		if (!isset($vevent->$property)) {
82
-			return $default;
83
-		}
84
-		$value = $vevent->$property->getValue();
85
-		$newstring = $value === null ? null : htmlspecialchars($value);
86
-		if (isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newstring) {
87
-			$oldstring = $oldVEvent->$property->getValue();
88
-			return sprintf($strikethrough, htmlspecialchars($oldstring), $newstring);
89
-		}
90
-		return $newstring;
91
-	}
92
-
93
-	/**
94
-	 * Like generateDiffString() but linkifies the property values if they are urls.
95
-	 */
96
-	private function generateLinkifiedDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
97
-		if (!isset($vevent->$property)) {
98
-			return $default;
99
-		}
100
-		/** @var string|null $newString */
101
-		$newString = htmlspecialchars($vevent->$property->getValue());
102
-		$oldString = isset($oldVEvent->$property) ? htmlspecialchars($oldVEvent->$property->getValue()) : null;
103
-		if ($oldString !== $newString) {
104
-			return sprintf(
105
-				"<span style='text-decoration: line-through'>%s</span><br />%s",
106
-				$this->linkify($oldString) ?? $oldString ?? '',
107
-				$this->linkify($newString) ?? $newString ?? ''
108
-			);
109
-		}
110
-		return $this->linkify($newString) ?? $newString;
111
-	}
112
-
113
-	/**
114
-	 * Convert a given url to a html link element or return null otherwise.
115
-	 */
116
-	private function linkify(?string $url): ?string {
117
-		if ($url === null) {
118
-			return null;
119
-		}
120
-		if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
121
-			return null;
122
-		}
123
-
124
-		return sprintf('<a href="%1$s">%1$s</a>', htmlspecialchars($url));
125
-	}
126
-
127
-	/**
128
-	 * @param VEvent $vEvent
129
-	 * @param VEvent|null $oldVEvent
130
-	 * @return array
131
-	 */
132
-	public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array {
133
-
134
-		// construct event reader
135
-		$eventReaderCurrent = new EventReader($vEvent);
136
-		$eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null;
137
-		$defaultVal = '';
138
-		$data = [];
139
-		$data['meeting_when'] = $this->generateWhenString($eventReaderCurrent);
140
-
141
-		foreach (self::STRING_DIFF as $key => $property) {
142
-			$data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
143
-		}
144
-
145
-		$data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal);
146
-
147
-		if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
148
-			$data['meeting_location_html'] = $locationHtml;
149
-		}
150
-
151
-		if (!empty($oldVEvent)) {
152
-			$oldMeetingWhen = $this->generateWhenString($eventReaderPrevious);
153
-			$data['meeting_title_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY', $data['meeting_title']);
154
-			$data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION', $data['meeting_description']);
155
-			$data['meeting_location_html'] = $this->generateLinkifiedDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']);
156
-
157
-			$oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal);
158
-			$data['meeting_url_html'] = !empty($oldUrl) && $oldUrl !== $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url'];
159
-
160
-			$data['meeting_when_html'] = $oldMeetingWhen !== $data['meeting_when'] ? sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", $oldMeetingWhen, $data['meeting_when']) : $data['meeting_when'];
161
-		}
162
-		// generate occurring next string
163
-		if ($eventReaderCurrent->recurs()) {
164
-			$data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent);
165
-		}
166
-		return $data;
167
-	}
168
-
169
-	/**
170
-	 * @param VEvent $vEvent
171
-	 * @return array
172
-	 */
173
-	public function buildReplyBodyData(VEvent $vEvent): array {
174
-		// construct event reader
175
-		$eventReader = new EventReader($vEvent);
176
-		$defaultVal = '';
177
-		$data = [];
178
-		$data['meeting_when'] = $this->generateWhenString($eventReader);
179
-
180
-		foreach (self::STRING_DIFF as $key => $property) {
181
-			$data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
182
-		}
183
-
184
-		if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
185
-			$data['meeting_location_html'] = $locationHtml;
186
-		}
187
-
188
-		$data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : '';
189
-
190
-		// generate occurring next string
191
-		if ($eventReader->recurs()) {
192
-			$data['meeting_occurring'] = $this->generateOccurringString($eventReader);
193
-		}
194
-
195
-		return $data;
196
-	}
197
-
198
-	/**
199
-	 * generates a when string based on if a event has an recurrence or not
200
-	 *
201
-	 * @since 30.0.0
202
-	 *
203
-	 * @param EventReader $er
204
-	 *
205
-	 * @return string
206
-	 */
207
-	public function generateWhenString(EventReader $er): string {
208
-		return match ($er->recurs()) {
209
-			true => $this->generateWhenStringRecurring($er),
210
-			false => $this->generateWhenStringSingular($er)
211
-		};
212
-	}
213
-
214
-	/**
215
-	 * generates a when string for a non recurring event
216
-	 *
217
-	 * @since 30.0.0
218
-	 *
219
-	 * @param EventReader $er
220
-	 *
221
-	 * @return string
222
-	 */
223
-	public function generateWhenStringSingular(EventReader $er): string {
224
-		// initialize
225
-		$startTime = null;
226
-		$endTime = null;
227
-		// calculate time difference from now to start of event
228
-		$occurring = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
229
-		// extract start date
230
-		$startDate = $this->l10n->l('date', $er->startDateTime(), ['width' => 'full']);
231
-		// time of the day
232
-		if (!$er->entireDay()) {
233
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
234
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
235
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
236
-		}
237
-		// generate localized when string
238
-		// TRANSLATORS
239
-		// Indicates when a calendar event will happen, shown on invitation emails
240
-		// Output produced in order:
241
-		// In 1 minute/hour/day/week/month/year on July 1, 2024 for the entire day
242
-		// In 1 minute/hour/day/week/month/year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
243
-		// In 2 minutes/hours/days/weeks/months/years on July 1, 2024 for the entire day
244
-		// In 2 minutes/hours/days/weeks/months/years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
245
-		return match ([$occurring['scale'], $endTime !== null]) {
246
-			['past', false] => $this->l10n->t(
247
-				'In the past on %1$s for the entire day',
248
-				[$startDate]
249
-			),
250
-			['minute', false] => $this->l10n->n(
251
-				'In %n minute on %1$s for the entire day',
252
-				'In %n minutes on %1$s for the entire day',
253
-				$occurring['interval'],
254
-				[$startDate]
255
-			),
256
-			['hour', false] => $this->l10n->n(
257
-				'In %n hour on %1$s for the entire day',
258
-				'In %n hours on %1$s for the entire day',
259
-				$occurring['interval'],
260
-				[$startDate]
261
-			),
262
-			['day', false] => $this->l10n->n(
263
-				'In %n day on %1$s for the entire day',
264
-				'In %n days on %1$s for the entire day',
265
-				$occurring['interval'],
266
-				[$startDate]
267
-			),
268
-			['week', false] => $this->l10n->n(
269
-				'In %n week on %1$s for the entire day',
270
-				'In %n weeks on %1$s for the entire day',
271
-				$occurring['interval'],
272
-				[$startDate]
273
-			),
274
-			['month', false] => $this->l10n->n(
275
-				'In %n month on %1$s for the entire day',
276
-				'In %n months on %1$s for the entire day',
277
-				$occurring['interval'],
278
-				[$startDate]
279
-			),
280
-			['year', false] => $this->l10n->n(
281
-				'In %n year on %1$s for the entire day',
282
-				'In %n years on %1$s for the entire day',
283
-				$occurring['interval'],
284
-				[$startDate]
285
-			),
286
-			['past', true] => $this->l10n->t(
287
-				'In the past on %1$s between %2$s - %3$s',
288
-				[$startDate, $startTime, $endTime]
289
-			),
290
-			['minute', true] => $this->l10n->n(
291
-				'In %n minute on %1$s between %2$s - %3$s',
292
-				'In %n minutes on %1$s between %2$s - %3$s',
293
-				$occurring['interval'],
294
-				[$startDate, $startTime, $endTime]
295
-			),
296
-			['hour', true] => $this->l10n->n(
297
-				'In %n hour on %1$s between %2$s - %3$s',
298
-				'In %n hours on %1$s between %2$s - %3$s',
299
-				$occurring['interval'],
300
-				[$startDate, $startTime, $endTime]
301
-			),
302
-			['day', true] => $this->l10n->n(
303
-				'In %n day on %1$s between %2$s - %3$s',
304
-				'In %n days on %1$s between %2$s - %3$s',
305
-				$occurring['interval'],
306
-				[$startDate, $startTime, $endTime]
307
-			),
308
-			['week', true] => $this->l10n->n(
309
-				'In %n week on %1$s between %2$s - %3$s',
310
-				'In %n weeks on %1$s between %2$s - %3$s',
311
-				$occurring['interval'],
312
-				[$startDate, $startTime, $endTime]
313
-			),
314
-			['month', true] => $this->l10n->n(
315
-				'In %n month on %1$s between %2$s - %3$s',
316
-				'In %n months on %1$s between %2$s - %3$s',
317
-				$occurring['interval'],
318
-				[$startDate, $startTime, $endTime]
319
-			),
320
-			['year', true] => $this->l10n->n(
321
-				'In %n year on %1$s between %2$s - %3$s',
322
-				'In %n years on %1$s between %2$s - %3$s',
323
-				$occurring['interval'],
324
-				[$startDate, $startTime, $endTime]
325
-			),
326
-			default => $this->l10n->t('Could not generate when statement')
327
-		};
328
-	}
329
-
330
-	/**
331
-	 * generates a when string based on recurrence precision/frequency
332
-	 *
333
-	 * @since 30.0.0
334
-	 *
335
-	 * @param EventReader $er
336
-	 *
337
-	 * @return string
338
-	 */
339
-	public function generateWhenStringRecurring(EventReader $er): string {
340
-		return match ($er->recurringPrecision()) {
341
-			'daily' => $this->generateWhenStringRecurringDaily($er),
342
-			'weekly' => $this->generateWhenStringRecurringWeekly($er),
343
-			'monthly' => $this->generateWhenStringRecurringMonthly($er),
344
-			'yearly' => $this->generateWhenStringRecurringYearly($er),
345
-			'fixed' => $this->generateWhenStringRecurringFixed($er),
346
-		};
347
-	}
348
-
349
-	/**
350
-	 * generates a when string for a daily precision/frequency
351
-	 *
352
-	 * @since 30.0.0
353
-	 *
354
-	 * @param EventReader $er
355
-	 *
356
-	 * @return string
357
-	 */
358
-	public function generateWhenStringRecurringDaily(EventReader $er): string {
359
-
360
-		// initialize
361
-		$interval = (int)$er->recurringInterval();
362
-		$startTime = null;
363
-		$conclusion = null;
364
-		// time of the day
365
-		if (!$er->entireDay()) {
366
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
367
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
368
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
369
-		}
370
-		// conclusion
371
-		if ($er->recurringConcludes()) {
372
-			$conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
373
-		}
374
-		// generate localized when string
375
-		// TRANSLATORS
376
-		// Indicates when a calendar event will happen, shown on invitation emails
377
-		// Output produced in order:
378
-		// Every Day for the entire day
379
-		// Every Day for the entire day until July 13, 2024
380
-		// Every Day between 8:00 AM - 9:00 AM (America/Toronto)
381
-		// Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
382
-		// Every 3 Days for the entire day
383
-		// Every 3 Days for the entire day until July 13, 2024
384
-		// Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto)
385
-		// Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
386
-		return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
387
-			[false, false, false] => $this->l10n->t('Every Day for the entire day'),
388
-			[false, false, true] => $this->l10n->t('Every Day for the entire day until %1$s', [$conclusion]),
389
-			[false, true, false] => $this->l10n->t('Every Day between %1$s - %2$s', [$startTime, $endTime]),
390
-			[false, true, true] => $this->l10n->t('Every Day between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
391
-			[true, false, false] => $this->l10n->t('Every %1$d Days for the entire day', [$interval]),
392
-			[true, false, true] => $this->l10n->t('Every %1$d Days for the entire day until %2$s', [$interval, $conclusion]),
393
-			[true, true, false] => $this->l10n->t('Every %1$d Days between %2$s - %3$s', [$interval, $startTime, $endTime]),
394
-			[true, true, true] => $this->l10n->t('Every %1$d Days between %2$s - %3$s until %4$s', [$interval, $startTime, $endTime, $conclusion]),
395
-			default => $this->l10n->t('Could not generate event recurrence statement')
396
-		};
397
-
398
-	}
399
-
400
-	/**
401
-	 * generates a when string for a weekly precision/frequency
402
-	 *
403
-	 * @since 30.0.0
404
-	 *
405
-	 * @param EventReader $er
406
-	 *
407
-	 * @return string
408
-	 */
409
-	public function generateWhenStringRecurringWeekly(EventReader $er): string {
410
-
411
-		// initialize
412
-		$interval = (int)$er->recurringInterval();
413
-		$startTime = null;
414
-		$conclusion = null;
415
-		// days of the week
416
-		$days = implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
417
-		// time of the day
418
-		if (!$er->entireDay()) {
419
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
420
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
421
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
422
-		}
423
-		// conclusion
424
-		if ($er->recurringConcludes()) {
425
-			$conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
426
-		}
427
-		// generate localized when string
428
-		// TRANSLATORS
429
-		// Indicates when a calendar event will happen, shown on invitation emails
430
-		// Output produced in order:
431
-		// Every Week on Monday, Wednesday, Friday for the entire day
432
-		// Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024
433
-		// Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
434
-		// Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
435
-		// Every 2 Weeks on Monday, Wednesday, Friday for the entire day
436
-		// Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024
437
-		// Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
438
-		// Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
439
-		return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
440
-			[false, false, false] => $this->l10n->t('Every Week on %1$s for the entire day', [$days]),
441
-			[false, false, true] => $this->l10n->t('Every Week on %1$s for the entire day until %2$s', [$days, $conclusion]),
442
-			[false, true, false] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
443
-			[false, true, true] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
444
-			[true, false, false] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day', [$interval, $days]),
445
-			[true, false, true] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
446
-			[true, true, false] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
447
-			[true, true, true] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]),
448
-			default => $this->l10n->t('Could not generate event recurrence statement')
449
-		};
450
-
451
-	}
452
-
453
-	/**
454
-	 * generates a when string for a monthly precision/frequency
455
-	 *
456
-	 * @since 30.0.0
457
-	 *
458
-	 * @param EventReader $er
459
-	 *
460
-	 * @return string
461
-	 */
462
-	public function generateWhenStringRecurringMonthly(EventReader $er): string {
463
-
464
-		// initialize
465
-		$interval = (int)$er->recurringInterval();
466
-		$startTime = null;
467
-		$conclusion = null;
468
-		// days of month
469
-		if ($er->recurringPattern() === 'R') {
470
-			$days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
471
-					. implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
472
-		} else {
473
-			$days = implode(', ', $er->recurringDaysOfMonth());
474
-		}
475
-		// time of the day
476
-		if (!$er->entireDay()) {
477
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
478
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
479
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
480
-		}
481
-		// conclusion
482
-		if ($er->recurringConcludes()) {
483
-			$conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
484
-		}
485
-		// generate localized when string
486
-		// TRANSLATORS
487
-		// Indicates when a calendar event will happen, shown on invitation emails
488
-		// Output produced in order, output varies depending on if the event is absolute or releative:
489
-		// Absolute: Every Month on the 1, 8 for the entire day
490
-		// Relative: Every Month on the First Sunday, Saturday for the entire day
491
-		// Absolute: Every Month on the 1, 8 for the entire day until December 31, 2024
492
-		// Relative: Every Month on the First Sunday, Saturday for the entire day until December 31, 2024
493
-		// Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
494
-		// Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
495
-		// Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
496
-		// Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
497
-		// Absolute: Every 2 Months on the 1, 8 for the entire day
498
-		// Relative: Every 2 Months on the First Sunday, Saturday for the entire day
499
-		// Absolute: Every 2 Months on the 1, 8 for the entire day until December 31, 2024
500
-		// Relative: Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024
501
-		// Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
502
-		// Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
503
-		// Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
504
-		// Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
505
-		return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
506
-			[false, false, false] => $this->l10n->t('Every Month on the %1$s for the entire day', [$days]),
507
-			[false, false, true] => $this->l10n->t('Every Month on the %1$s for the entire day until %2$s', [$days, $conclusion]),
508
-			[false, true, false] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
509
-			[false, true, true] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
510
-			[true, false, false] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day', [$interval, $days]),
511
-			[true, false, true] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
512
-			[true, true, false] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
513
-			[true, true, true] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]),
514
-			default => $this->l10n->t('Could not generate event recurrence statement')
515
-		};
516
-	}
517
-
518
-	/**
519
-	 * generates a when string for a yearly precision/frequency
520
-	 *
521
-	 * @since 30.0.0
522
-	 *
523
-	 * @param EventReader $er
524
-	 *
525
-	 * @return string
526
-	 */
527
-	public function generateWhenStringRecurringYearly(EventReader $er): string {
528
-
529
-		// initialize
530
-		$interval = (int)$er->recurringInterval();
531
-		$startTime = null;
532
-		$conclusion = null;
533
-		// months of year
534
-		$months = implode(', ', array_map(function ($value) { return $this->localizeMonthName($value); }, $er->recurringMonthsOfYearNamed()));
535
-		// days of month
536
-		if ($er->recurringPattern() === 'R') {
537
-			$days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
538
-					. implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
539
-		} else {
540
-			$days = $er->startDateTime()->format('jS');
541
-		}
542
-		// time of the day
543
-		if (!$er->entireDay()) {
544
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
545
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
546
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
547
-		}
548
-		// conclusion
549
-		if ($er->recurringConcludes()) {
550
-			$conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
551
-		}
552
-		// generate localized when string
553
-		// TRANSLATORS
554
-		// Indicates when a calendar event will happen, shown on invitation emails
555
-		// Output produced in order, output varies depending on if the event is absolute or releative:
556
-		// Absolute: Every Year in July on the 1st for the entire day
557
-		// Relative: Every Year in July on the First Sunday, Saturday for the entire day
558
-		// Absolute: Every Year in July on the 1st for the entire day until July 31, 2026
559
-		// Relative: Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026
560
-		// Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
561
-		// Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
562
-		// Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
563
-		// Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
564
-		// Absolute: Every 2 Years in July on the 1st for the entire day
565
-		// Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day
566
-		// Absolute: Every 2 Years in July on the 1st for the entire day until July 31, 2026
567
-		// Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026
568
-		// Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
569
-		// Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
570
-		// Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
571
-		// Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
572
-		return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
573
-			[false, false, false] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day', [$months, $days]),
574
-			[false, false, true] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day until %3$s', [$months, $days, $conclusion]),
575
-			[false, true, false] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s', [$months, $days, $startTime, $endTime]),
576
-			[false, true, true] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s until %5$s', [$months, $days, $startTime, $endTime, $conclusion]),
577
-			[true, false, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day', [$interval, $months, $days]),
578
-			[true, false, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day until %4$s', [$interval, $months,  $days, $conclusion]),
579
-			[true, true, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s', [$interval, $months, $days, $startTime, $endTime]),
580
-			[true, true, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s until %6$s', [$interval, $months, $days, $startTime, $endTime, $conclusion]),
581
-			default => $this->l10n->t('Could not generate event recurrence statement')
582
-		};
583
-	}
584
-
585
-	/**
586
-	 * generates a when string for a fixed precision/frequency
587
-	 *
588
-	 * @since 30.0.0
589
-	 *
590
-	 * @param EventReader $er
591
-	 *
592
-	 * @return string
593
-	 */
594
-	public function generateWhenStringRecurringFixed(EventReader $er): string {
595
-		// initialize
596
-		$startTime = null;
597
-		$conclusion = null;
598
-		// time of the day
599
-		if (!$er->entireDay()) {
600
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
601
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
602
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
603
-		}
604
-		// conclusion
605
-		$conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
606
-		// generate localized when string
607
-		// TRANSLATORS
608
-		// Indicates when a calendar event will happen, shown on invitation emails
609
-		// Output produced in order:
610
-		// On specific dates for the entire day until July 13, 2024
611
-		// On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
612
-		return match ($startTime !== null) {
613
-			false => $this->l10n->t('On specific dates for the entire day until %1$s', [$conclusion]),
614
-			true => $this->l10n->t('On specific dates between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
615
-		};
616
-	}
617
-
618
-	/**
619
-	 * generates a occurring next string for a recurring event
620
-	 *
621
-	 * @since 30.0.0
622
-	 *
623
-	 * @param EventReader $er
624
-	 *
625
-	 * @return string
626
-	 */
627
-	public function generateOccurringString(EventReader $er): string {
628
-
629
-		// initialize
630
-		$occurrence = null;
631
-		$occurrence2 = null;
632
-		$occurrence3 = null;
633
-		// reset to initial occurrence
634
-		$er->recurrenceRewind();
635
-		// forward to current date
636
-		$er->recurrenceAdvanceTo($this->timeFactory->getDateTime());
637
-		// calculate time difference from now to start of next event occurrence and minimize it
638
-		$occurrenceIn = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
639
-		// store next occurrence value
640
-		$occurrence = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
641
-		// forward one occurrence
642
-		$er->recurrenceAdvance();
643
-		// evaluate if occurrence is valid
644
-		if ($er->recurrenceDate() !== null) {
645
-			// store following occurrence value
646
-			$occurrence2 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
647
-			// forward one occurrence
648
-			$er->recurrenceAdvance();
649
-			// evaluate if occurrence is valid
650
-			if ($er->recurrenceDate()) {
651
-				// store following occurrence value
652
-				$occurrence3 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
653
-			}
654
-		}
655
-		// generate localized when string
656
-		// TRANSLATORS
657
-		// Indicates when a calendar event will happen, shown on invitation emails
658
-		// Output produced in order:
659
-		// In 1 minute/hour/day/week/month/year on July 1, 2024
660
-		// In 1 minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024
661
-		// In 1 minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024 and July 5, 2024
662
-		// In 2 minutes/hours/days/weeks/months/years on July 1, 2024
663
-		// In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024
664
-		// In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024 and July 5, 2024
665
-		return match ([$occurrenceIn['scale'], $occurrence2 !== null, $occurrence3 !== null]) {
666
-			['past', false, false] => $this->l10n->t(
667
-				'In the past on %1$s',
668
-				[$occurrence]
669
-			),
670
-			['minute', false, false] => $this->l10n->n(
671
-				'In %n minute on %1$s',
672
-				'In %n minutes on %1$s',
673
-				$occurrenceIn['interval'],
674
-				[$occurrence]
675
-			),
676
-			['hour', false, false] => $this->l10n->n(
677
-				'In %n hour on %1$s',
678
-				'In %n hours on %1$s',
679
-				$occurrenceIn['interval'],
680
-				[$occurrence]
681
-			),
682
-			['day', false, false] => $this->l10n->n(
683
-				'In %n day on %1$s',
684
-				'In %n days on %1$s',
685
-				$occurrenceIn['interval'],
686
-				[$occurrence]
687
-			),
688
-			['week', false, false] => $this->l10n->n(
689
-				'In %n week on %1$s',
690
-				'In %n weeks on %1$s',
691
-				$occurrenceIn['interval'],
692
-				[$occurrence]
693
-			),
694
-			['month', false, false] => $this->l10n->n(
695
-				'In %n month on %1$s',
696
-				'In %n months on %1$s',
697
-				$occurrenceIn['interval'],
698
-				[$occurrence]
699
-			),
700
-			['year', false, false] => $this->l10n->n(
701
-				'In %n year on %1$s',
702
-				'In %n years on %1$s',
703
-				$occurrenceIn['interval'],
704
-				[$occurrence]
705
-			),
706
-			['past', true, false] => $this->l10n->t(
707
-				'In the past on %1$s then on %2$s',
708
-				[$occurrence, $occurrence2]
709
-			),
710
-			['minute', true, false] => $this->l10n->n(
711
-				'In %n minute on %1$s then on %2$s',
712
-				'In %n minutes on %1$s then on %2$s',
713
-				$occurrenceIn['interval'],
714
-				[$occurrence, $occurrence2]
715
-			),
716
-			['hour', true, false] => $this->l10n->n(
717
-				'In %n hour on %1$s then on %2$s',
718
-				'In %n hours on %1$s then on %2$s',
719
-				$occurrenceIn['interval'],
720
-				[$occurrence, $occurrence2]
721
-			),
722
-			['day', true, false] => $this->l10n->n(
723
-				'In %n day on %1$s then on %2$s',
724
-				'In %n days on %1$s then on %2$s',
725
-				$occurrenceIn['interval'],
726
-				[$occurrence, $occurrence2]
727
-			),
728
-			['week', true, false] => $this->l10n->n(
729
-				'In %n week on %1$s then on %2$s',
730
-				'In %n weeks on %1$s then on %2$s',
731
-				$occurrenceIn['interval'],
732
-				[$occurrence, $occurrence2]
733
-			),
734
-			['month', true, false] => $this->l10n->n(
735
-				'In %n month on %1$s then on %2$s',
736
-				'In %n months on %1$s then on %2$s',
737
-				$occurrenceIn['interval'],
738
-				[$occurrence, $occurrence2]
739
-			),
740
-			['year', true, false] => $this->l10n->n(
741
-				'In %n year on %1$s then on %2$s',
742
-				'In %n years on %1$s then on %2$s',
743
-				$occurrenceIn['interval'],
744
-				[$occurrence, $occurrence2]
745
-			),
746
-			['past', true, true] => $this->l10n->t(
747
-				'In the past on %1$s then on %2$s and %3$s',
748
-				[$occurrence, $occurrence2, $occurrence3]
749
-			),
750
-			['minute', true, true] => $this->l10n->n(
751
-				'In %n minute on %1$s then on %2$s and %3$s',
752
-				'In %n minutes on %1$s then on %2$s and %3$s',
753
-				$occurrenceIn['interval'],
754
-				[$occurrence, $occurrence2, $occurrence3]
755
-			),
756
-			['hour', true, true] => $this->l10n->n(
757
-				'In %n hour on %1$s then on %2$s and %3$s',
758
-				'In %n hours on %1$s then on %2$s and %3$s',
759
-				$occurrenceIn['interval'],
760
-				[$occurrence, $occurrence2, $occurrence3]
761
-			),
762
-			['day', true, true] => $this->l10n->n(
763
-				'In %n day on %1$s then on %2$s and %3$s',
764
-				'In %n days on %1$s then on %2$s and %3$s',
765
-				$occurrenceIn['interval'],
766
-				[$occurrence, $occurrence2, $occurrence3]
767
-			),
768
-			['week', true, true] => $this->l10n->n(
769
-				'In %n week on %1$s then on %2$s and %3$s',
770
-				'In %n weeks on %1$s then on %2$s and %3$s',
771
-				$occurrenceIn['interval'],
772
-				[$occurrence, $occurrence2, $occurrence3]
773
-			),
774
-			['month', true, true] => $this->l10n->n(
775
-				'In %n month on %1$s then on %2$s and %3$s',
776
-				'In %n months on %1$s then on %2$s and %3$s',
777
-				$occurrenceIn['interval'],
778
-				[$occurrence, $occurrence2, $occurrence3]
779
-			),
780
-			['year', true, true] => $this->l10n->n(
781
-				'In %n year on %1$s then on %2$s and %3$s',
782
-				'In %n years on %1$s then on %2$s and %3$s',
783
-				$occurrenceIn['interval'],
784
-				[$occurrence, $occurrence2, $occurrence3]
785
-			),
786
-			default => $this->l10n->t('Could not generate next recurrence statement')
787
-		};
788
-
789
-	}
790
-
791
-	/**
792
-	 * @param VEvent $vEvent
793
-	 * @return array
794
-	 */
795
-	public function buildCancelledBodyData(VEvent $vEvent): array {
796
-		// construct event reader
797
-		$eventReaderCurrent = new EventReader($vEvent);
798
-		$defaultVal = '';
799
-		$strikethrough = "<span style='text-decoration: line-through'>%s</span>";
800
-
801
-		$newMeetingWhen = $this->generateWhenString($eventReaderCurrent);
802
-		$newSummary = htmlspecialchars(isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event'));
803
-		$newDescription = htmlspecialchars(isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal);
804
-		$newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? sprintf('<a href="%1$s">%1$s</a>', $vEvent->URL) : $defaultVal;
805
-		$newLocation = htmlspecialchars(isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal);
806
-		$newLocationHtml = $this->linkify($newLocation) ?? $newLocation;
807
-
808
-		$data = [];
809
-		$data['meeting_when_html'] = $newMeetingWhen === '' ?: sprintf($strikethrough, $newMeetingWhen);
810
-		$data['meeting_when'] = $newMeetingWhen;
811
-		$data['meeting_title_html'] = sprintf($strikethrough, $newSummary);
812
-		$data['meeting_title'] = $newSummary !== '' ? $newSummary: $this->l10n->t('Untitled event');
813
-		$data['meeting_description_html'] = $newDescription !== '' ? sprintf($strikethrough, $newDescription) : '';
814
-		$data['meeting_description'] = $newDescription;
815
-		$data['meeting_url_html'] = $newUrl !== '' ? sprintf($strikethrough, $newUrl) : '';
816
-		$data['meeting_url'] = isset($vEvent->URL) ? (string)$vEvent->URL : '';
817
-		$data['meeting_location_html'] = $newLocationHtml !== '' ? sprintf($strikethrough, $newLocationHtml) : '';
818
-		$data['meeting_location'] = $newLocation;
819
-		return $data;
820
-	}
821
-
822
-	/**
823
-	 * Check if event took place in the past
824
-	 *
825
-	 * @param VCalendar $vObject
826
-	 * @return int
827
-	 */
828
-	public function getLastOccurrence(VCalendar $vObject) {
829
-		/** @var VEvent $component */
830
-		$component = $vObject->VEVENT;
831
-
832
-		if (isset($component->RRULE)) {
833
-			$it = new EventIterator($vObject, (string)$component->UID);
834
-			$maxDate = new \DateTime(IMipPlugin::MAX_DATE);
835
-			if ($it->isInfinite()) {
836
-				return $maxDate->getTimestamp();
837
-			}
838
-
839
-			$end = $it->getDtEnd();
840
-			while ($it->valid() && $end < $maxDate) {
841
-				$end = $it->getDtEnd();
842
-				$it->next();
843
-			}
844
-			return $end->getTimestamp();
845
-		}
846
-
847
-		/** @var Property\ICalendar\DateTime $dtStart */
848
-		$dtStart = $component->DTSTART;
849
-
850
-		if (isset($component->DTEND)) {
851
-			/** @var Property\ICalendar\DateTime $dtEnd */
852
-			$dtEnd = $component->DTEND;
853
-			return $dtEnd->getDateTime()->getTimeStamp();
854
-		}
855
-
856
-		if (isset($component->DURATION)) {
857
-			/** @var \DateTime $endDate */
858
-			$endDate = clone $dtStart->getDateTime();
859
-			// $component->DTEND->getDateTime() returns DateTimeImmutable
860
-			$endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue()));
861
-			return $endDate->getTimestamp();
862
-		}
863
-
864
-		if (!$dtStart->hasTime()) {
865
-			/** @var \DateTime $endDate */
866
-			// $component->DTSTART->getDateTime() returns DateTimeImmutable
867
-			$endDate = clone $dtStart->getDateTime();
868
-			$endDate = $endDate->modify('+1 day');
869
-			return $endDate->getTimestamp();
870
-		}
871
-
872
-		// No computation of end time possible - return start date
873
-		return $dtStart->getDateTime()->getTimeStamp();
874
-	}
875
-
876
-	/**
877
-	 * @param Property $attendee
878
-	 */
879
-	public function setL10nFromAttendee(Property $attendee) {
880
-		$language = null;
881
-		$locale = null;
882
-		// check if the attendee is a system user
883
-		$userAddress = $attendee->getValue();
884
-		if (str_starts_with($userAddress, 'mailto:')) {
885
-			$userAddress = substr($userAddress, 7);
886
-		}
887
-		$users = $this->userManager->getByEmail($userAddress);
888
-		if ($users !== []) {
889
-			$user = array_shift($users);
890
-			$language = $this->config->getUserValue($user->getUID(), 'core', 'lang', null);
891
-			$locale = $this->config->getUserValue($user->getUID(), 'core', 'locale', null);
892
-		}
893
-		// fallback to attendee LANGUAGE parameter if language not set
894
-		if ($language === null && isset($attendee['LANGUAGE']) && $attendee['LANGUAGE'] instanceof Parameter) {
895
-			$language = $attendee['LANGUAGE']->getValue();
896
-		}
897
-		// fallback to system language if language not set
898
-		if ($language === null) {
899
-			$language = $this->l10nFactory->findGenericLanguage();
900
-		}
901
-		// fallback to system locale if locale not set
902
-		if ($locale === null) {
903
-			$locale = $this->l10nFactory->findLocale($language);
904
-		}
905
-		$this->l10n = $this->l10nFactory->get('dav', $language, $locale);
906
-	}
907
-
908
-	/**
909
-	 * @param Property|null $attendee
910
-	 * @return bool
911
-	 */
912
-	public function getAttendeeRsvpOrReqForParticipant(?Property $attendee = null) {
913
-		if ($attendee === null) {
914
-			return false;
915
-		}
916
-
917
-		$rsvp = $attendee->offsetGet('RSVP');
918
-		if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) {
919
-			return true;
920
-		}
921
-		$role = $attendee->offsetGet('ROLE');
922
-		// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16
923
-		// Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set
924
-		if ($role === null
925
-			|| (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0))
926
-			|| (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0))
927
-		) {
928
-			return true;
929
-		}
930
-
931
-		// RFC 5545 3.2.17: default RSVP is false
932
-		return false;
933
-	}
934
-
935
-	/**
936
-	 * @param IEMailTemplate $template
937
-	 * @param string $method
938
-	 * @param string $sender
939
-	 * @param string $summary
940
-	 * @param string|null $partstat
941
-	 * @param bool $isModified
942
-	 */
943
-	public function addSubjectAndHeading(IEMailTemplate $template,
944
-		string $method, string $sender, string $summary, bool $isModified, ?Property $replyingAttendee = null): void {
945
-		if ($method === IMipPlugin::METHOD_CANCEL) {
946
-			// TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}"
947
-			$template->setSubject($this->l10n->t('Cancelled: %1$s', [$summary]));
948
-			$template->addHeading($this->l10n->t('"%1$s" has been canceled', [$summary]));
949
-		} elseif ($method === IMipPlugin::METHOD_REPLY) {
950
-			// TRANSLATORS Subject for email, when an invitation is replied to. Ex: "Re: {{Event Name}}"
951
-			$template->setSubject($this->l10n->t('Re: %1$s', [$summary]));
952
-			// Build the strings
953
-			$partstat = (isset($replyingAttendee)) ? $replyingAttendee->offsetGet('PARTSTAT') : null;
954
-			$partstat = ($partstat instanceof Parameter) ? $partstat->getValue() : null;
955
-			switch ($partstat) {
956
-				case 'ACCEPTED':
957
-					$template->addHeading($this->l10n->t('%1$s has accepted your invitation', [$sender]));
958
-					break;
959
-				case 'TENTATIVE':
960
-					$template->addHeading($this->l10n->t('%1$s has tentatively accepted your invitation', [$sender]));
961
-					break;
962
-				case 'DECLINED':
963
-					$template->addHeading($this->l10n->t('%1$s has declined your invitation', [$sender]));
964
-					break;
965
-				case null:
966
-				default:
967
-					$template->addHeading($this->l10n->t('%1$s has responded to your invitation', [$sender]));
968
-					break;
969
-			}
970
-		} elseif ($method === IMipPlugin::METHOD_REQUEST && $isModified) {
971
-			// TRANSLATORS Subject for email, when an invitation is updated. Ex: "Invitation updated: {{Event Name}}"
972
-			$template->setSubject($this->l10n->t('Invitation updated: %1$s', [$summary]));
973
-			$template->addHeading($this->l10n->t('%1$s updated the event "%2$s"', [$sender, $summary]));
974
-		} else {
975
-			// TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}"
976
-			$template->setSubject($this->l10n->t('Invitation: %1$s', [$summary]));
977
-			$template->addHeading($this->l10n->t('%1$s would like to invite you to "%2$s"', [$sender, $summary]));
978
-		}
979
-	}
980
-
981
-	/**
982
-	 * @param string $path
983
-	 * @return string
984
-	 */
985
-	public function getAbsoluteImagePath($path): string {
986
-		return $this->urlGenerator->getAbsoluteURL(
987
-			$this->urlGenerator->imagePath('core', $path)
988
-		);
989
-	}
990
-
991
-	/**
992
-	 * addAttendees: add organizer and attendee names/emails to iMip mail.
993
-	 *
994
-	 * Enable with DAV setting: invitation_list_attendees (default: no)
995
-	 *
996
-	 * The default is 'no', which matches old behavior, and is privacy preserving.
997
-	 *
998
-	 * To enable including attendees in invitation emails:
999
-	 *   % php occ config:app:set dav invitation_list_attendees --value yes
1000
-	 *
1001
-	 * @param IEMailTemplate $template
1002
-	 * @param IL10N $this->l10n
1003
-	 * @param VEvent $vevent
1004
-	 * @author brad2014 on github.com
1005
-	 */
1006
-	public function addAttendees(IEMailTemplate $template, VEvent $vevent) {
1007
-		if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') {
1008
-			return;
1009
-		}
1010
-
1011
-		if (isset($vevent->ORGANIZER)) {
1012
-			/** @var Property | Property\ICalendar\CalAddress $organizer */
1013
-			$organizer = $vevent->ORGANIZER;
1014
-			$organizerEmail = substr($organizer->getNormalizedValue(), 7);
1015
-			/** @var string|null $organizerName */
1016
-			$organizerName = isset($organizer->CN) ? $organizer->CN->getValue() : null;
1017
-			$organizerHTML = sprintf('<a href="%s">%s</a>',
1018
-				htmlspecialchars($organizer->getNormalizedValue()),
1019
-				htmlspecialchars($organizerName ?: $organizerEmail));
1020
-			$organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail);
1021
-			if (isset($organizer['PARTSTAT'])) {
1022
-				/** @var Parameter $partstat */
1023
-				$partstat = $organizer['PARTSTAT'];
1024
-				if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
1025
-					$organizerHTML .= ' ✔︎';
1026
-					$organizerText .= ' ✔︎';
1027
-				}
1028
-			}
1029
-			$template->addBodyListItem($organizerHTML, $this->l10n->t('Organizer:'),
1030
-				$this->getAbsoluteImagePath('caldav/organizer.png'),
1031
-				$organizerText, '', IMipPlugin::IMIP_INDENT);
1032
-		}
1033
-
1034
-		$attendees = $vevent->select('ATTENDEE');
1035
-		if (count($attendees) === 0) {
1036
-			return;
1037
-		}
1038
-
1039
-		$attendeesHTML = [];
1040
-		$attendeesText = [];
1041
-		foreach ($attendees as $attendee) {
1042
-			$attendeeEmail = substr($attendee->getNormalizedValue(), 7);
1043
-			$attendeeName = isset($attendee['CN']) ? $attendee['CN']->getValue() : null;
1044
-			$attendeeHTML = sprintf('<a href="%s">%s</a>',
1045
-				htmlspecialchars($attendee->getNormalizedValue()),
1046
-				htmlspecialchars($attendeeName ?: $attendeeEmail));
1047
-			$attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail);
1048
-			if (isset($attendee['PARTSTAT'])) {
1049
-				/** @var Parameter $partstat */
1050
-				$partstat = $attendee['PARTSTAT'];
1051
-				if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
1052
-					$attendeeHTML .= ' ✔︎';
1053
-					$attendeeText .= ' ✔︎';
1054
-				}
1055
-			}
1056
-			$attendeesHTML[] = $attendeeHTML;
1057
-			$attendeesText[] = $attendeeText;
1058
-		}
1059
-
1060
-		$template->addBodyListItem(implode('<br/>', $attendeesHTML), $this->l10n->t('Attendees:'),
1061
-			$this->getAbsoluteImagePath('caldav/attendees.png'),
1062
-			implode("\n", $attendeesText), '', IMipPlugin::IMIP_INDENT);
1063
-	}
1064
-
1065
-	/**
1066
-	 * @param IEMailTemplate $template
1067
-	 * @param VEVENT $vevent
1068
-	 * @param $data
1069
-	 */
1070
-	public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) {
1071
-		$template->addBodyListItem(
1072
-			$data['meeting_title_html'] ?? htmlspecialchars($data['meeting_title']), $this->l10n->t('Title:'),
1073
-			$this->getAbsoluteImagePath('caldav/title.png'), $data['meeting_title'], '', IMipPlugin::IMIP_INDENT);
1074
-		if ($data['meeting_when'] !== '') {
1075
-			$template->addBodyListItem($data['meeting_when_html'] ?? htmlspecialchars($data['meeting_when']), $this->l10n->t('When:'),
1076
-				$this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_when'], '', IMipPlugin::IMIP_INDENT);
1077
-		}
1078
-		if ($data['meeting_location'] !== '') {
1079
-			$template->addBodyListItem($data['meeting_location_html'] ?? htmlspecialchars($data['meeting_location']), $this->l10n->t('Location:'),
1080
-				$this->getAbsoluteImagePath('caldav/location.png'), $data['meeting_location'], '', IMipPlugin::IMIP_INDENT);
1081
-		}
1082
-		if ($data['meeting_url'] !== '') {
1083
-			$template->addBodyListItem($data['meeting_url_html'] ?? htmlspecialchars($data['meeting_url']), $this->l10n->t('Link:'),
1084
-				$this->getAbsoluteImagePath('caldav/link.png'), $data['meeting_url'], '', IMipPlugin::IMIP_INDENT);
1085
-		}
1086
-		if (isset($data['meeting_occurring'])) {
1087
-			$template->addBodyListItem($data['meeting_occurring_html'] ?? htmlspecialchars($data['meeting_occurring']), $this->l10n->t('Occurring:'),
1088
-				$this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_occurring'], '', IMipPlugin::IMIP_INDENT);
1089
-		}
1090
-
1091
-		$this->addAttendees($template, $vevent);
1092
-
1093
-		/* Put description last, like an email body, since it can be arbitrarily long */
1094
-		if ($data['meeting_description']) {
1095
-			$template->addBodyListItem($data['meeting_description_html'] ?? htmlspecialchars($data['meeting_description']), $this->l10n->t('Description:'),
1096
-				$this->getAbsoluteImagePath('caldav/description.png'), $data['meeting_description'], '', IMipPlugin::IMIP_INDENT);
1097
-		}
1098
-	}
1099
-
1100
-	/**
1101
-	 * @param Message $iTipMessage
1102
-	 * @return null|Property
1103
-	 */
1104
-	public function getCurrentAttendee(Message $iTipMessage): ?Property {
1105
-		/** @var VEvent $vevent */
1106
-		$vevent = $iTipMessage->message->VEVENT;
1107
-		$attendees = $vevent->select('ATTENDEE');
1108
-		foreach ($attendees as $attendee) {
1109
-			if ($iTipMessage->method === 'REPLY' && strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
1110
-				/** @var Property $attendee */
1111
-				return $attendee;
1112
-			} elseif (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
1113
-				/** @var Property $attendee */
1114
-				return $attendee;
1115
-			}
1116
-		}
1117
-		return null;
1118
-	}
1119
-
1120
-	/**
1121
-	 * @param Message $iTipMessage
1122
-	 * @param VEvent $vevent
1123
-	 * @param int $lastOccurrence
1124
-	 * @return string
1125
-	 */
1126
-	public function createInvitationToken(Message $iTipMessage, VEvent $vevent, int $lastOccurrence): string {
1127
-		$token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC);
1128
-
1129
-		$attendee = $iTipMessage->recipient;
1130
-		$organizer = $iTipMessage->sender;
1131
-		$sequence = $iTipMessage->sequence;
1132
-		$recurrenceId = isset($vevent->{'RECURRENCE-ID'})
1133
-			? $vevent->{'RECURRENCE-ID'}->serialize() : null;
1134
-		$uid = $vevent->{'UID'}?->getValue();
1135
-
1136
-		$query = $this->db->getQueryBuilder();
1137
-		$query->insert('calendar_invitations')
1138
-			->values([
1139
-				'token' => $query->createNamedParameter($token),
1140
-				'attendee' => $query->createNamedParameter($attendee),
1141
-				'organizer' => $query->createNamedParameter($organizer),
1142
-				'sequence' => $query->createNamedParameter($sequence),
1143
-				'recurrenceid' => $query->createNamedParameter($recurrenceId),
1144
-				'expiration' => $query->createNamedParameter($lastOccurrence),
1145
-				'uid' => $query->createNamedParameter($uid)
1146
-			])
1147
-			->executeStatement();
1148
-
1149
-		return $token;
1150
-	}
1151
-
1152
-	/**
1153
-	 * @param IEMailTemplate $template
1154
-	 * @param $token
1155
-	 */
1156
-	public function addResponseButtons(IEMailTemplate $template, $token) {
1157
-		$template->addBodyButtonGroup(
1158
-			$this->l10n->t('Accept'),
1159
-			$this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [
1160
-				'token' => $token,
1161
-			]),
1162
-			$this->l10n->t('Decline'),
1163
-			$this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [
1164
-				'token' => $token,
1165
-			])
1166
-		);
1167
-	}
1168
-
1169
-	public function addMoreOptionsButton(IEMailTemplate $template, $token) {
1170
-		$moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [
1171
-			'token' => $token,
1172
-		]);
1173
-		$html = vsprintf('<small><a href="%s">%s</a></small>', [
1174
-			$moreOptionsURL, $this->l10n->t('More options …')
1175
-		]);
1176
-		$text = $this->l10n->t('More options at %s', [$moreOptionsURL]);
1177
-
1178
-		$template->addBodyText($html, $text);
1179
-	}
1180
-
1181
-	public function getReplyingAttendee(Message $iTipMessage): ?Property {
1182
-		/** @var VEvent $vevent */
1183
-		$vevent = $iTipMessage->message->VEVENT;
1184
-		$attendees = $vevent->select('ATTENDEE');
1185
-		foreach ($attendees as $attendee) {
1186
-			/** @var Property $attendee */
1187
-			if (strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
1188
-				return $attendee;
1189
-			}
1190
-		}
1191
-		return null;
1192
-	}
1193
-
1194
-	public function isRoomOrResource(Property $attendee): bool {
1195
-		$cuType = $attendee->offsetGet('CUTYPE');
1196
-		if (!$cuType instanceof Parameter) {
1197
-			return false;
1198
-		}
1199
-		$type = $cuType->getValue() ?? 'INDIVIDUAL';
1200
-		if (\in_array(strtoupper($type), ['RESOURCE', 'ROOM'], true)) {
1201
-			// Don't send emails to things
1202
-			return true;
1203
-		}
1204
-		return false;
1205
-	}
1206
-
1207
-	public function isCircle(Property $attendee): bool {
1208
-		$cuType = $attendee->offsetGet('CUTYPE');
1209
-		if (!$cuType instanceof Parameter) {
1210
-			return false;
1211
-		}
1212
-
1213
-		$uri = $attendee->getValue();
1214
-		if (!$uri) {
1215
-			return false;
1216
-		}
1217
-
1218
-		$cuTypeValue = $cuType->getValue();
1219
-		return $cuTypeValue === 'GROUP' && str_starts_with($uri, 'mailto:circle+');
1220
-	}
1221
-
1222
-	public function minimizeInterval(\DateInterval $dateInterval): array {
1223
-		// evaluate if time interval is in the past
1224
-		if ($dateInterval->invert == 1) {
1225
-			return ['interval' => 1, 'scale' => 'past'];
1226
-		}
1227
-		// evaluate interval parts and return smallest time period
1228
-		if ($dateInterval->y > 0) {
1229
-			$interval = $dateInterval->y;
1230
-			$scale = 'year';
1231
-		} elseif ($dateInterval->m > 0) {
1232
-			$interval = $dateInterval->m;
1233
-			$scale = 'month';
1234
-		} elseif ($dateInterval->d >= 7) {
1235
-			$interval = (int)($dateInterval->d / 7);
1236
-			$scale = 'week';
1237
-		} elseif ($dateInterval->d > 0) {
1238
-			$interval = $dateInterval->d;
1239
-			$scale = 'day';
1240
-		} elseif ($dateInterval->h > 0) {
1241
-			$interval = $dateInterval->h;
1242
-			$scale = 'hour';
1243
-		} else {
1244
-			$interval = $dateInterval->i;
1245
-			$scale = 'minute';
1246
-		}
1247
-
1248
-		return ['interval' => $interval, 'scale' => $scale];
1249
-	}
1250
-
1251
-	/**
1252
-	 * Localizes week day names to another language
1253
-	 *
1254
-	 * @param string $value
1255
-	 *
1256
-	 * @return string
1257
-	 */
1258
-	public function localizeDayName(string $value): string {
1259
-		return match ($value) {
1260
-			'Monday' => $this->l10n->t('Monday'),
1261
-			'Tuesday' => $this->l10n->t('Tuesday'),
1262
-			'Wednesday' => $this->l10n->t('Wednesday'),
1263
-			'Thursday' => $this->l10n->t('Thursday'),
1264
-			'Friday' => $this->l10n->t('Friday'),
1265
-			'Saturday' => $this->l10n->t('Saturday'),
1266
-			'Sunday' => $this->l10n->t('Sunday'),
1267
-		};
1268
-	}
1269
-
1270
-	/**
1271
-	 * Localizes month names to another language
1272
-	 *
1273
-	 * @param string $value
1274
-	 *
1275
-	 * @return string
1276
-	 */
1277
-	public function localizeMonthName(string $value): string {
1278
-		return match ($value) {
1279
-			'January' => $this->l10n->t('January'),
1280
-			'February' => $this->l10n->t('February'),
1281
-			'March' => $this->l10n->t('March'),
1282
-			'April' => $this->l10n->t('April'),
1283
-			'May' => $this->l10n->t('May'),
1284
-			'June' => $this->l10n->t('June'),
1285
-			'July' => $this->l10n->t('July'),
1286
-			'August' => $this->l10n->t('August'),
1287
-			'September' => $this->l10n->t('September'),
1288
-			'October' => $this->l10n->t('October'),
1289
-			'November' => $this->l10n->t('November'),
1290
-			'December' => $this->l10n->t('December'),
1291
-		};
1292
-	}
1293
-
1294
-	/**
1295
-	 * Localizes relative position names to another language
1296
-	 *
1297
-	 * @param string $value
1298
-	 *
1299
-	 * @return string
1300
-	 */
1301
-	public function localizeRelativePositionName(string $value): string {
1302
-		return match ($value) {
1303
-			'First' => $this->l10n->t('First'),
1304
-			'Second' => $this->l10n->t('Second'),
1305
-			'Third' => $this->l10n->t('Third'),
1306
-			'Fourth' => $this->l10n->t('Fourth'),
1307
-			'Fifth' => $this->l10n->t('Fifth'),
1308
-			'Last' => $this->l10n->t('Last'),
1309
-			'Second Last' => $this->l10n->t('Second Last'),
1310
-			'Third Last' => $this->l10n->t('Third Last'),
1311
-			'Fourth Last' => $this->l10n->t('Fourth Last'),
1312
-			'Fifth Last' => $this->l10n->t('Fifth Last'),
1313
-		};
1314
-	}
32
+    private IL10N $l10n;
33
+
34
+    /** @var string[] */
35
+    private const STRING_DIFF = [
36
+        'meeting_title' => 'SUMMARY',
37
+        'meeting_description' => 'DESCRIPTION',
38
+        'meeting_url' => 'URL',
39
+        'meeting_location' => 'LOCATION'
40
+    ];
41
+
42
+    public function __construct(
43
+        private URLGenerator $urlGenerator,
44
+        private IConfig $config,
45
+        private IDBConnection $db,
46
+        private ISecureRandom $random,
47
+        private L10NFactory $l10nFactory,
48
+        private ITimeFactory $timeFactory,
49
+        private readonly IUserManager $userManager,
50
+    ) {
51
+        $language = $this->l10nFactory->findGenericLanguage();
52
+        $locale = $this->l10nFactory->findLocale($language);
53
+        $this->l10n = $this->l10nFactory->get('dav', $language, $locale);
54
+    }
55
+
56
+    /**
57
+     * @param string|null $senderName
58
+     * @param string $default
59
+     * @return string
60
+     */
61
+    public function getFrom(?string $senderName, string $default): string {
62
+        if ($senderName === null) {
63
+            return $default;
64
+        }
65
+
66
+        return $this->l10n->t('%1$s via %2$s', [$senderName, $default]);
67
+    }
68
+
69
+    public static function readPropertyWithDefault(VEvent $vevent, string $property, string $default) {
70
+        if (isset($vevent->$property)) {
71
+            $value = $vevent->$property->getValue();
72
+            if (!empty($value)) {
73
+                return $value;
74
+            }
75
+        }
76
+        return $default;
77
+    }
78
+
79
+    private function generateDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
80
+        $strikethrough = "<span style='text-decoration: line-through'>%s</span><br />%s";
81
+        if (!isset($vevent->$property)) {
82
+            return $default;
83
+        }
84
+        $value = $vevent->$property->getValue();
85
+        $newstring = $value === null ? null : htmlspecialchars($value);
86
+        if (isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newstring) {
87
+            $oldstring = $oldVEvent->$property->getValue();
88
+            return sprintf($strikethrough, htmlspecialchars($oldstring), $newstring);
89
+        }
90
+        return $newstring;
91
+    }
92
+
93
+    /**
94
+     * Like generateDiffString() but linkifies the property values if they are urls.
95
+     */
96
+    private function generateLinkifiedDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
97
+        if (!isset($vevent->$property)) {
98
+            return $default;
99
+        }
100
+        /** @var string|null $newString */
101
+        $newString = htmlspecialchars($vevent->$property->getValue());
102
+        $oldString = isset($oldVEvent->$property) ? htmlspecialchars($oldVEvent->$property->getValue()) : null;
103
+        if ($oldString !== $newString) {
104
+            return sprintf(
105
+                "<span style='text-decoration: line-through'>%s</span><br />%s",
106
+                $this->linkify($oldString) ?? $oldString ?? '',
107
+                $this->linkify($newString) ?? $newString ?? ''
108
+            );
109
+        }
110
+        return $this->linkify($newString) ?? $newString;
111
+    }
112
+
113
+    /**
114
+     * Convert a given url to a html link element or return null otherwise.
115
+     */
116
+    private function linkify(?string $url): ?string {
117
+        if ($url === null) {
118
+            return null;
119
+        }
120
+        if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
121
+            return null;
122
+        }
123
+
124
+        return sprintf('<a href="%1$s">%1$s</a>', htmlspecialchars($url));
125
+    }
126
+
127
+    /**
128
+     * @param VEvent $vEvent
129
+     * @param VEvent|null $oldVEvent
130
+     * @return array
131
+     */
132
+    public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array {
133
+
134
+        // construct event reader
135
+        $eventReaderCurrent = new EventReader($vEvent);
136
+        $eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null;
137
+        $defaultVal = '';
138
+        $data = [];
139
+        $data['meeting_when'] = $this->generateWhenString($eventReaderCurrent);
140
+
141
+        foreach (self::STRING_DIFF as $key => $property) {
142
+            $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
143
+        }
144
+
145
+        $data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal);
146
+
147
+        if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
148
+            $data['meeting_location_html'] = $locationHtml;
149
+        }
150
+
151
+        if (!empty($oldVEvent)) {
152
+            $oldMeetingWhen = $this->generateWhenString($eventReaderPrevious);
153
+            $data['meeting_title_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY', $data['meeting_title']);
154
+            $data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION', $data['meeting_description']);
155
+            $data['meeting_location_html'] = $this->generateLinkifiedDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']);
156
+
157
+            $oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal);
158
+            $data['meeting_url_html'] = !empty($oldUrl) && $oldUrl !== $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url'];
159
+
160
+            $data['meeting_when_html'] = $oldMeetingWhen !== $data['meeting_when'] ? sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", $oldMeetingWhen, $data['meeting_when']) : $data['meeting_when'];
161
+        }
162
+        // generate occurring next string
163
+        if ($eventReaderCurrent->recurs()) {
164
+            $data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent);
165
+        }
166
+        return $data;
167
+    }
168
+
169
+    /**
170
+     * @param VEvent $vEvent
171
+     * @return array
172
+     */
173
+    public function buildReplyBodyData(VEvent $vEvent): array {
174
+        // construct event reader
175
+        $eventReader = new EventReader($vEvent);
176
+        $defaultVal = '';
177
+        $data = [];
178
+        $data['meeting_when'] = $this->generateWhenString($eventReader);
179
+
180
+        foreach (self::STRING_DIFF as $key => $property) {
181
+            $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
182
+        }
183
+
184
+        if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
185
+            $data['meeting_location_html'] = $locationHtml;
186
+        }
187
+
188
+        $data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : '';
189
+
190
+        // generate occurring next string
191
+        if ($eventReader->recurs()) {
192
+            $data['meeting_occurring'] = $this->generateOccurringString($eventReader);
193
+        }
194
+
195
+        return $data;
196
+    }
197
+
198
+    /**
199
+     * generates a when string based on if a event has an recurrence or not
200
+     *
201
+     * @since 30.0.0
202
+     *
203
+     * @param EventReader $er
204
+     *
205
+     * @return string
206
+     */
207
+    public function generateWhenString(EventReader $er): string {
208
+        return match ($er->recurs()) {
209
+            true => $this->generateWhenStringRecurring($er),
210
+            false => $this->generateWhenStringSingular($er)
211
+        };
212
+    }
213
+
214
+    /**
215
+     * generates a when string for a non recurring event
216
+     *
217
+     * @since 30.0.0
218
+     *
219
+     * @param EventReader $er
220
+     *
221
+     * @return string
222
+     */
223
+    public function generateWhenStringSingular(EventReader $er): string {
224
+        // initialize
225
+        $startTime = null;
226
+        $endTime = null;
227
+        // calculate time difference from now to start of event
228
+        $occurring = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
229
+        // extract start date
230
+        $startDate = $this->l10n->l('date', $er->startDateTime(), ['width' => 'full']);
231
+        // time of the day
232
+        if (!$er->entireDay()) {
233
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
234
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
235
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
236
+        }
237
+        // generate localized when string
238
+        // TRANSLATORS
239
+        // Indicates when a calendar event will happen, shown on invitation emails
240
+        // Output produced in order:
241
+        // In 1 minute/hour/day/week/month/year on July 1, 2024 for the entire day
242
+        // In 1 minute/hour/day/week/month/year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
243
+        // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 for the entire day
244
+        // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
245
+        return match ([$occurring['scale'], $endTime !== null]) {
246
+            ['past', false] => $this->l10n->t(
247
+                'In the past on %1$s for the entire day',
248
+                [$startDate]
249
+            ),
250
+            ['minute', false] => $this->l10n->n(
251
+                'In %n minute on %1$s for the entire day',
252
+                'In %n minutes on %1$s for the entire day',
253
+                $occurring['interval'],
254
+                [$startDate]
255
+            ),
256
+            ['hour', false] => $this->l10n->n(
257
+                'In %n hour on %1$s for the entire day',
258
+                'In %n hours on %1$s for the entire day',
259
+                $occurring['interval'],
260
+                [$startDate]
261
+            ),
262
+            ['day', false] => $this->l10n->n(
263
+                'In %n day on %1$s for the entire day',
264
+                'In %n days on %1$s for the entire day',
265
+                $occurring['interval'],
266
+                [$startDate]
267
+            ),
268
+            ['week', false] => $this->l10n->n(
269
+                'In %n week on %1$s for the entire day',
270
+                'In %n weeks on %1$s for the entire day',
271
+                $occurring['interval'],
272
+                [$startDate]
273
+            ),
274
+            ['month', false] => $this->l10n->n(
275
+                'In %n month on %1$s for the entire day',
276
+                'In %n months on %1$s for the entire day',
277
+                $occurring['interval'],
278
+                [$startDate]
279
+            ),
280
+            ['year', false] => $this->l10n->n(
281
+                'In %n year on %1$s for the entire day',
282
+                'In %n years on %1$s for the entire day',
283
+                $occurring['interval'],
284
+                [$startDate]
285
+            ),
286
+            ['past', true] => $this->l10n->t(
287
+                'In the past on %1$s between %2$s - %3$s',
288
+                [$startDate, $startTime, $endTime]
289
+            ),
290
+            ['minute', true] => $this->l10n->n(
291
+                'In %n minute on %1$s between %2$s - %3$s',
292
+                'In %n minutes on %1$s between %2$s - %3$s',
293
+                $occurring['interval'],
294
+                [$startDate, $startTime, $endTime]
295
+            ),
296
+            ['hour', true] => $this->l10n->n(
297
+                'In %n hour on %1$s between %2$s - %3$s',
298
+                'In %n hours on %1$s between %2$s - %3$s',
299
+                $occurring['interval'],
300
+                [$startDate, $startTime, $endTime]
301
+            ),
302
+            ['day', true] => $this->l10n->n(
303
+                'In %n day on %1$s between %2$s - %3$s',
304
+                'In %n days on %1$s between %2$s - %3$s',
305
+                $occurring['interval'],
306
+                [$startDate, $startTime, $endTime]
307
+            ),
308
+            ['week', true] => $this->l10n->n(
309
+                'In %n week on %1$s between %2$s - %3$s',
310
+                'In %n weeks on %1$s between %2$s - %3$s',
311
+                $occurring['interval'],
312
+                [$startDate, $startTime, $endTime]
313
+            ),
314
+            ['month', true] => $this->l10n->n(
315
+                'In %n month on %1$s between %2$s - %3$s',
316
+                'In %n months on %1$s between %2$s - %3$s',
317
+                $occurring['interval'],
318
+                [$startDate, $startTime, $endTime]
319
+            ),
320
+            ['year', true] => $this->l10n->n(
321
+                'In %n year on %1$s between %2$s - %3$s',
322
+                'In %n years on %1$s between %2$s - %3$s',
323
+                $occurring['interval'],
324
+                [$startDate, $startTime, $endTime]
325
+            ),
326
+            default => $this->l10n->t('Could not generate when statement')
327
+        };
328
+    }
329
+
330
+    /**
331
+     * generates a when string based on recurrence precision/frequency
332
+     *
333
+     * @since 30.0.0
334
+     *
335
+     * @param EventReader $er
336
+     *
337
+     * @return string
338
+     */
339
+    public function generateWhenStringRecurring(EventReader $er): string {
340
+        return match ($er->recurringPrecision()) {
341
+            'daily' => $this->generateWhenStringRecurringDaily($er),
342
+            'weekly' => $this->generateWhenStringRecurringWeekly($er),
343
+            'monthly' => $this->generateWhenStringRecurringMonthly($er),
344
+            'yearly' => $this->generateWhenStringRecurringYearly($er),
345
+            'fixed' => $this->generateWhenStringRecurringFixed($er),
346
+        };
347
+    }
348
+
349
+    /**
350
+     * generates a when string for a daily precision/frequency
351
+     *
352
+     * @since 30.0.0
353
+     *
354
+     * @param EventReader $er
355
+     *
356
+     * @return string
357
+     */
358
+    public function generateWhenStringRecurringDaily(EventReader $er): string {
359
+
360
+        // initialize
361
+        $interval = (int)$er->recurringInterval();
362
+        $startTime = null;
363
+        $conclusion = null;
364
+        // time of the day
365
+        if (!$er->entireDay()) {
366
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
367
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
368
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
369
+        }
370
+        // conclusion
371
+        if ($er->recurringConcludes()) {
372
+            $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
373
+        }
374
+        // generate localized when string
375
+        // TRANSLATORS
376
+        // Indicates when a calendar event will happen, shown on invitation emails
377
+        // Output produced in order:
378
+        // Every Day for the entire day
379
+        // Every Day for the entire day until July 13, 2024
380
+        // Every Day between 8:00 AM - 9:00 AM (America/Toronto)
381
+        // Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
382
+        // Every 3 Days for the entire day
383
+        // Every 3 Days for the entire day until July 13, 2024
384
+        // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto)
385
+        // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
386
+        return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
387
+            [false, false, false] => $this->l10n->t('Every Day for the entire day'),
388
+            [false, false, true] => $this->l10n->t('Every Day for the entire day until %1$s', [$conclusion]),
389
+            [false, true, false] => $this->l10n->t('Every Day between %1$s - %2$s', [$startTime, $endTime]),
390
+            [false, true, true] => $this->l10n->t('Every Day between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
391
+            [true, false, false] => $this->l10n->t('Every %1$d Days for the entire day', [$interval]),
392
+            [true, false, true] => $this->l10n->t('Every %1$d Days for the entire day until %2$s', [$interval, $conclusion]),
393
+            [true, true, false] => $this->l10n->t('Every %1$d Days between %2$s - %3$s', [$interval, $startTime, $endTime]),
394
+            [true, true, true] => $this->l10n->t('Every %1$d Days between %2$s - %3$s until %4$s', [$interval, $startTime, $endTime, $conclusion]),
395
+            default => $this->l10n->t('Could not generate event recurrence statement')
396
+        };
397
+
398
+    }
399
+
400
+    /**
401
+     * generates a when string for a weekly precision/frequency
402
+     *
403
+     * @since 30.0.0
404
+     *
405
+     * @param EventReader $er
406
+     *
407
+     * @return string
408
+     */
409
+    public function generateWhenStringRecurringWeekly(EventReader $er): string {
410
+
411
+        // initialize
412
+        $interval = (int)$er->recurringInterval();
413
+        $startTime = null;
414
+        $conclusion = null;
415
+        // days of the week
416
+        $days = implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
417
+        // time of the day
418
+        if (!$er->entireDay()) {
419
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
420
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
421
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
422
+        }
423
+        // conclusion
424
+        if ($er->recurringConcludes()) {
425
+            $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
426
+        }
427
+        // generate localized when string
428
+        // TRANSLATORS
429
+        // Indicates when a calendar event will happen, shown on invitation emails
430
+        // Output produced in order:
431
+        // Every Week on Monday, Wednesday, Friday for the entire day
432
+        // Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024
433
+        // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
434
+        // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
435
+        // Every 2 Weeks on Monday, Wednesday, Friday for the entire day
436
+        // Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024
437
+        // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
438
+        // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
439
+        return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
440
+            [false, false, false] => $this->l10n->t('Every Week on %1$s for the entire day', [$days]),
441
+            [false, false, true] => $this->l10n->t('Every Week on %1$s for the entire day until %2$s', [$days, $conclusion]),
442
+            [false, true, false] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
443
+            [false, true, true] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
444
+            [true, false, false] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day', [$interval, $days]),
445
+            [true, false, true] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
446
+            [true, true, false] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
447
+            [true, true, true] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]),
448
+            default => $this->l10n->t('Could not generate event recurrence statement')
449
+        };
450
+
451
+    }
452
+
453
+    /**
454
+     * generates a when string for a monthly precision/frequency
455
+     *
456
+     * @since 30.0.0
457
+     *
458
+     * @param EventReader $er
459
+     *
460
+     * @return string
461
+     */
462
+    public function generateWhenStringRecurringMonthly(EventReader $er): string {
463
+
464
+        // initialize
465
+        $interval = (int)$er->recurringInterval();
466
+        $startTime = null;
467
+        $conclusion = null;
468
+        // days of month
469
+        if ($er->recurringPattern() === 'R') {
470
+            $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
471
+                    . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
472
+        } else {
473
+            $days = implode(', ', $er->recurringDaysOfMonth());
474
+        }
475
+        // time of the day
476
+        if (!$er->entireDay()) {
477
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
478
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
479
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
480
+        }
481
+        // conclusion
482
+        if ($er->recurringConcludes()) {
483
+            $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
484
+        }
485
+        // generate localized when string
486
+        // TRANSLATORS
487
+        // Indicates when a calendar event will happen, shown on invitation emails
488
+        // Output produced in order, output varies depending on if the event is absolute or releative:
489
+        // Absolute: Every Month on the 1, 8 for the entire day
490
+        // Relative: Every Month on the First Sunday, Saturday for the entire day
491
+        // Absolute: Every Month on the 1, 8 for the entire day until December 31, 2024
492
+        // Relative: Every Month on the First Sunday, Saturday for the entire day until December 31, 2024
493
+        // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
494
+        // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
495
+        // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
496
+        // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
497
+        // Absolute: Every 2 Months on the 1, 8 for the entire day
498
+        // Relative: Every 2 Months on the First Sunday, Saturday for the entire day
499
+        // Absolute: Every 2 Months on the 1, 8 for the entire day until December 31, 2024
500
+        // Relative: Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024
501
+        // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
502
+        // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
503
+        // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
504
+        // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
505
+        return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
506
+            [false, false, false] => $this->l10n->t('Every Month on the %1$s for the entire day', [$days]),
507
+            [false, false, true] => $this->l10n->t('Every Month on the %1$s for the entire day until %2$s', [$days, $conclusion]),
508
+            [false, true, false] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
509
+            [false, true, true] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
510
+            [true, false, false] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day', [$interval, $days]),
511
+            [true, false, true] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
512
+            [true, true, false] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
513
+            [true, true, true] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]),
514
+            default => $this->l10n->t('Could not generate event recurrence statement')
515
+        };
516
+    }
517
+
518
+    /**
519
+     * generates a when string for a yearly precision/frequency
520
+     *
521
+     * @since 30.0.0
522
+     *
523
+     * @param EventReader $er
524
+     *
525
+     * @return string
526
+     */
527
+    public function generateWhenStringRecurringYearly(EventReader $er): string {
528
+
529
+        // initialize
530
+        $interval = (int)$er->recurringInterval();
531
+        $startTime = null;
532
+        $conclusion = null;
533
+        // months of year
534
+        $months = implode(', ', array_map(function ($value) { return $this->localizeMonthName($value); }, $er->recurringMonthsOfYearNamed()));
535
+        // days of month
536
+        if ($er->recurringPattern() === 'R') {
537
+            $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
538
+                    . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
539
+        } else {
540
+            $days = $er->startDateTime()->format('jS');
541
+        }
542
+        // time of the day
543
+        if (!$er->entireDay()) {
544
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
545
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
546
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
547
+        }
548
+        // conclusion
549
+        if ($er->recurringConcludes()) {
550
+            $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
551
+        }
552
+        // generate localized when string
553
+        // TRANSLATORS
554
+        // Indicates when a calendar event will happen, shown on invitation emails
555
+        // Output produced in order, output varies depending on if the event is absolute or releative:
556
+        // Absolute: Every Year in July on the 1st for the entire day
557
+        // Relative: Every Year in July on the First Sunday, Saturday for the entire day
558
+        // Absolute: Every Year in July on the 1st for the entire day until July 31, 2026
559
+        // Relative: Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026
560
+        // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
561
+        // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
562
+        // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
563
+        // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
564
+        // Absolute: Every 2 Years in July on the 1st for the entire day
565
+        // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day
566
+        // Absolute: Every 2 Years in July on the 1st for the entire day until July 31, 2026
567
+        // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026
568
+        // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
569
+        // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
570
+        // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
571
+        // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
572
+        return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
573
+            [false, false, false] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day', [$months, $days]),
574
+            [false, false, true] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day until %3$s', [$months, $days, $conclusion]),
575
+            [false, true, false] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s', [$months, $days, $startTime, $endTime]),
576
+            [false, true, true] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s until %5$s', [$months, $days, $startTime, $endTime, $conclusion]),
577
+            [true, false, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day', [$interval, $months, $days]),
578
+            [true, false, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day until %4$s', [$interval, $months,  $days, $conclusion]),
579
+            [true, true, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s', [$interval, $months, $days, $startTime, $endTime]),
580
+            [true, true, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s until %6$s', [$interval, $months, $days, $startTime, $endTime, $conclusion]),
581
+            default => $this->l10n->t('Could not generate event recurrence statement')
582
+        };
583
+    }
584
+
585
+    /**
586
+     * generates a when string for a fixed precision/frequency
587
+     *
588
+     * @since 30.0.0
589
+     *
590
+     * @param EventReader $er
591
+     *
592
+     * @return string
593
+     */
594
+    public function generateWhenStringRecurringFixed(EventReader $er): string {
595
+        // initialize
596
+        $startTime = null;
597
+        $conclusion = null;
598
+        // time of the day
599
+        if (!$er->entireDay()) {
600
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
601
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
602
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
603
+        }
604
+        // conclusion
605
+        $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
606
+        // generate localized when string
607
+        // TRANSLATORS
608
+        // Indicates when a calendar event will happen, shown on invitation emails
609
+        // Output produced in order:
610
+        // On specific dates for the entire day until July 13, 2024
611
+        // On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
612
+        return match ($startTime !== null) {
613
+            false => $this->l10n->t('On specific dates for the entire day until %1$s', [$conclusion]),
614
+            true => $this->l10n->t('On specific dates between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
615
+        };
616
+    }
617
+
618
+    /**
619
+     * generates a occurring next string for a recurring event
620
+     *
621
+     * @since 30.0.0
622
+     *
623
+     * @param EventReader $er
624
+     *
625
+     * @return string
626
+     */
627
+    public function generateOccurringString(EventReader $er): string {
628
+
629
+        // initialize
630
+        $occurrence = null;
631
+        $occurrence2 = null;
632
+        $occurrence3 = null;
633
+        // reset to initial occurrence
634
+        $er->recurrenceRewind();
635
+        // forward to current date
636
+        $er->recurrenceAdvanceTo($this->timeFactory->getDateTime());
637
+        // calculate time difference from now to start of next event occurrence and minimize it
638
+        $occurrenceIn = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
639
+        // store next occurrence value
640
+        $occurrence = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
641
+        // forward one occurrence
642
+        $er->recurrenceAdvance();
643
+        // evaluate if occurrence is valid
644
+        if ($er->recurrenceDate() !== null) {
645
+            // store following occurrence value
646
+            $occurrence2 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
647
+            // forward one occurrence
648
+            $er->recurrenceAdvance();
649
+            // evaluate if occurrence is valid
650
+            if ($er->recurrenceDate()) {
651
+                // store following occurrence value
652
+                $occurrence3 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
653
+            }
654
+        }
655
+        // generate localized when string
656
+        // TRANSLATORS
657
+        // Indicates when a calendar event will happen, shown on invitation emails
658
+        // Output produced in order:
659
+        // In 1 minute/hour/day/week/month/year on July 1, 2024
660
+        // In 1 minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024
661
+        // In 1 minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024 and July 5, 2024
662
+        // In 2 minutes/hours/days/weeks/months/years on July 1, 2024
663
+        // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024
664
+        // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024 and July 5, 2024
665
+        return match ([$occurrenceIn['scale'], $occurrence2 !== null, $occurrence3 !== null]) {
666
+            ['past', false, false] => $this->l10n->t(
667
+                'In the past on %1$s',
668
+                [$occurrence]
669
+            ),
670
+            ['minute', false, false] => $this->l10n->n(
671
+                'In %n minute on %1$s',
672
+                'In %n minutes on %1$s',
673
+                $occurrenceIn['interval'],
674
+                [$occurrence]
675
+            ),
676
+            ['hour', false, false] => $this->l10n->n(
677
+                'In %n hour on %1$s',
678
+                'In %n hours on %1$s',
679
+                $occurrenceIn['interval'],
680
+                [$occurrence]
681
+            ),
682
+            ['day', false, false] => $this->l10n->n(
683
+                'In %n day on %1$s',
684
+                'In %n days on %1$s',
685
+                $occurrenceIn['interval'],
686
+                [$occurrence]
687
+            ),
688
+            ['week', false, false] => $this->l10n->n(
689
+                'In %n week on %1$s',
690
+                'In %n weeks on %1$s',
691
+                $occurrenceIn['interval'],
692
+                [$occurrence]
693
+            ),
694
+            ['month', false, false] => $this->l10n->n(
695
+                'In %n month on %1$s',
696
+                'In %n months on %1$s',
697
+                $occurrenceIn['interval'],
698
+                [$occurrence]
699
+            ),
700
+            ['year', false, false] => $this->l10n->n(
701
+                'In %n year on %1$s',
702
+                'In %n years on %1$s',
703
+                $occurrenceIn['interval'],
704
+                [$occurrence]
705
+            ),
706
+            ['past', true, false] => $this->l10n->t(
707
+                'In the past on %1$s then on %2$s',
708
+                [$occurrence, $occurrence2]
709
+            ),
710
+            ['minute', true, false] => $this->l10n->n(
711
+                'In %n minute on %1$s then on %2$s',
712
+                'In %n minutes on %1$s then on %2$s',
713
+                $occurrenceIn['interval'],
714
+                [$occurrence, $occurrence2]
715
+            ),
716
+            ['hour', true, false] => $this->l10n->n(
717
+                'In %n hour on %1$s then on %2$s',
718
+                'In %n hours on %1$s then on %2$s',
719
+                $occurrenceIn['interval'],
720
+                [$occurrence, $occurrence2]
721
+            ),
722
+            ['day', true, false] => $this->l10n->n(
723
+                'In %n day on %1$s then on %2$s',
724
+                'In %n days on %1$s then on %2$s',
725
+                $occurrenceIn['interval'],
726
+                [$occurrence, $occurrence2]
727
+            ),
728
+            ['week', true, false] => $this->l10n->n(
729
+                'In %n week on %1$s then on %2$s',
730
+                'In %n weeks on %1$s then on %2$s',
731
+                $occurrenceIn['interval'],
732
+                [$occurrence, $occurrence2]
733
+            ),
734
+            ['month', true, false] => $this->l10n->n(
735
+                'In %n month on %1$s then on %2$s',
736
+                'In %n months on %1$s then on %2$s',
737
+                $occurrenceIn['interval'],
738
+                [$occurrence, $occurrence2]
739
+            ),
740
+            ['year', true, false] => $this->l10n->n(
741
+                'In %n year on %1$s then on %2$s',
742
+                'In %n years on %1$s then on %2$s',
743
+                $occurrenceIn['interval'],
744
+                [$occurrence, $occurrence2]
745
+            ),
746
+            ['past', true, true] => $this->l10n->t(
747
+                'In the past on %1$s then on %2$s and %3$s',
748
+                [$occurrence, $occurrence2, $occurrence3]
749
+            ),
750
+            ['minute', true, true] => $this->l10n->n(
751
+                'In %n minute on %1$s then on %2$s and %3$s',
752
+                'In %n minutes on %1$s then on %2$s and %3$s',
753
+                $occurrenceIn['interval'],
754
+                [$occurrence, $occurrence2, $occurrence3]
755
+            ),
756
+            ['hour', true, true] => $this->l10n->n(
757
+                'In %n hour on %1$s then on %2$s and %3$s',
758
+                'In %n hours on %1$s then on %2$s and %3$s',
759
+                $occurrenceIn['interval'],
760
+                [$occurrence, $occurrence2, $occurrence3]
761
+            ),
762
+            ['day', true, true] => $this->l10n->n(
763
+                'In %n day on %1$s then on %2$s and %3$s',
764
+                'In %n days on %1$s then on %2$s and %3$s',
765
+                $occurrenceIn['interval'],
766
+                [$occurrence, $occurrence2, $occurrence3]
767
+            ),
768
+            ['week', true, true] => $this->l10n->n(
769
+                'In %n week on %1$s then on %2$s and %3$s',
770
+                'In %n weeks on %1$s then on %2$s and %3$s',
771
+                $occurrenceIn['interval'],
772
+                [$occurrence, $occurrence2, $occurrence3]
773
+            ),
774
+            ['month', true, true] => $this->l10n->n(
775
+                'In %n month on %1$s then on %2$s and %3$s',
776
+                'In %n months on %1$s then on %2$s and %3$s',
777
+                $occurrenceIn['interval'],
778
+                [$occurrence, $occurrence2, $occurrence3]
779
+            ),
780
+            ['year', true, true] => $this->l10n->n(
781
+                'In %n year on %1$s then on %2$s and %3$s',
782
+                'In %n years on %1$s then on %2$s and %3$s',
783
+                $occurrenceIn['interval'],
784
+                [$occurrence, $occurrence2, $occurrence3]
785
+            ),
786
+            default => $this->l10n->t('Could not generate next recurrence statement')
787
+        };
788
+
789
+    }
790
+
791
+    /**
792
+     * @param VEvent $vEvent
793
+     * @return array
794
+     */
795
+    public function buildCancelledBodyData(VEvent $vEvent): array {
796
+        // construct event reader
797
+        $eventReaderCurrent = new EventReader($vEvent);
798
+        $defaultVal = '';
799
+        $strikethrough = "<span style='text-decoration: line-through'>%s</span>";
800
+
801
+        $newMeetingWhen = $this->generateWhenString($eventReaderCurrent);
802
+        $newSummary = htmlspecialchars(isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event'));
803
+        $newDescription = htmlspecialchars(isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal);
804
+        $newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? sprintf('<a href="%1$s">%1$s</a>', $vEvent->URL) : $defaultVal;
805
+        $newLocation = htmlspecialchars(isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal);
806
+        $newLocationHtml = $this->linkify($newLocation) ?? $newLocation;
807
+
808
+        $data = [];
809
+        $data['meeting_when_html'] = $newMeetingWhen === '' ?: sprintf($strikethrough, $newMeetingWhen);
810
+        $data['meeting_when'] = $newMeetingWhen;
811
+        $data['meeting_title_html'] = sprintf($strikethrough, $newSummary);
812
+        $data['meeting_title'] = $newSummary !== '' ? $newSummary: $this->l10n->t('Untitled event');
813
+        $data['meeting_description_html'] = $newDescription !== '' ? sprintf($strikethrough, $newDescription) : '';
814
+        $data['meeting_description'] = $newDescription;
815
+        $data['meeting_url_html'] = $newUrl !== '' ? sprintf($strikethrough, $newUrl) : '';
816
+        $data['meeting_url'] = isset($vEvent->URL) ? (string)$vEvent->URL : '';
817
+        $data['meeting_location_html'] = $newLocationHtml !== '' ? sprintf($strikethrough, $newLocationHtml) : '';
818
+        $data['meeting_location'] = $newLocation;
819
+        return $data;
820
+    }
821
+
822
+    /**
823
+     * Check if event took place in the past
824
+     *
825
+     * @param VCalendar $vObject
826
+     * @return int
827
+     */
828
+    public function getLastOccurrence(VCalendar $vObject) {
829
+        /** @var VEvent $component */
830
+        $component = $vObject->VEVENT;
831
+
832
+        if (isset($component->RRULE)) {
833
+            $it = new EventIterator($vObject, (string)$component->UID);
834
+            $maxDate = new \DateTime(IMipPlugin::MAX_DATE);
835
+            if ($it->isInfinite()) {
836
+                return $maxDate->getTimestamp();
837
+            }
838
+
839
+            $end = $it->getDtEnd();
840
+            while ($it->valid() && $end < $maxDate) {
841
+                $end = $it->getDtEnd();
842
+                $it->next();
843
+            }
844
+            return $end->getTimestamp();
845
+        }
846
+
847
+        /** @var Property\ICalendar\DateTime $dtStart */
848
+        $dtStart = $component->DTSTART;
849
+
850
+        if (isset($component->DTEND)) {
851
+            /** @var Property\ICalendar\DateTime $dtEnd */
852
+            $dtEnd = $component->DTEND;
853
+            return $dtEnd->getDateTime()->getTimeStamp();
854
+        }
855
+
856
+        if (isset($component->DURATION)) {
857
+            /** @var \DateTime $endDate */
858
+            $endDate = clone $dtStart->getDateTime();
859
+            // $component->DTEND->getDateTime() returns DateTimeImmutable
860
+            $endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue()));
861
+            return $endDate->getTimestamp();
862
+        }
863
+
864
+        if (!$dtStart->hasTime()) {
865
+            /** @var \DateTime $endDate */
866
+            // $component->DTSTART->getDateTime() returns DateTimeImmutable
867
+            $endDate = clone $dtStart->getDateTime();
868
+            $endDate = $endDate->modify('+1 day');
869
+            return $endDate->getTimestamp();
870
+        }
871
+
872
+        // No computation of end time possible - return start date
873
+        return $dtStart->getDateTime()->getTimeStamp();
874
+    }
875
+
876
+    /**
877
+     * @param Property $attendee
878
+     */
879
+    public function setL10nFromAttendee(Property $attendee) {
880
+        $language = null;
881
+        $locale = null;
882
+        // check if the attendee is a system user
883
+        $userAddress = $attendee->getValue();
884
+        if (str_starts_with($userAddress, 'mailto:')) {
885
+            $userAddress = substr($userAddress, 7);
886
+        }
887
+        $users = $this->userManager->getByEmail($userAddress);
888
+        if ($users !== []) {
889
+            $user = array_shift($users);
890
+            $language = $this->config->getUserValue($user->getUID(), 'core', 'lang', null);
891
+            $locale = $this->config->getUserValue($user->getUID(), 'core', 'locale', null);
892
+        }
893
+        // fallback to attendee LANGUAGE parameter if language not set
894
+        if ($language === null && isset($attendee['LANGUAGE']) && $attendee['LANGUAGE'] instanceof Parameter) {
895
+            $language = $attendee['LANGUAGE']->getValue();
896
+        }
897
+        // fallback to system language if language not set
898
+        if ($language === null) {
899
+            $language = $this->l10nFactory->findGenericLanguage();
900
+        }
901
+        // fallback to system locale if locale not set
902
+        if ($locale === null) {
903
+            $locale = $this->l10nFactory->findLocale($language);
904
+        }
905
+        $this->l10n = $this->l10nFactory->get('dav', $language, $locale);
906
+    }
907
+
908
+    /**
909
+     * @param Property|null $attendee
910
+     * @return bool
911
+     */
912
+    public function getAttendeeRsvpOrReqForParticipant(?Property $attendee = null) {
913
+        if ($attendee === null) {
914
+            return false;
915
+        }
916
+
917
+        $rsvp = $attendee->offsetGet('RSVP');
918
+        if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) {
919
+            return true;
920
+        }
921
+        $role = $attendee->offsetGet('ROLE');
922
+        // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16
923
+        // Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set
924
+        if ($role === null
925
+            || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0))
926
+            || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0))
927
+        ) {
928
+            return true;
929
+        }
930
+
931
+        // RFC 5545 3.2.17: default RSVP is false
932
+        return false;
933
+    }
934
+
935
+    /**
936
+     * @param IEMailTemplate $template
937
+     * @param string $method
938
+     * @param string $sender
939
+     * @param string $summary
940
+     * @param string|null $partstat
941
+     * @param bool $isModified
942
+     */
943
+    public function addSubjectAndHeading(IEMailTemplate $template,
944
+        string $method, string $sender, string $summary, bool $isModified, ?Property $replyingAttendee = null): void {
945
+        if ($method === IMipPlugin::METHOD_CANCEL) {
946
+            // TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}"
947
+            $template->setSubject($this->l10n->t('Cancelled: %1$s', [$summary]));
948
+            $template->addHeading($this->l10n->t('"%1$s" has been canceled', [$summary]));
949
+        } elseif ($method === IMipPlugin::METHOD_REPLY) {
950
+            // TRANSLATORS Subject for email, when an invitation is replied to. Ex: "Re: {{Event Name}}"
951
+            $template->setSubject($this->l10n->t('Re: %1$s', [$summary]));
952
+            // Build the strings
953
+            $partstat = (isset($replyingAttendee)) ? $replyingAttendee->offsetGet('PARTSTAT') : null;
954
+            $partstat = ($partstat instanceof Parameter) ? $partstat->getValue() : null;
955
+            switch ($partstat) {
956
+                case 'ACCEPTED':
957
+                    $template->addHeading($this->l10n->t('%1$s has accepted your invitation', [$sender]));
958
+                    break;
959
+                case 'TENTATIVE':
960
+                    $template->addHeading($this->l10n->t('%1$s has tentatively accepted your invitation', [$sender]));
961
+                    break;
962
+                case 'DECLINED':
963
+                    $template->addHeading($this->l10n->t('%1$s has declined your invitation', [$sender]));
964
+                    break;
965
+                case null:
966
+                default:
967
+                    $template->addHeading($this->l10n->t('%1$s has responded to your invitation', [$sender]));
968
+                    break;
969
+            }
970
+        } elseif ($method === IMipPlugin::METHOD_REQUEST && $isModified) {
971
+            // TRANSLATORS Subject for email, when an invitation is updated. Ex: "Invitation updated: {{Event Name}}"
972
+            $template->setSubject($this->l10n->t('Invitation updated: %1$s', [$summary]));
973
+            $template->addHeading($this->l10n->t('%1$s updated the event "%2$s"', [$sender, $summary]));
974
+        } else {
975
+            // TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}"
976
+            $template->setSubject($this->l10n->t('Invitation: %1$s', [$summary]));
977
+            $template->addHeading($this->l10n->t('%1$s would like to invite you to "%2$s"', [$sender, $summary]));
978
+        }
979
+    }
980
+
981
+    /**
982
+     * @param string $path
983
+     * @return string
984
+     */
985
+    public function getAbsoluteImagePath($path): string {
986
+        return $this->urlGenerator->getAbsoluteURL(
987
+            $this->urlGenerator->imagePath('core', $path)
988
+        );
989
+    }
990
+
991
+    /**
992
+     * addAttendees: add organizer and attendee names/emails to iMip mail.
993
+     *
994
+     * Enable with DAV setting: invitation_list_attendees (default: no)
995
+     *
996
+     * The default is 'no', which matches old behavior, and is privacy preserving.
997
+     *
998
+     * To enable including attendees in invitation emails:
999
+     *   % php occ config:app:set dav invitation_list_attendees --value yes
1000
+     *
1001
+     * @param IEMailTemplate $template
1002
+     * @param IL10N $this->l10n
1003
+     * @param VEvent $vevent
1004
+     * @author brad2014 on github.com
1005
+     */
1006
+    public function addAttendees(IEMailTemplate $template, VEvent $vevent) {
1007
+        if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') {
1008
+            return;
1009
+        }
1010
+
1011
+        if (isset($vevent->ORGANIZER)) {
1012
+            /** @var Property | Property\ICalendar\CalAddress $organizer */
1013
+            $organizer = $vevent->ORGANIZER;
1014
+            $organizerEmail = substr($organizer->getNormalizedValue(), 7);
1015
+            /** @var string|null $organizerName */
1016
+            $organizerName = isset($organizer->CN) ? $organizer->CN->getValue() : null;
1017
+            $organizerHTML = sprintf('<a href="%s">%s</a>',
1018
+                htmlspecialchars($organizer->getNormalizedValue()),
1019
+                htmlspecialchars($organizerName ?: $organizerEmail));
1020
+            $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail);
1021
+            if (isset($organizer['PARTSTAT'])) {
1022
+                /** @var Parameter $partstat */
1023
+                $partstat = $organizer['PARTSTAT'];
1024
+                if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
1025
+                    $organizerHTML .= ' ✔︎';
1026
+                    $organizerText .= ' ✔︎';
1027
+                }
1028
+            }
1029
+            $template->addBodyListItem($organizerHTML, $this->l10n->t('Organizer:'),
1030
+                $this->getAbsoluteImagePath('caldav/organizer.png'),
1031
+                $organizerText, '', IMipPlugin::IMIP_INDENT);
1032
+        }
1033
+
1034
+        $attendees = $vevent->select('ATTENDEE');
1035
+        if (count($attendees) === 0) {
1036
+            return;
1037
+        }
1038
+
1039
+        $attendeesHTML = [];
1040
+        $attendeesText = [];
1041
+        foreach ($attendees as $attendee) {
1042
+            $attendeeEmail = substr($attendee->getNormalizedValue(), 7);
1043
+            $attendeeName = isset($attendee['CN']) ? $attendee['CN']->getValue() : null;
1044
+            $attendeeHTML = sprintf('<a href="%s">%s</a>',
1045
+                htmlspecialchars($attendee->getNormalizedValue()),
1046
+                htmlspecialchars($attendeeName ?: $attendeeEmail));
1047
+            $attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail);
1048
+            if (isset($attendee['PARTSTAT'])) {
1049
+                /** @var Parameter $partstat */
1050
+                $partstat = $attendee['PARTSTAT'];
1051
+                if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
1052
+                    $attendeeHTML .= ' ✔︎';
1053
+                    $attendeeText .= ' ✔︎';
1054
+                }
1055
+            }
1056
+            $attendeesHTML[] = $attendeeHTML;
1057
+            $attendeesText[] = $attendeeText;
1058
+        }
1059
+
1060
+        $template->addBodyListItem(implode('<br/>', $attendeesHTML), $this->l10n->t('Attendees:'),
1061
+            $this->getAbsoluteImagePath('caldav/attendees.png'),
1062
+            implode("\n", $attendeesText), '', IMipPlugin::IMIP_INDENT);
1063
+    }
1064
+
1065
+    /**
1066
+     * @param IEMailTemplate $template
1067
+     * @param VEVENT $vevent
1068
+     * @param $data
1069
+     */
1070
+    public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) {
1071
+        $template->addBodyListItem(
1072
+            $data['meeting_title_html'] ?? htmlspecialchars($data['meeting_title']), $this->l10n->t('Title:'),
1073
+            $this->getAbsoluteImagePath('caldav/title.png'), $data['meeting_title'], '', IMipPlugin::IMIP_INDENT);
1074
+        if ($data['meeting_when'] !== '') {
1075
+            $template->addBodyListItem($data['meeting_when_html'] ?? htmlspecialchars($data['meeting_when']), $this->l10n->t('When:'),
1076
+                $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_when'], '', IMipPlugin::IMIP_INDENT);
1077
+        }
1078
+        if ($data['meeting_location'] !== '') {
1079
+            $template->addBodyListItem($data['meeting_location_html'] ?? htmlspecialchars($data['meeting_location']), $this->l10n->t('Location:'),
1080
+                $this->getAbsoluteImagePath('caldav/location.png'), $data['meeting_location'], '', IMipPlugin::IMIP_INDENT);
1081
+        }
1082
+        if ($data['meeting_url'] !== '') {
1083
+            $template->addBodyListItem($data['meeting_url_html'] ?? htmlspecialchars($data['meeting_url']), $this->l10n->t('Link:'),
1084
+                $this->getAbsoluteImagePath('caldav/link.png'), $data['meeting_url'], '', IMipPlugin::IMIP_INDENT);
1085
+        }
1086
+        if (isset($data['meeting_occurring'])) {
1087
+            $template->addBodyListItem($data['meeting_occurring_html'] ?? htmlspecialchars($data['meeting_occurring']), $this->l10n->t('Occurring:'),
1088
+                $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_occurring'], '', IMipPlugin::IMIP_INDENT);
1089
+        }
1090
+
1091
+        $this->addAttendees($template, $vevent);
1092
+
1093
+        /* Put description last, like an email body, since it can be arbitrarily long */
1094
+        if ($data['meeting_description']) {
1095
+            $template->addBodyListItem($data['meeting_description_html'] ?? htmlspecialchars($data['meeting_description']), $this->l10n->t('Description:'),
1096
+                $this->getAbsoluteImagePath('caldav/description.png'), $data['meeting_description'], '', IMipPlugin::IMIP_INDENT);
1097
+        }
1098
+    }
1099
+
1100
+    /**
1101
+     * @param Message $iTipMessage
1102
+     * @return null|Property
1103
+     */
1104
+    public function getCurrentAttendee(Message $iTipMessage): ?Property {
1105
+        /** @var VEvent $vevent */
1106
+        $vevent = $iTipMessage->message->VEVENT;
1107
+        $attendees = $vevent->select('ATTENDEE');
1108
+        foreach ($attendees as $attendee) {
1109
+            if ($iTipMessage->method === 'REPLY' && strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
1110
+                /** @var Property $attendee */
1111
+                return $attendee;
1112
+            } elseif (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
1113
+                /** @var Property $attendee */
1114
+                return $attendee;
1115
+            }
1116
+        }
1117
+        return null;
1118
+    }
1119
+
1120
+    /**
1121
+     * @param Message $iTipMessage
1122
+     * @param VEvent $vevent
1123
+     * @param int $lastOccurrence
1124
+     * @return string
1125
+     */
1126
+    public function createInvitationToken(Message $iTipMessage, VEvent $vevent, int $lastOccurrence): string {
1127
+        $token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC);
1128
+
1129
+        $attendee = $iTipMessage->recipient;
1130
+        $organizer = $iTipMessage->sender;
1131
+        $sequence = $iTipMessage->sequence;
1132
+        $recurrenceId = isset($vevent->{'RECURRENCE-ID'})
1133
+            ? $vevent->{'RECURRENCE-ID'}->serialize() : null;
1134
+        $uid = $vevent->{'UID'}?->getValue();
1135
+
1136
+        $query = $this->db->getQueryBuilder();
1137
+        $query->insert('calendar_invitations')
1138
+            ->values([
1139
+                'token' => $query->createNamedParameter($token),
1140
+                'attendee' => $query->createNamedParameter($attendee),
1141
+                'organizer' => $query->createNamedParameter($organizer),
1142
+                'sequence' => $query->createNamedParameter($sequence),
1143
+                'recurrenceid' => $query->createNamedParameter($recurrenceId),
1144
+                'expiration' => $query->createNamedParameter($lastOccurrence),
1145
+                'uid' => $query->createNamedParameter($uid)
1146
+            ])
1147
+            ->executeStatement();
1148
+
1149
+        return $token;
1150
+    }
1151
+
1152
+    /**
1153
+     * @param IEMailTemplate $template
1154
+     * @param $token
1155
+     */
1156
+    public function addResponseButtons(IEMailTemplate $template, $token) {
1157
+        $template->addBodyButtonGroup(
1158
+            $this->l10n->t('Accept'),
1159
+            $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [
1160
+                'token' => $token,
1161
+            ]),
1162
+            $this->l10n->t('Decline'),
1163
+            $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [
1164
+                'token' => $token,
1165
+            ])
1166
+        );
1167
+    }
1168
+
1169
+    public function addMoreOptionsButton(IEMailTemplate $template, $token) {
1170
+        $moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [
1171
+            'token' => $token,
1172
+        ]);
1173
+        $html = vsprintf('<small><a href="%s">%s</a></small>', [
1174
+            $moreOptionsURL, $this->l10n->t('More options …')
1175
+        ]);
1176
+        $text = $this->l10n->t('More options at %s', [$moreOptionsURL]);
1177
+
1178
+        $template->addBodyText($html, $text);
1179
+    }
1180
+
1181
+    public function getReplyingAttendee(Message $iTipMessage): ?Property {
1182
+        /** @var VEvent $vevent */
1183
+        $vevent = $iTipMessage->message->VEVENT;
1184
+        $attendees = $vevent->select('ATTENDEE');
1185
+        foreach ($attendees as $attendee) {
1186
+            /** @var Property $attendee */
1187
+            if (strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
1188
+                return $attendee;
1189
+            }
1190
+        }
1191
+        return null;
1192
+    }
1193
+
1194
+    public function isRoomOrResource(Property $attendee): bool {
1195
+        $cuType = $attendee->offsetGet('CUTYPE');
1196
+        if (!$cuType instanceof Parameter) {
1197
+            return false;
1198
+        }
1199
+        $type = $cuType->getValue() ?? 'INDIVIDUAL';
1200
+        if (\in_array(strtoupper($type), ['RESOURCE', 'ROOM'], true)) {
1201
+            // Don't send emails to things
1202
+            return true;
1203
+        }
1204
+        return false;
1205
+    }
1206
+
1207
+    public function isCircle(Property $attendee): bool {
1208
+        $cuType = $attendee->offsetGet('CUTYPE');
1209
+        if (!$cuType instanceof Parameter) {
1210
+            return false;
1211
+        }
1212
+
1213
+        $uri = $attendee->getValue();
1214
+        if (!$uri) {
1215
+            return false;
1216
+        }
1217
+
1218
+        $cuTypeValue = $cuType->getValue();
1219
+        return $cuTypeValue === 'GROUP' && str_starts_with($uri, 'mailto:circle+');
1220
+    }
1221
+
1222
+    public function minimizeInterval(\DateInterval $dateInterval): array {
1223
+        // evaluate if time interval is in the past
1224
+        if ($dateInterval->invert == 1) {
1225
+            return ['interval' => 1, 'scale' => 'past'];
1226
+        }
1227
+        // evaluate interval parts and return smallest time period
1228
+        if ($dateInterval->y > 0) {
1229
+            $interval = $dateInterval->y;
1230
+            $scale = 'year';
1231
+        } elseif ($dateInterval->m > 0) {
1232
+            $interval = $dateInterval->m;
1233
+            $scale = 'month';
1234
+        } elseif ($dateInterval->d >= 7) {
1235
+            $interval = (int)($dateInterval->d / 7);
1236
+            $scale = 'week';
1237
+        } elseif ($dateInterval->d > 0) {
1238
+            $interval = $dateInterval->d;
1239
+            $scale = 'day';
1240
+        } elseif ($dateInterval->h > 0) {
1241
+            $interval = $dateInterval->h;
1242
+            $scale = 'hour';
1243
+        } else {
1244
+            $interval = $dateInterval->i;
1245
+            $scale = 'minute';
1246
+        }
1247
+
1248
+        return ['interval' => $interval, 'scale' => $scale];
1249
+    }
1250
+
1251
+    /**
1252
+     * Localizes week day names to another language
1253
+     *
1254
+     * @param string $value
1255
+     *
1256
+     * @return string
1257
+     */
1258
+    public function localizeDayName(string $value): string {
1259
+        return match ($value) {
1260
+            'Monday' => $this->l10n->t('Monday'),
1261
+            'Tuesday' => $this->l10n->t('Tuesday'),
1262
+            'Wednesday' => $this->l10n->t('Wednesday'),
1263
+            'Thursday' => $this->l10n->t('Thursday'),
1264
+            'Friday' => $this->l10n->t('Friday'),
1265
+            'Saturday' => $this->l10n->t('Saturday'),
1266
+            'Sunday' => $this->l10n->t('Sunday'),
1267
+        };
1268
+    }
1269
+
1270
+    /**
1271
+     * Localizes month names to another language
1272
+     *
1273
+     * @param string $value
1274
+     *
1275
+     * @return string
1276
+     */
1277
+    public function localizeMonthName(string $value): string {
1278
+        return match ($value) {
1279
+            'January' => $this->l10n->t('January'),
1280
+            'February' => $this->l10n->t('February'),
1281
+            'March' => $this->l10n->t('March'),
1282
+            'April' => $this->l10n->t('April'),
1283
+            'May' => $this->l10n->t('May'),
1284
+            'June' => $this->l10n->t('June'),
1285
+            'July' => $this->l10n->t('July'),
1286
+            'August' => $this->l10n->t('August'),
1287
+            'September' => $this->l10n->t('September'),
1288
+            'October' => $this->l10n->t('October'),
1289
+            'November' => $this->l10n->t('November'),
1290
+            'December' => $this->l10n->t('December'),
1291
+        };
1292
+    }
1293
+
1294
+    /**
1295
+     * Localizes relative position names to another language
1296
+     *
1297
+     * @param string $value
1298
+     *
1299
+     * @return string
1300
+     */
1301
+    public function localizeRelativePositionName(string $value): string {
1302
+        return match ($value) {
1303
+            'First' => $this->l10n->t('First'),
1304
+            'Second' => $this->l10n->t('Second'),
1305
+            'Third' => $this->l10n->t('Third'),
1306
+            'Fourth' => $this->l10n->t('Fourth'),
1307
+            'Fifth' => $this->l10n->t('Fifth'),
1308
+            'Last' => $this->l10n->t('Last'),
1309
+            'Second Last' => $this->l10n->t('Second Last'),
1310
+            'Third Last' => $this->l10n->t('Third Last'),
1311
+            'Fourth Last' => $this->l10n->t('Fourth Last'),
1312
+            'Fifth Last' => $this->l10n->t('Fifth Last'),
1313
+        };
1314
+    }
1315 1315
 }
Please login to merge, or discard this patch.
apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php 1 patch
Indentation   +422 added lines, -422 removed lines patch added patch discarded remove patch
@@ -31,426 +31,426 @@
 block discarded – undo
31 31
  * @package OCA\DAV\CalDAV\Reminder\NotificationProvider
32 32
  */
33 33
 class EmailProvider extends AbstractProvider {
34
-	/** @var string */
35
-	public const NOTIFICATION_TYPE = 'EMAIL';
36
-
37
-	public function __construct(
38
-		IConfig $config,
39
-		private IMailer $mailer,
40
-		LoggerInterface $logger,
41
-		L10NFactory $l10nFactory,
42
-		IURLGenerator $urlGenerator,
43
-		private IEmailValidator $emailValidator,
44
-	) {
45
-		parent::__construct($logger, $l10nFactory, $urlGenerator, $config);
46
-	}
47
-
48
-	/**
49
-	 * Send out notification via email
50
-	 *
51
-	 * @param VEvent $vevent
52
-	 * @param string|null $calendarDisplayName
53
-	 * @param string[] $principalEmailAddresses
54
-	 * @param array $users
55
-	 * @throws \Exception
56
-	 */
57
-	public function send(VEvent $vevent,
58
-		?string $calendarDisplayName,
59
-		array $principalEmailAddresses,
60
-		array $users = []):void {
61
-		$fallbackLanguage = $this->getFallbackLanguage();
62
-
63
-		$organizerEmailAddress = null;
64
-		if (isset($vevent->ORGANIZER)) {
65
-			$organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER);
66
-		}
67
-
68
-		$emailAddressesOfSharees = $this->getEMailAddressesOfAllUsersWithWriteAccessToCalendar($users);
69
-		$emailAddressesOfAttendees = [];
70
-		if (count($principalEmailAddresses) === 0
71
-			|| ($organizerEmailAddress && in_array($organizerEmailAddress, $principalEmailAddresses, true))
72
-		) {
73
-			$emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent);
74
-		}
75
-
76
-		// Quote from php.net:
77
-		// If the input arrays have the same string keys, then the later value for that key will overwrite the previous one.
78
-		// => if there are duplicate email addresses, it will always take the system value
79
-		$emailAddresses = array_merge(
80
-			$emailAddressesOfAttendees,
81
-			$emailAddressesOfSharees
82
-		);
83
-
84
-		$sortedByLanguage = $this->sortEMailAddressesByLanguage($emailAddresses, $fallbackLanguage);
85
-		$organizer = $this->getOrganizerEMailAndNameFromEvent($vevent);
86
-
87
-		foreach ($sortedByLanguage as $lang => $emailAddresses) {
88
-			if (!$this->hasL10NForLang($lang)) {
89
-				$lang = $fallbackLanguage;
90
-			}
91
-			$l10n = $this->getL10NForLang($lang);
92
-			$fromEMail = Util::getDefaultEmailAddress('reminders-noreply');
93
-
94
-			$template = $this->mailer->createEMailTemplate('dav.calendarReminder');
95
-			$template->addHeader();
96
-			$this->addSubjectAndHeading($template, $l10n, $vevent);
97
-			$this->addBulletList($template, $l10n, $calendarDisplayName ?? $this->getCalendarDisplayNameFallback($lang), $vevent);
98
-			$template->addFooter();
99
-
100
-			foreach ($emailAddresses as $emailAddress) {
101
-				if (!$this->emailValidator->isValid($emailAddress)) {
102
-					$this->logger->error('Email address {address} for reminder notification is incorrect', ['app' => 'dav', 'address' => $emailAddress]);
103
-					continue;
104
-				}
105
-
106
-				$message = $this->mailer->createMessage();
107
-				$message->setFrom([$fromEMail]);
108
-				if ($organizer) {
109
-					$message->setReplyTo($organizer);
110
-				}
111
-				$message->setTo([$emailAddress]);
112
-				$message->useTemplate($template);
113
-				$message->setAutoSubmitted(AutoSubmitted::VALUE_AUTO_GENERATED);
114
-
115
-				try {
116
-					$failed = $this->mailer->send($message);
117
-					if ($failed) {
118
-						$this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]);
119
-					}
120
-				} catch (\Exception $ex) {
121
-					$this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]);
122
-				}
123
-			}
124
-		}
125
-	}
126
-
127
-	/**
128
-	 * @param IEMailTemplate $template
129
-	 * @param IL10N $l10n
130
-	 * @param VEvent $vevent
131
-	 */
132
-	private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, VEvent $vevent):void {
133
-		$template->setSubject('Notification: ' . $this->getTitleFromVEvent($vevent, $l10n));
134
-		$template->addHeading($this->getTitleFromVEvent($vevent, $l10n));
135
-	}
136
-
137
-	/**
138
-	 * @param IEMailTemplate $template
139
-	 * @param IL10N $l10n
140
-	 * @param string $calendarDisplayName
141
-	 * @param array $eventData
142
-	 */
143
-	private function addBulletList(IEMailTemplate $template,
144
-		IL10N $l10n,
145
-		string $calendarDisplayName,
146
-		VEvent $vevent):void {
147
-		$template->addBodyListItem(
148
-			htmlspecialchars($calendarDisplayName),
149
-			$l10n->t('Calendar:'),
150
-			$this->getAbsoluteImagePath('actions/info.png'),
151
-			htmlspecialchars($calendarDisplayName),
152
-		);
153
-
154
-		$template->addBodyListItem($this->generateDateString($l10n, $vevent), $l10n->t('Date:'),
155
-			$this->getAbsoluteImagePath('places/calendar.png'));
156
-
157
-		if (isset($vevent->LOCATION)) {
158
-			$template->addBodyListItem(
159
-				htmlspecialchars((string)$vevent->LOCATION),
160
-				$l10n->t('Where:'),
161
-				$this->getAbsoluteImagePath('actions/address.png'),
162
-				htmlspecialchars((string)$vevent->LOCATION),
163
-			);
164
-		}
165
-		if (isset($vevent->DESCRIPTION)) {
166
-			$template->addBodyListItem(
167
-				htmlspecialchars((string)$vevent->DESCRIPTION),
168
-				$l10n->t('Description:'),
169
-				$this->getAbsoluteImagePath('actions/more.png'),
170
-				htmlspecialchars((string)$vevent->DESCRIPTION),
171
-			);
172
-		}
173
-	}
174
-
175
-	private function getAbsoluteImagePath(string $path):string {
176
-		return $this->urlGenerator->getAbsoluteURL(
177
-			$this->urlGenerator->imagePath('core', $path)
178
-		);
179
-	}
180
-
181
-	/**
182
-	 * @param VEvent $vevent
183
-	 * @return array|null
184
-	 */
185
-	private function getOrganizerEMailAndNameFromEvent(VEvent $vevent):?array {
186
-		if (!$vevent->ORGANIZER) {
187
-			return null;
188
-		}
189
-
190
-		$organizer = $vevent->ORGANIZER;
191
-		if (strcasecmp($organizer->getValue(), 'mailto:') !== 0) {
192
-			return null;
193
-		}
194
-
195
-		$organizerEMail = substr($organizer->getValue(), 7);
196
-
197
-		if (!$this->emailValidator->isValid($organizerEMail)) {
198
-			return null;
199
-		}
200
-
201
-		$name = $organizer->offsetGet('CN');
202
-		if ($name instanceof Parameter) {
203
-			return [$organizerEMail => $name];
204
-		}
205
-
206
-		return [$organizerEMail];
207
-	}
208
-
209
-	/**
210
-	 * @param array<string, array{LANG?: string}> $emails
211
-	 * @return array<string, string[]>
212
-	 */
213
-	private function sortEMailAddressesByLanguage(array $emails,
214
-		string $defaultLanguage):array {
215
-		$sortedByLanguage = [];
216
-
217
-		foreach ($emails as $emailAddress => $parameters) {
218
-			if (isset($parameters['LANG'])) {
219
-				$lang = $parameters['LANG'];
220
-			} else {
221
-				$lang = $defaultLanguage;
222
-			}
223
-
224
-			if (!isset($sortedByLanguage[$lang])) {
225
-				$sortedByLanguage[$lang] = [];
226
-			}
227
-
228
-			$sortedByLanguage[$lang][] = $emailAddress;
229
-		}
230
-
231
-		return $sortedByLanguage;
232
-	}
233
-
234
-	/**
235
-	 * @param VEvent $vevent
236
-	 * @return array<string, array{LANG?: string}>
237
-	 */
238
-	private function getAllEMailAddressesFromEvent(VEvent $vevent):array {
239
-		$emailAddresses = [];
240
-
241
-		if (isset($vevent->ATTENDEE)) {
242
-			foreach ($vevent->ATTENDEE as $attendee) {
243
-				if (!($attendee instanceof VObject\Property)) {
244
-					continue;
245
-				}
246
-
247
-				$cuType = $this->getCUTypeOfAttendee($attendee);
248
-				if (\in_array($cuType, ['RESOURCE', 'ROOM', 'UNKNOWN'])) {
249
-					// Don't send emails to things
250
-					continue;
251
-				}
252
-
253
-				$partstat = $this->getPartstatOfAttendee($attendee);
254
-				if ($partstat === 'DECLINED') {
255
-					// Don't send out emails to people who declined
256
-					continue;
257
-				}
258
-				if ($partstat === 'DELEGATED') {
259
-					$delegates = $attendee->offsetGet('DELEGATED-TO');
260
-					if (!($delegates instanceof VObject\Parameter)) {
261
-						continue;
262
-					}
263
-
264
-					$emailAddressesOfDelegates = $delegates->getParts();
265
-					foreach ($emailAddressesOfDelegates as $addressesOfDelegate) {
266
-						if (strcasecmp($addressesOfDelegate, 'mailto:') === 0) {
267
-							$delegateEmail = substr($addressesOfDelegate, 7);
268
-							if ($this->emailValidator->isValid($delegateEmail)) {
269
-								$emailAddresses[$delegateEmail] = [];
270
-							}
271
-						}
272
-					}
273
-
274
-					continue;
275
-				}
276
-
277
-				$emailAddressOfAttendee = $this->getEMailAddressOfAttendee($attendee);
278
-				if ($emailAddressOfAttendee !== null) {
279
-					$properties = [];
280
-
281
-					$langProp = $attendee->offsetGet('LANG');
282
-					if ($langProp instanceof VObject\Parameter && $langProp->getValue() !== null) {
283
-						$properties['LANG'] = $langProp->getValue();
284
-					}
285
-
286
-					$emailAddresses[$emailAddressOfAttendee] = $properties;
287
-				}
288
-			}
289
-		}
290
-
291
-		if (isset($vevent->ORGANIZER) && $this->hasAttendeeMailURI($vevent->ORGANIZER)) {
292
-			$organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER);
293
-			if ($organizerEmailAddress !== null) {
294
-				$emailAddresses[$organizerEmailAddress] = [];
295
-			}
296
-		}
297
-
298
-		return $emailAddresses;
299
-	}
300
-
301
-	private function getCUTypeOfAttendee(VObject\Property $attendee):string {
302
-		$cuType = $attendee->offsetGet('CUTYPE');
303
-		if ($cuType instanceof VObject\Parameter) {
304
-			return strtoupper($cuType->getValue());
305
-		}
306
-
307
-		return 'INDIVIDUAL';
308
-	}
309
-
310
-	private function getPartstatOfAttendee(VObject\Property $attendee):string {
311
-		$partstat = $attendee->offsetGet('PARTSTAT');
312
-		if ($partstat instanceof VObject\Parameter) {
313
-			return strtoupper($partstat->getValue());
314
-		}
315
-
316
-		return 'NEEDS-ACTION';
317
-	}
318
-
319
-	private function hasAttendeeMailURI(VObject\Property $attendee): bool {
320
-		return stripos($attendee->getValue(), 'mailto:') === 0;
321
-	}
322
-
323
-	private function getEMailAddressOfAttendee(VObject\Property $attendee): ?string {
324
-		if (!$this->hasAttendeeMailURI($attendee)) {
325
-			return null;
326
-		}
327
-		$attendeeEMail = substr($attendee->getValue(), 7);
328
-		if (!$this->emailValidator->isValid($attendeeEMail)) {
329
-			return null;
330
-		}
331
-
332
-		return $attendeeEMail;
333
-	}
334
-
335
-	/**
336
-	 * @param IUser[] $users
337
-	 * @return array<string, array{LANG?: string}>
338
-	 */
339
-	private function getEMailAddressesOfAllUsersWithWriteAccessToCalendar(array $users):array {
340
-		$emailAddresses = [];
341
-
342
-		foreach ($users as $user) {
343
-			$emailAddress = $user->getEMailAddress();
344
-			if ($emailAddress) {
345
-				$lang = $this->l10nFactory->getUserLanguage($user);
346
-				if ($lang) {
347
-					$emailAddresses[$emailAddress] = [
348
-						'LANG' => $lang,
349
-					];
350
-				} else {
351
-					$emailAddresses[$emailAddress] = [];
352
-				}
353
-			}
354
-		}
355
-
356
-		return $emailAddresses;
357
-	}
358
-
359
-	/**
360
-	 * @throws \Exception
361
-	 */
362
-	private function generateDateString(IL10N $l10n, VEvent $vevent): string {
363
-		$isAllDay = $vevent->DTSTART instanceof Property\ICalendar\Date;
364
-
365
-		/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */
366
-		/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */
367
-		/** @var \DateTimeImmutable $dtstartDt */
368
-		$dtstartDt = $vevent->DTSTART->getDateTime();
369
-		/** @var \DateTimeImmutable $dtendDt */
370
-		$dtendDt = $this->getDTEndFromEvent($vevent)->getDateTime();
371
-
372
-		$diff = $dtstartDt->diff($dtendDt);
373
-
374
-		$dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM));
375
-		$dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM));
376
-
377
-		if ($isAllDay) {
378
-			// One day event
379
-			if ($diff->days === 1) {
380
-				return $this->getDateString($l10n, $dtstartDt);
381
-			}
382
-
383
-			return implode(' - ', [
384
-				$this->getDateString($l10n, $dtstartDt),
385
-				$this->getDateString($l10n, $dtendDt),
386
-			]);
387
-		}
388
-
389
-		$startTimezone = $endTimezone = null;
390
-		if (!$vevent->DTSTART->isFloating()) {
391
-			$startTimezone = $vevent->DTSTART->getDateTime()->getTimezone()->getName();
392
-			$endTimezone = $this->getDTEndFromEvent($vevent)->getDateTime()->getTimezone()->getName();
393
-		}
394
-
395
-		$localeStart = implode(', ', [
396
-			$this->getWeekDayName($l10n, $dtstartDt),
397
-			$this->getDateTimeString($l10n, $dtstartDt)
398
-		]);
399
-
400
-		// always show full date with timezone if timezones are different
401
-		if ($startTimezone !== $endTimezone) {
402
-			$localeEnd = implode(', ', [
403
-				$this->getWeekDayName($l10n, $dtendDt),
404
-				$this->getDateTimeString($l10n, $dtendDt)
405
-			]);
406
-
407
-			return $localeStart
408
-				. ' (' . $startTimezone . ') '
409
-				. ' - '
410
-				. $localeEnd
411
-				. ' (' . $endTimezone . ')';
412
-		}
413
-
414
-		// Show only the time if the day is the same
415
-		$localeEnd = $this->isDayEqual($dtstartDt, $dtendDt)
416
-			? $this->getTimeString($l10n, $dtendDt)
417
-			: implode(', ', [
418
-				$this->getWeekDayName($l10n, $dtendDt),
419
-				$this->getDateTimeString($l10n, $dtendDt)
420
-			]);
421
-
422
-		return $localeStart
423
-			. ' - '
424
-			. $localeEnd
425
-			. ' (' . $startTimezone . ')';
426
-	}
427
-
428
-	private function isDayEqual(DateTime $dtStart,
429
-		DateTime $dtEnd):bool {
430
-		return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d');
431
-	}
432
-
433
-	private function getWeekDayName(IL10N $l10n, DateTime $dt):string {
434
-		return (string)$l10n->l('weekdayName', $dt, ['width' => 'abbreviated']);
435
-	}
436
-
437
-	private function getDateString(IL10N $l10n, DateTime $dt):string {
438
-		return (string)$l10n->l('date', $dt, ['width' => 'medium']);
439
-	}
440
-
441
-	private function getDateTimeString(IL10N $l10n, DateTime $dt):string {
442
-		return (string)$l10n->l('datetime', $dt, ['width' => 'medium|short']);
443
-	}
444
-
445
-	private function getTimeString(IL10N $l10n, DateTime $dt):string {
446
-		return (string)$l10n->l('time', $dt, ['width' => 'short']);
447
-	}
448
-
449
-	private function getTitleFromVEvent(VEvent $vevent, IL10N $l10n):string {
450
-		if (isset($vevent->SUMMARY)) {
451
-			return (string)$vevent->SUMMARY;
452
-		}
453
-
454
-		return $l10n->t('Untitled event');
455
-	}
34
+    /** @var string */
35
+    public const NOTIFICATION_TYPE = 'EMAIL';
36
+
37
+    public function __construct(
38
+        IConfig $config,
39
+        private IMailer $mailer,
40
+        LoggerInterface $logger,
41
+        L10NFactory $l10nFactory,
42
+        IURLGenerator $urlGenerator,
43
+        private IEmailValidator $emailValidator,
44
+    ) {
45
+        parent::__construct($logger, $l10nFactory, $urlGenerator, $config);
46
+    }
47
+
48
+    /**
49
+     * Send out notification via email
50
+     *
51
+     * @param VEvent $vevent
52
+     * @param string|null $calendarDisplayName
53
+     * @param string[] $principalEmailAddresses
54
+     * @param array $users
55
+     * @throws \Exception
56
+     */
57
+    public function send(VEvent $vevent,
58
+        ?string $calendarDisplayName,
59
+        array $principalEmailAddresses,
60
+        array $users = []):void {
61
+        $fallbackLanguage = $this->getFallbackLanguage();
62
+
63
+        $organizerEmailAddress = null;
64
+        if (isset($vevent->ORGANIZER)) {
65
+            $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER);
66
+        }
67
+
68
+        $emailAddressesOfSharees = $this->getEMailAddressesOfAllUsersWithWriteAccessToCalendar($users);
69
+        $emailAddressesOfAttendees = [];
70
+        if (count($principalEmailAddresses) === 0
71
+            || ($organizerEmailAddress && in_array($organizerEmailAddress, $principalEmailAddresses, true))
72
+        ) {
73
+            $emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent);
74
+        }
75
+
76
+        // Quote from php.net:
77
+        // If the input arrays have the same string keys, then the later value for that key will overwrite the previous one.
78
+        // => if there are duplicate email addresses, it will always take the system value
79
+        $emailAddresses = array_merge(
80
+            $emailAddressesOfAttendees,
81
+            $emailAddressesOfSharees
82
+        );
83
+
84
+        $sortedByLanguage = $this->sortEMailAddressesByLanguage($emailAddresses, $fallbackLanguage);
85
+        $organizer = $this->getOrganizerEMailAndNameFromEvent($vevent);
86
+
87
+        foreach ($sortedByLanguage as $lang => $emailAddresses) {
88
+            if (!$this->hasL10NForLang($lang)) {
89
+                $lang = $fallbackLanguage;
90
+            }
91
+            $l10n = $this->getL10NForLang($lang);
92
+            $fromEMail = Util::getDefaultEmailAddress('reminders-noreply');
93
+
94
+            $template = $this->mailer->createEMailTemplate('dav.calendarReminder');
95
+            $template->addHeader();
96
+            $this->addSubjectAndHeading($template, $l10n, $vevent);
97
+            $this->addBulletList($template, $l10n, $calendarDisplayName ?? $this->getCalendarDisplayNameFallback($lang), $vevent);
98
+            $template->addFooter();
99
+
100
+            foreach ($emailAddresses as $emailAddress) {
101
+                if (!$this->emailValidator->isValid($emailAddress)) {
102
+                    $this->logger->error('Email address {address} for reminder notification is incorrect', ['app' => 'dav', 'address' => $emailAddress]);
103
+                    continue;
104
+                }
105
+
106
+                $message = $this->mailer->createMessage();
107
+                $message->setFrom([$fromEMail]);
108
+                if ($organizer) {
109
+                    $message->setReplyTo($organizer);
110
+                }
111
+                $message->setTo([$emailAddress]);
112
+                $message->useTemplate($template);
113
+                $message->setAutoSubmitted(AutoSubmitted::VALUE_AUTO_GENERATED);
114
+
115
+                try {
116
+                    $failed = $this->mailer->send($message);
117
+                    if ($failed) {
118
+                        $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]);
119
+                    }
120
+                } catch (\Exception $ex) {
121
+                    $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]);
122
+                }
123
+            }
124
+        }
125
+    }
126
+
127
+    /**
128
+     * @param IEMailTemplate $template
129
+     * @param IL10N $l10n
130
+     * @param VEvent $vevent
131
+     */
132
+    private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, VEvent $vevent):void {
133
+        $template->setSubject('Notification: ' . $this->getTitleFromVEvent($vevent, $l10n));
134
+        $template->addHeading($this->getTitleFromVEvent($vevent, $l10n));
135
+    }
136
+
137
+    /**
138
+     * @param IEMailTemplate $template
139
+     * @param IL10N $l10n
140
+     * @param string $calendarDisplayName
141
+     * @param array $eventData
142
+     */
143
+    private function addBulletList(IEMailTemplate $template,
144
+        IL10N $l10n,
145
+        string $calendarDisplayName,
146
+        VEvent $vevent):void {
147
+        $template->addBodyListItem(
148
+            htmlspecialchars($calendarDisplayName),
149
+            $l10n->t('Calendar:'),
150
+            $this->getAbsoluteImagePath('actions/info.png'),
151
+            htmlspecialchars($calendarDisplayName),
152
+        );
153
+
154
+        $template->addBodyListItem($this->generateDateString($l10n, $vevent), $l10n->t('Date:'),
155
+            $this->getAbsoluteImagePath('places/calendar.png'));
156
+
157
+        if (isset($vevent->LOCATION)) {
158
+            $template->addBodyListItem(
159
+                htmlspecialchars((string)$vevent->LOCATION),
160
+                $l10n->t('Where:'),
161
+                $this->getAbsoluteImagePath('actions/address.png'),
162
+                htmlspecialchars((string)$vevent->LOCATION),
163
+            );
164
+        }
165
+        if (isset($vevent->DESCRIPTION)) {
166
+            $template->addBodyListItem(
167
+                htmlspecialchars((string)$vevent->DESCRIPTION),
168
+                $l10n->t('Description:'),
169
+                $this->getAbsoluteImagePath('actions/more.png'),
170
+                htmlspecialchars((string)$vevent->DESCRIPTION),
171
+            );
172
+        }
173
+    }
174
+
175
+    private function getAbsoluteImagePath(string $path):string {
176
+        return $this->urlGenerator->getAbsoluteURL(
177
+            $this->urlGenerator->imagePath('core', $path)
178
+        );
179
+    }
180
+
181
+    /**
182
+     * @param VEvent $vevent
183
+     * @return array|null
184
+     */
185
+    private function getOrganizerEMailAndNameFromEvent(VEvent $vevent):?array {
186
+        if (!$vevent->ORGANIZER) {
187
+            return null;
188
+        }
189
+
190
+        $organizer = $vevent->ORGANIZER;
191
+        if (strcasecmp($organizer->getValue(), 'mailto:') !== 0) {
192
+            return null;
193
+        }
194
+
195
+        $organizerEMail = substr($organizer->getValue(), 7);
196
+
197
+        if (!$this->emailValidator->isValid($organizerEMail)) {
198
+            return null;
199
+        }
200
+
201
+        $name = $organizer->offsetGet('CN');
202
+        if ($name instanceof Parameter) {
203
+            return [$organizerEMail => $name];
204
+        }
205
+
206
+        return [$organizerEMail];
207
+    }
208
+
209
+    /**
210
+     * @param array<string, array{LANG?: string}> $emails
211
+     * @return array<string, string[]>
212
+     */
213
+    private function sortEMailAddressesByLanguage(array $emails,
214
+        string $defaultLanguage):array {
215
+        $sortedByLanguage = [];
216
+
217
+        foreach ($emails as $emailAddress => $parameters) {
218
+            if (isset($parameters['LANG'])) {
219
+                $lang = $parameters['LANG'];
220
+            } else {
221
+                $lang = $defaultLanguage;
222
+            }
223
+
224
+            if (!isset($sortedByLanguage[$lang])) {
225
+                $sortedByLanguage[$lang] = [];
226
+            }
227
+
228
+            $sortedByLanguage[$lang][] = $emailAddress;
229
+        }
230
+
231
+        return $sortedByLanguage;
232
+    }
233
+
234
+    /**
235
+     * @param VEvent $vevent
236
+     * @return array<string, array{LANG?: string}>
237
+     */
238
+    private function getAllEMailAddressesFromEvent(VEvent $vevent):array {
239
+        $emailAddresses = [];
240
+
241
+        if (isset($vevent->ATTENDEE)) {
242
+            foreach ($vevent->ATTENDEE as $attendee) {
243
+                if (!($attendee instanceof VObject\Property)) {
244
+                    continue;
245
+                }
246
+
247
+                $cuType = $this->getCUTypeOfAttendee($attendee);
248
+                if (\in_array($cuType, ['RESOURCE', 'ROOM', 'UNKNOWN'])) {
249
+                    // Don't send emails to things
250
+                    continue;
251
+                }
252
+
253
+                $partstat = $this->getPartstatOfAttendee($attendee);
254
+                if ($partstat === 'DECLINED') {
255
+                    // Don't send out emails to people who declined
256
+                    continue;
257
+                }
258
+                if ($partstat === 'DELEGATED') {
259
+                    $delegates = $attendee->offsetGet('DELEGATED-TO');
260
+                    if (!($delegates instanceof VObject\Parameter)) {
261
+                        continue;
262
+                    }
263
+
264
+                    $emailAddressesOfDelegates = $delegates->getParts();
265
+                    foreach ($emailAddressesOfDelegates as $addressesOfDelegate) {
266
+                        if (strcasecmp($addressesOfDelegate, 'mailto:') === 0) {
267
+                            $delegateEmail = substr($addressesOfDelegate, 7);
268
+                            if ($this->emailValidator->isValid($delegateEmail)) {
269
+                                $emailAddresses[$delegateEmail] = [];
270
+                            }
271
+                        }
272
+                    }
273
+
274
+                    continue;
275
+                }
276
+
277
+                $emailAddressOfAttendee = $this->getEMailAddressOfAttendee($attendee);
278
+                if ($emailAddressOfAttendee !== null) {
279
+                    $properties = [];
280
+
281
+                    $langProp = $attendee->offsetGet('LANG');
282
+                    if ($langProp instanceof VObject\Parameter && $langProp->getValue() !== null) {
283
+                        $properties['LANG'] = $langProp->getValue();
284
+                    }
285
+
286
+                    $emailAddresses[$emailAddressOfAttendee] = $properties;
287
+                }
288
+            }
289
+        }
290
+
291
+        if (isset($vevent->ORGANIZER) && $this->hasAttendeeMailURI($vevent->ORGANIZER)) {
292
+            $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER);
293
+            if ($organizerEmailAddress !== null) {
294
+                $emailAddresses[$organizerEmailAddress] = [];
295
+            }
296
+        }
297
+
298
+        return $emailAddresses;
299
+    }
300
+
301
+    private function getCUTypeOfAttendee(VObject\Property $attendee):string {
302
+        $cuType = $attendee->offsetGet('CUTYPE');
303
+        if ($cuType instanceof VObject\Parameter) {
304
+            return strtoupper($cuType->getValue());
305
+        }
306
+
307
+        return 'INDIVIDUAL';
308
+    }
309
+
310
+    private function getPartstatOfAttendee(VObject\Property $attendee):string {
311
+        $partstat = $attendee->offsetGet('PARTSTAT');
312
+        if ($partstat instanceof VObject\Parameter) {
313
+            return strtoupper($partstat->getValue());
314
+        }
315
+
316
+        return 'NEEDS-ACTION';
317
+    }
318
+
319
+    private function hasAttendeeMailURI(VObject\Property $attendee): bool {
320
+        return stripos($attendee->getValue(), 'mailto:') === 0;
321
+    }
322
+
323
+    private function getEMailAddressOfAttendee(VObject\Property $attendee): ?string {
324
+        if (!$this->hasAttendeeMailURI($attendee)) {
325
+            return null;
326
+        }
327
+        $attendeeEMail = substr($attendee->getValue(), 7);
328
+        if (!$this->emailValidator->isValid($attendeeEMail)) {
329
+            return null;
330
+        }
331
+
332
+        return $attendeeEMail;
333
+    }
334
+
335
+    /**
336
+     * @param IUser[] $users
337
+     * @return array<string, array{LANG?: string}>
338
+     */
339
+    private function getEMailAddressesOfAllUsersWithWriteAccessToCalendar(array $users):array {
340
+        $emailAddresses = [];
341
+
342
+        foreach ($users as $user) {
343
+            $emailAddress = $user->getEMailAddress();
344
+            if ($emailAddress) {
345
+                $lang = $this->l10nFactory->getUserLanguage($user);
346
+                if ($lang) {
347
+                    $emailAddresses[$emailAddress] = [
348
+                        'LANG' => $lang,
349
+                    ];
350
+                } else {
351
+                    $emailAddresses[$emailAddress] = [];
352
+                }
353
+            }
354
+        }
355
+
356
+        return $emailAddresses;
357
+    }
358
+
359
+    /**
360
+     * @throws \Exception
361
+     */
362
+    private function generateDateString(IL10N $l10n, VEvent $vevent): string {
363
+        $isAllDay = $vevent->DTSTART instanceof Property\ICalendar\Date;
364
+
365
+        /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */
366
+        /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */
367
+        /** @var \DateTimeImmutable $dtstartDt */
368
+        $dtstartDt = $vevent->DTSTART->getDateTime();
369
+        /** @var \DateTimeImmutable $dtendDt */
370
+        $dtendDt = $this->getDTEndFromEvent($vevent)->getDateTime();
371
+
372
+        $diff = $dtstartDt->diff($dtendDt);
373
+
374
+        $dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM));
375
+        $dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM));
376
+
377
+        if ($isAllDay) {
378
+            // One day event
379
+            if ($diff->days === 1) {
380
+                return $this->getDateString($l10n, $dtstartDt);
381
+            }
382
+
383
+            return implode(' - ', [
384
+                $this->getDateString($l10n, $dtstartDt),
385
+                $this->getDateString($l10n, $dtendDt),
386
+            ]);
387
+        }
388
+
389
+        $startTimezone = $endTimezone = null;
390
+        if (!$vevent->DTSTART->isFloating()) {
391
+            $startTimezone = $vevent->DTSTART->getDateTime()->getTimezone()->getName();
392
+            $endTimezone = $this->getDTEndFromEvent($vevent)->getDateTime()->getTimezone()->getName();
393
+        }
394
+
395
+        $localeStart = implode(', ', [
396
+            $this->getWeekDayName($l10n, $dtstartDt),
397
+            $this->getDateTimeString($l10n, $dtstartDt)
398
+        ]);
399
+
400
+        // always show full date with timezone if timezones are different
401
+        if ($startTimezone !== $endTimezone) {
402
+            $localeEnd = implode(', ', [
403
+                $this->getWeekDayName($l10n, $dtendDt),
404
+                $this->getDateTimeString($l10n, $dtendDt)
405
+            ]);
406
+
407
+            return $localeStart
408
+                . ' (' . $startTimezone . ') '
409
+                . ' - '
410
+                . $localeEnd
411
+                . ' (' . $endTimezone . ')';
412
+        }
413
+
414
+        // Show only the time if the day is the same
415
+        $localeEnd = $this->isDayEqual($dtstartDt, $dtendDt)
416
+            ? $this->getTimeString($l10n, $dtendDt)
417
+            : implode(', ', [
418
+                $this->getWeekDayName($l10n, $dtendDt),
419
+                $this->getDateTimeString($l10n, $dtendDt)
420
+            ]);
421
+
422
+        return $localeStart
423
+            . ' - '
424
+            . $localeEnd
425
+            . ' (' . $startTimezone . ')';
426
+    }
427
+
428
+    private function isDayEqual(DateTime $dtStart,
429
+        DateTime $dtEnd):bool {
430
+        return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d');
431
+    }
432
+
433
+    private function getWeekDayName(IL10N $l10n, DateTime $dt):string {
434
+        return (string)$l10n->l('weekdayName', $dt, ['width' => 'abbreviated']);
435
+    }
436
+
437
+    private function getDateString(IL10N $l10n, DateTime $dt):string {
438
+        return (string)$l10n->l('date', $dt, ['width' => 'medium']);
439
+    }
440
+
441
+    private function getDateTimeString(IL10N $l10n, DateTime $dt):string {
442
+        return (string)$l10n->l('datetime', $dt, ['width' => 'medium|short']);
443
+    }
444
+
445
+    private function getTimeString(IL10N $l10n, DateTime $dt):string {
446
+        return (string)$l10n->l('time', $dt, ['width' => 'short']);
447
+    }
448
+
449
+    private function getTitleFromVEvent(VEvent $vevent, IL10N $l10n):string {
450
+        if (isset($vevent->SUMMARY)) {
451
+            return (string)$vevent->SUMMARY;
452
+        }
453
+
454
+        return $l10n->t('Untitled event');
455
+    }
456 456
 }
Please login to merge, or discard this patch.