Completed
Push — master ( dc97bc...c81d40 )
by
unknown
30:51
created
apps/dav/lib/CalDAV/Schedule/IMipService.php 1 patch
Indentation   +1264 added lines, -1264 removed lines patch added patch discarded remove patch
@@ -27,1268 +27,1268 @@
 block discarded – undo
27 27
 
28 28
 class IMipService {
29 29
 
30
-	private IL10N $l10n;
31
-
32
-	/** @var string[] */
33
-	private const STRING_DIFF = [
34
-		'meeting_title' => 'SUMMARY',
35
-		'meeting_description' => 'DESCRIPTION',
36
-		'meeting_url' => 'URL',
37
-		'meeting_location' => 'LOCATION'
38
-	];
39
-
40
-	public function __construct(
41
-		private URLGenerator $urlGenerator,
42
-		private IConfig $config,
43
-		private IDBConnection $db,
44
-		private ISecureRandom $random,
45
-		private L10NFactory $l10nFactory,
46
-		private ITimeFactory $timeFactory,
47
-	) {
48
-		$language = $this->l10nFactory->findGenericLanguage();
49
-		$locale = $this->l10nFactory->findLocale($language);
50
-		$this->l10n = $this->l10nFactory->get('dav', $language, $locale);
51
-	}
52
-
53
-	/**
54
-	 * @param string|null $senderName
55
-	 * @param string $default
56
-	 * @return string
57
-	 */
58
-	public function getFrom(?string $senderName, string $default): string {
59
-		if ($senderName === null) {
60
-			return $default;
61
-		}
62
-
63
-		return $this->l10n->t('%1$s via %2$s', [$senderName, $default]);
64
-	}
65
-
66
-	public static function readPropertyWithDefault(VEvent $vevent, string $property, string $default) {
67
-		if (isset($vevent->$property)) {
68
-			$value = $vevent->$property->getValue();
69
-			if (!empty($value)) {
70
-				return $value;
71
-			}
72
-		}
73
-		return $default;
74
-	}
75
-
76
-	private function generateDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
77
-		$strikethrough = "<span style='text-decoration: line-through'>%s</span><br />%s";
78
-		if (!isset($vevent->$property)) {
79
-			return $default;
80
-		}
81
-		$newstring = $vevent->$property->getValue();
82
-		if (isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newstring) {
83
-			$oldstring = $oldVEvent->$property->getValue();
84
-			return sprintf($strikethrough, $oldstring, $newstring);
85
-		}
86
-		return $newstring;
87
-	}
88
-
89
-	/**
90
-	 * Like generateDiffString() but linkifies the property values if they are urls.
91
-	 */
92
-	private function generateLinkifiedDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
93
-		if (!isset($vevent->$property)) {
94
-			return $default;
95
-		}
96
-		/** @var string|null $newString */
97
-		$newString = $vevent->$property->getValue();
98
-		$oldString = isset($oldVEvent->$property) ? $oldVEvent->$property->getValue() : null;
99
-		if ($oldString !== $newString) {
100
-			return sprintf(
101
-				"<span style='text-decoration: line-through'>%s</span><br />%s",
102
-				$this->linkify($oldString) ?? $oldString ?? '',
103
-				$this->linkify($newString) ?? $newString ?? ''
104
-			);
105
-		}
106
-		return $this->linkify($newString) ?? $newString;
107
-	}
108
-
109
-	/**
110
-	 * Convert a given url to a html link element or return null otherwise.
111
-	 */
112
-	private function linkify(?string $url): ?string {
113
-		if ($url === null) {
114
-			return null;
115
-		}
116
-		if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
117
-			return null;
118
-		}
119
-
120
-		return sprintf('<a href="%1$s">%1$s</a>', htmlspecialchars($url));
121
-	}
122
-
123
-	/**
124
-	 * @param VEvent $vEvent
125
-	 * @param VEvent|null $oldVEvent
126
-	 * @return array
127
-	 */
128
-	public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array {
129
-
130
-		// construct event reader
131
-		$eventReaderCurrent = new EventReader($vEvent);
132
-		$eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null;
133
-		$defaultVal = '';
134
-		$data = [];
135
-		$data['meeting_when'] = $this->generateWhenString($eventReaderCurrent);
136
-
137
-		foreach (self::STRING_DIFF as $key => $property) {
138
-			$data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
139
-		}
140
-
141
-		$data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal);
142
-
143
-		if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
144
-			$data['meeting_location_html'] = $locationHtml;
145
-		}
146
-
147
-		if (!empty($oldVEvent)) {
148
-			$oldMeetingWhen = $this->generateWhenString($eventReaderPrevious);
149
-			$data['meeting_title_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY', $data['meeting_title']);
150
-			$data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION', $data['meeting_description']);
151
-			$data['meeting_location_html'] = $this->generateLinkifiedDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']);
152
-
153
-			$oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal);
154
-			$data['meeting_url_html'] = !empty($oldUrl) && $oldUrl !== $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url'];
155
-
156
-			$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'];
157
-		}
158
-		// generate occurring next string
159
-		if ($eventReaderCurrent->recurs()) {
160
-			$data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent);
161
-		}
162
-		return $data;
163
-	}
164
-
165
-	/**
166
-	 * @param VEvent $vEvent
167
-	 * @return array
168
-	 */
169
-	public function buildReplyBodyData(VEvent $vEvent): array {
170
-		// construct event reader
171
-		$eventReader = new EventReader($vEvent);
172
-		$defaultVal = '';
173
-		$data = [];
174
-		$data['meeting_when'] = $this->generateWhenString($eventReader);
175
-
176
-		foreach (self::STRING_DIFF as $key => $property) {
177
-			$data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
178
-		}
179
-
180
-		if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
181
-			$data['meeting_location_html'] = $locationHtml;
182
-		}
183
-
184
-		$data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : '';
185
-
186
-		// generate occurring next string
187
-		if ($eventReader->recurs()) {
188
-			$data['meeting_occurring'] = $this->generateOccurringString($eventReader);
189
-		}
190
-
191
-		return $data;
192
-	}
193
-
194
-	/**
195
-	 * generates a when string based on if a event has an recurrence or not
196
-	 *
197
-	 * @since 30.0.0
198
-	 *
199
-	 * @param EventReader $er
200
-	 *
201
-	 * @return string
202
-	 */
203
-	public function generateWhenString(EventReader $er): string {
204
-		return match ($er->recurs()) {
205
-			true => $this->generateWhenStringRecurring($er),
206
-			false => $this->generateWhenStringSingular($er)
207
-		};
208
-	}
209
-
210
-	/**
211
-	 * generates a when string for a non recurring event
212
-	 *
213
-	 * @since 30.0.0
214
-	 *
215
-	 * @param EventReader $er
216
-	 *
217
-	 * @return string
218
-	 */
219
-	public function generateWhenStringSingular(EventReader $er): string {
220
-		// initialize
221
-		$startTime = null;
222
-		$endTime = null;
223
-		// calculate time difference from now to start of event
224
-		$occurring = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
225
-		// extract start date
226
-		$startDate = $this->l10n->l('date', $er->startDateTime(), ['width' => 'full']);
227
-		// time of the day
228
-		if (!$er->entireDay()) {
229
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
230
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
231
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
232
-		}
233
-		// generate localized when string
234
-		// TRANSLATORS
235
-		// Indicates when a calendar event will happen, shown on invitation emails
236
-		// Output produced in order:
237
-		// In 1 minute/hour/day/week/month/year on July 1, 2024 for the entire day
238
-		// In 1 minute/hour/day/week/month/year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
239
-		// In 2 minutes/hours/days/weeks/months/years on July 1, 2024 for the entire day
240
-		// In 2 minutes/hours/days/weeks/months/years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
241
-		return match ([$occurring['scale'], $endTime !== null]) {
242
-			['past', false] => $this->l10n->t(
243
-				'In the past on %1$s for the entire day',
244
-				[$startDate]
245
-			),
246
-			['minute', false] => $this->l10n->n(
247
-				'In %n minute on %1$s for the entire day',
248
-				'In %n minutes on %1$s for the entire day',
249
-				$occurring['interval'],
250
-				[$startDate]
251
-			),
252
-			['hour', false] => $this->l10n->n(
253
-				'In %n hour on %1$s for the entire day',
254
-				'In %n hours on %1$s for the entire day',
255
-				$occurring['interval'],
256
-				[$startDate]
257
-			),
258
-			['day', false] => $this->l10n->n(
259
-				'In %n day on %1$s for the entire day',
260
-				'In %n days on %1$s for the entire day',
261
-				$occurring['interval'],
262
-				[$startDate]
263
-			),
264
-			['week', false] => $this->l10n->n(
265
-				'In %n week on %1$s for the entire day',
266
-				'In %n weeks on %1$s for the entire day',
267
-				$occurring['interval'],
268
-				[$startDate]
269
-			),
270
-			['month', false] => $this->l10n->n(
271
-				'In %n month on %1$s for the entire day',
272
-				'In %n months on %1$s for the entire day',
273
-				$occurring['interval'],
274
-				[$startDate]
275
-			),
276
-			['year', false] => $this->l10n->n(
277
-				'In %n year on %1$s for the entire day',
278
-				'In %n years on %1$s for the entire day',
279
-				$occurring['interval'],
280
-				[$startDate]
281
-			),
282
-			['past', true] => $this->l10n->t(
283
-				'In the past on %1$s between %2$s - %3$s',
284
-				[$startDate, $startTime, $endTime]
285
-			),
286
-			['minute', true] => $this->l10n->n(
287
-				'In %n minute on %1$s between %2$s - %3$s',
288
-				'In %n minutes on %1$s between %2$s - %3$s',
289
-				$occurring['interval'],
290
-				[$startDate, $startTime, $endTime]
291
-			),
292
-			['hour', true] => $this->l10n->n(
293
-				'In %n hour on %1$s between %2$s - %3$s',
294
-				'In %n hours on %1$s between %2$s - %3$s',
295
-				$occurring['interval'],
296
-				[$startDate, $startTime, $endTime]
297
-			),
298
-			['day', true] => $this->l10n->n(
299
-				'In %n day on %1$s between %2$s - %3$s',
300
-				'In %n days on %1$s between %2$s - %3$s',
301
-				$occurring['interval'],
302
-				[$startDate, $startTime, $endTime]
303
-			),
304
-			['week', true] => $this->l10n->n(
305
-				'In %n week on %1$s between %2$s - %3$s',
306
-				'In %n weeks on %1$s between %2$s - %3$s',
307
-				$occurring['interval'],
308
-				[$startDate, $startTime, $endTime]
309
-			),
310
-			['month', true] => $this->l10n->n(
311
-				'In %n month on %1$s between %2$s - %3$s',
312
-				'In %n months on %1$s between %2$s - %3$s',
313
-				$occurring['interval'],
314
-				[$startDate, $startTime, $endTime]
315
-			),
316
-			['year', true] => $this->l10n->n(
317
-				'In %n year on %1$s between %2$s - %3$s',
318
-				'In %n years on %1$s between %2$s - %3$s',
319
-				$occurring['interval'],
320
-				[$startDate, $startTime, $endTime]
321
-			),
322
-			default => $this->l10n->t('Could not generate when statement')
323
-		};
324
-	}
325
-
326
-	/**
327
-	 * generates a when string based on recurrence precision/frequency
328
-	 *
329
-	 * @since 30.0.0
330
-	 *
331
-	 * @param EventReader $er
332
-	 *
333
-	 * @return string
334
-	 */
335
-	public function generateWhenStringRecurring(EventReader $er): string {
336
-		return match ($er->recurringPrecision()) {
337
-			'daily' => $this->generateWhenStringRecurringDaily($er),
338
-			'weekly' => $this->generateWhenStringRecurringWeekly($er),
339
-			'monthly' => $this->generateWhenStringRecurringMonthly($er),
340
-			'yearly' => $this->generateWhenStringRecurringYearly($er),
341
-			'fixed' => $this->generateWhenStringRecurringFixed($er),
342
-		};
343
-	}
344
-
345
-	/**
346
-	 * generates a when string for a daily precision/frequency
347
-	 *
348
-	 * @since 30.0.0
349
-	 *
350
-	 * @param EventReader $er
351
-	 *
352
-	 * @return string
353
-	 */
354
-	public function generateWhenStringRecurringDaily(EventReader $er): string {
355
-
356
-		// initialize
357
-		$interval = (int)$er->recurringInterval();
358
-		$startTime = null;
359
-		$conclusion = null;
360
-		// time of the day
361
-		if (!$er->entireDay()) {
362
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
363
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
364
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
365
-		}
366
-		// conclusion
367
-		if ($er->recurringConcludes()) {
368
-			$conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
369
-		}
370
-		// generate localized when string
371
-		// TRANSLATORS
372
-		// Indicates when a calendar event will happen, shown on invitation emails
373
-		// Output produced in order:
374
-		// Every Day for the entire day
375
-		// Every Day for the entire day until July 13, 2024
376
-		// Every Day between 8:00 AM - 9:00 AM (America/Toronto)
377
-		// Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
378
-		// Every 3 Days for the entire day
379
-		// Every 3 Days for the entire day until July 13, 2024
380
-		// Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto)
381
-		// Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
382
-		return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
383
-			[false, false, false] => $this->l10n->t('Every Day for the entire day'),
384
-			[false, false, true] => $this->l10n->t('Every Day for the entire day until %1$s', [$conclusion]),
385
-			[false, true, false] => $this->l10n->t('Every Day between %1$s - %2$s', [$startTime, $endTime]),
386
-			[false, true, true] => $this->l10n->t('Every Day between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
387
-			[true, false, false] => $this->l10n->t('Every %1$d Days for the entire day', [$interval]),
388
-			[true, false, true] => $this->l10n->t('Every %1$d Days for the entire day until %2$s', [$interval, $conclusion]),
389
-			[true, true, false] => $this->l10n->t('Every %1$d Days between %2$s - %3$s', [$interval, $startTime, $endTime]),
390
-			[true, true, true] => $this->l10n->t('Every %1$d Days between %2$s - %3$s until %4$s', [$interval, $startTime, $endTime, $conclusion]),
391
-			default => $this->l10n->t('Could not generate event recurrence statement')
392
-		};
393
-
394
-	}
395
-
396
-	/**
397
-	 * generates a when string for a weekly precision/frequency
398
-	 *
399
-	 * @since 30.0.0
400
-	 *
401
-	 * @param EventReader $er
402
-	 *
403
-	 * @return string
404
-	 */
405
-	public function generateWhenStringRecurringWeekly(EventReader $er): string {
406
-
407
-		// initialize
408
-		$interval = (int)$er->recurringInterval();
409
-		$startTime = null;
410
-		$conclusion = null;
411
-		// days of the week
412
-		$days = implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
413
-		// time of the day
414
-		if (!$er->entireDay()) {
415
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
416
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
417
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
418
-		}
419
-		// conclusion
420
-		if ($er->recurringConcludes()) {
421
-			$conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
422
-		}
423
-		// generate localized when string
424
-		// TRANSLATORS
425
-		// Indicates when a calendar event will happen, shown on invitation emails
426
-		// Output produced in order:
427
-		// Every Week on Monday, Wednesday, Friday for the entire day
428
-		// Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024
429
-		// Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
430
-		// Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
431
-		// Every 2 Weeks on Monday, Wednesday, Friday for the entire day
432
-		// Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024
433
-		// Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
434
-		// Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
435
-		return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
436
-			[false, false, false] => $this->l10n->t('Every Week on %1$s for the entire day', [$days]),
437
-			[false, false, true] => $this->l10n->t('Every Week on %1$s for the entire day until %2$s', [$days, $conclusion]),
438
-			[false, true, false] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
439
-			[false, true, true] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
440
-			[true, false, false] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day', [$interval, $days]),
441
-			[true, false, true] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
442
-			[true, true, false] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
443
-			[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]),
444
-			default => $this->l10n->t('Could not generate event recurrence statement')
445
-		};
446
-
447
-	}
448
-
449
-	/**
450
-	 * generates a when string for a monthly precision/frequency
451
-	 *
452
-	 * @since 30.0.0
453
-	 *
454
-	 * @param EventReader $er
455
-	 *
456
-	 * @return string
457
-	 */
458
-	public function generateWhenStringRecurringMonthly(EventReader $er): string {
459
-
460
-		// initialize
461
-		$interval = (int)$er->recurringInterval();
462
-		$startTime = null;
463
-		$conclusion = null;
464
-		// days of month
465
-		if ($er->recurringPattern() === 'R') {
466
-			$days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
467
-					. implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
468
-		} else {
469
-			$days = implode(', ', $er->recurringDaysOfMonth());
470
-		}
471
-		// time of the day
472
-		if (!$er->entireDay()) {
473
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
474
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
475
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
476
-		}
477
-		// conclusion
478
-		if ($er->recurringConcludes()) {
479
-			$conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
480
-		}
481
-		// generate localized when string
482
-		// TRANSLATORS
483
-		// Indicates when a calendar event will happen, shown on invitation emails
484
-		// Output produced in order, output varies depending on if the event is absolute or releative:
485
-		// Absolute: Every Month on the 1, 8 for the entire day
486
-		// Relative: Every Month on the First Sunday, Saturday for the entire day
487
-		// Absolute: Every Month on the 1, 8 for the entire day until December 31, 2024
488
-		// Relative: Every Month on the First Sunday, Saturday for the entire day until December 31, 2024
489
-		// Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
490
-		// Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
491
-		// Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
492
-		// Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
493
-		// Absolute: Every 2 Months on the 1, 8 for the entire day
494
-		// Relative: Every 2 Months on the First Sunday, Saturday for the entire day
495
-		// Absolute: Every 2 Months on the 1, 8 for the entire day until December 31, 2024
496
-		// Relative: Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024
497
-		// Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
498
-		// Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
499
-		// Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
500
-		// Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
501
-		return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
502
-			[false, false, false] => $this->l10n->t('Every Month on the %1$s for the entire day', [$days]),
503
-			[false, false, true] => $this->l10n->t('Every Month on the %1$s for the entire day until %2$s', [$days, $conclusion]),
504
-			[false, true, false] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
505
-			[false, true, true] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
506
-			[true, false, false] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day', [$interval, $days]),
507
-			[true, false, true] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
508
-			[true, true, false] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
509
-			[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]),
510
-			default => $this->l10n->t('Could not generate event recurrence statement')
511
-		};
512
-	}
513
-
514
-	/**
515
-	 * generates a when string for a yearly precision/frequency
516
-	 *
517
-	 * @since 30.0.0
518
-	 *
519
-	 * @param EventReader $er
520
-	 *
521
-	 * @return string
522
-	 */
523
-	public function generateWhenStringRecurringYearly(EventReader $er): string {
524
-
525
-		// initialize
526
-		$interval = (int)$er->recurringInterval();
527
-		$startTime = null;
528
-		$conclusion = null;
529
-		// months of year
530
-		$months = implode(', ', array_map(function ($value) { return $this->localizeMonthName($value); }, $er->recurringMonthsOfYearNamed()));
531
-		// days of month
532
-		if ($er->recurringPattern() === 'R') {
533
-			$days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
534
-					. implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
535
-		} else {
536
-			$days = $er->startDateTime()->format('jS');
537
-		}
538
-		// time of the day
539
-		if (!$er->entireDay()) {
540
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
541
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
542
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
543
-		}
544
-		// conclusion
545
-		if ($er->recurringConcludes()) {
546
-			$conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
547
-		}
548
-		// generate localized when string
549
-		// TRANSLATORS
550
-		// Indicates when a calendar event will happen, shown on invitation emails
551
-		// Output produced in order, output varies depending on if the event is absolute or releative:
552
-		// Absolute: Every Year in July on the 1st for the entire day
553
-		// Relative: Every Year in July on the First Sunday, Saturday for the entire day
554
-		// Absolute: Every Year in July on the 1st for the entire day until July 31, 2026
555
-		// Relative: Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026
556
-		// Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
557
-		// Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
558
-		// Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
559
-		// Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
560
-		// Absolute: Every 2 Years in July on the 1st for the entire day
561
-		// Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day
562
-		// Absolute: Every 2 Years in July on the 1st for the entire day until July 31, 2026
563
-		// Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026
564
-		// Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
565
-		// Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
566
-		// Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
567
-		// Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
568
-		return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
569
-			[false, false, false] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day', [$months, $days]),
570
-			[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]),
571
-			[false, true, false] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s', [$months, $days, $startTime, $endTime]),
572
-			[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]),
573
-			[true, false, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day', [$interval, $months, $days]),
574
-			[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]),
575
-			[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]),
576
-			[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]),
577
-			default => $this->l10n->t('Could not generate event recurrence statement')
578
-		};
579
-	}
580
-
581
-	/**
582
-	 * generates a when string for a fixed precision/frequency
583
-	 *
584
-	 * @since 30.0.0
585
-	 *
586
-	 * @param EventReader $er
587
-	 *
588
-	 * @return string
589
-	 */
590
-	public function generateWhenStringRecurringFixed(EventReader $er): string {
591
-		// initialize
592
-		$startTime = null;
593
-		$conclusion = null;
594
-		// time of the day
595
-		if (!$er->entireDay()) {
596
-			$startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
597
-			$startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
598
-			$endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
599
-		}
600
-		// conclusion
601
-		$conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
602
-		// generate localized when string
603
-		// TRANSLATORS
604
-		// Indicates when a calendar event will happen, shown on invitation emails
605
-		// Output produced in order:
606
-		// On specific dates for the entire day until July 13, 2024
607
-		// On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
608
-		return match ($startTime !== null) {
609
-			false => $this->l10n->t('On specific dates for the entire day until %1$s', [$conclusion]),
610
-			true => $this->l10n->t('On specific dates between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
611
-		};
612
-	}
613
-
614
-	/**
615
-	 * generates a occurring next string for a recurring event
616
-	 *
617
-	 * @since 30.0.0
618
-	 *
619
-	 * @param EventReader $er
620
-	 *
621
-	 * @return string
622
-	 */
623
-	public function generateOccurringString(EventReader $er): string {
624
-
625
-		// initialize
626
-		$occurrence = null;
627
-		$occurrence2 = null;
628
-		$occurrence3 = null;
629
-		// reset to initial occurrence
630
-		$er->recurrenceRewind();
631
-		// forward to current date
632
-		$er->recurrenceAdvanceTo($this->timeFactory->getDateTime());
633
-		// calculate time difference from now to start of next event occurrence and minimize it
634
-		$occurrenceIn = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
635
-		// store next occurrence value
636
-		$occurrence = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
637
-		// forward one occurrence
638
-		$er->recurrenceAdvance();
639
-		// evaluate if occurrence is valid
640
-		if ($er->recurrenceDate() !== null) {
641
-			// store following occurrence value
642
-			$occurrence2 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
643
-			// forward one occurrence
644
-			$er->recurrenceAdvance();
645
-			// evaluate if occurrence is valid
646
-			if ($er->recurrenceDate()) {
647
-				// store following occurrence value
648
-				$occurrence3 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
649
-			}
650
-		}
651
-		// generate localized when string
652
-		// TRANSLATORS
653
-		// Indicates when a calendar event will happen, shown on invitation emails
654
-		// Output produced in order:
655
-		// In 1 minute/hour/day/week/month/year on July 1, 2024
656
-		// In 1 minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024
657
-		// In 1 minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024 and July 5, 2024
658
-		// In 2 minutes/hours/days/weeks/months/years on July 1, 2024
659
-		// In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024
660
-		// In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024 and July 5, 2024
661
-		return match ([$occurrenceIn['scale'], $occurrence2 !== null, $occurrence3 !== null]) {
662
-			['past', false, false] => $this->l10n->t(
663
-				'In the past on %1$s',
664
-				[$occurrence]
665
-			),
666
-			['minute', false, false] => $this->l10n->n(
667
-				'In %n minute on %1$s',
668
-				'In %n minutes on %1$s',
669
-				$occurrenceIn['interval'],
670
-				[$occurrence]
671
-			),
672
-			['hour', false, false] => $this->l10n->n(
673
-				'In %n hour on %1$s',
674
-				'In %n hours on %1$s',
675
-				$occurrenceIn['interval'],
676
-				[$occurrence]
677
-			),
678
-			['day', false, false] => $this->l10n->n(
679
-				'In %n day on %1$s',
680
-				'In %n days on %1$s',
681
-				$occurrenceIn['interval'],
682
-				[$occurrence]
683
-			),
684
-			['week', false, false] => $this->l10n->n(
685
-				'In %n week on %1$s',
686
-				'In %n weeks on %1$s',
687
-				$occurrenceIn['interval'],
688
-				[$occurrence]
689
-			),
690
-			['month', false, false] => $this->l10n->n(
691
-				'In %n month on %1$s',
692
-				'In %n months on %1$s',
693
-				$occurrenceIn['interval'],
694
-				[$occurrence]
695
-			),
696
-			['year', false, false] => $this->l10n->n(
697
-				'In %n year on %1$s',
698
-				'In %n years on %1$s',
699
-				$occurrenceIn['interval'],
700
-				[$occurrence]
701
-			),
702
-			['past', true, false] => $this->l10n->t(
703
-				'In the past on %1$s then on %2$s',
704
-				[$occurrence, $occurrence2]
705
-			),
706
-			['minute', true, false] => $this->l10n->n(
707
-				'In %n minute on %1$s then on %2$s',
708
-				'In %n minutes on %1$s then on %2$s',
709
-				$occurrenceIn['interval'],
710
-				[$occurrence, $occurrence2]
711
-			),
712
-			['hour', true, false] => $this->l10n->n(
713
-				'In %n hour on %1$s then on %2$s',
714
-				'In %n hours on %1$s then on %2$s',
715
-				$occurrenceIn['interval'],
716
-				[$occurrence, $occurrence2]
717
-			),
718
-			['day', true, false] => $this->l10n->n(
719
-				'In %n day on %1$s then on %2$s',
720
-				'In %n days on %1$s then on %2$s',
721
-				$occurrenceIn['interval'],
722
-				[$occurrence, $occurrence2]
723
-			),
724
-			['week', true, false] => $this->l10n->n(
725
-				'In %n week on %1$s then on %2$s',
726
-				'In %n weeks on %1$s then on %2$s',
727
-				$occurrenceIn['interval'],
728
-				[$occurrence, $occurrence2]
729
-			),
730
-			['month', true, false] => $this->l10n->n(
731
-				'In %n month on %1$s then on %2$s',
732
-				'In %n months on %1$s then on %2$s',
733
-				$occurrenceIn['interval'],
734
-				[$occurrence, $occurrence2]
735
-			),
736
-			['year', true, false] => $this->l10n->n(
737
-				'In %n year on %1$s then on %2$s',
738
-				'In %n years on %1$s then on %2$s',
739
-				$occurrenceIn['interval'],
740
-				[$occurrence, $occurrence2]
741
-			),
742
-			['past', true, true] => $this->l10n->t(
743
-				'In the past on %1$s then on %2$s and %3$s',
744
-				[$occurrence, $occurrence2, $occurrence3]
745
-			),
746
-			['minute', true, true] => $this->l10n->n(
747
-				'In %n minute on %1$s then on %2$s and %3$s',
748
-				'In %n minutes on %1$s then on %2$s and %3$s',
749
-				$occurrenceIn['interval'],
750
-				[$occurrence, $occurrence2, $occurrence3]
751
-			),
752
-			['hour', true, true] => $this->l10n->n(
753
-				'In %n hour on %1$s then on %2$s and %3$s',
754
-				'In %n hours on %1$s then on %2$s and %3$s',
755
-				$occurrenceIn['interval'],
756
-				[$occurrence, $occurrence2, $occurrence3]
757
-			),
758
-			['day', true, true] => $this->l10n->n(
759
-				'In %n day on %1$s then on %2$s and %3$s',
760
-				'In %n days on %1$s then on %2$s and %3$s',
761
-				$occurrenceIn['interval'],
762
-				[$occurrence, $occurrence2, $occurrence3]
763
-			),
764
-			['week', true, true] => $this->l10n->n(
765
-				'In %n week on %1$s then on %2$s and %3$s',
766
-				'In %n weeks on %1$s then on %2$s and %3$s',
767
-				$occurrenceIn['interval'],
768
-				[$occurrence, $occurrence2, $occurrence3]
769
-			),
770
-			['month', true, true] => $this->l10n->n(
771
-				'In %n month on %1$s then on %2$s and %3$s',
772
-				'In %n months on %1$s then on %2$s and %3$s',
773
-				$occurrenceIn['interval'],
774
-				[$occurrence, $occurrence2, $occurrence3]
775
-			),
776
-			['year', true, true] => $this->l10n->n(
777
-				'In %n year on %1$s then on %2$s and %3$s',
778
-				'In %n years on %1$s then on %2$s and %3$s',
779
-				$occurrenceIn['interval'],
780
-				[$occurrence, $occurrence2, $occurrence3]
781
-			),
782
-			default => $this->l10n->t('Could not generate next recurrence statement')
783
-		};
784
-
785
-	}
786
-
787
-	/**
788
-	 * @param VEvent $vEvent
789
-	 * @return array
790
-	 */
791
-	public function buildCancelledBodyData(VEvent $vEvent): array {
792
-		// construct event reader
793
-		$eventReaderCurrent = new EventReader($vEvent);
794
-		$defaultVal = '';
795
-		$strikethrough = "<span style='text-decoration: line-through'>%s</span>";
796
-
797
-		$newMeetingWhen = $this->generateWhenString($eventReaderCurrent);
798
-		$newSummary = isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event');
799
-		$newDescription = isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal;
800
-		$newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? sprintf('<a href="%1$s">%1$s</a>', $vEvent->URL) : $defaultVal;
801
-		$newLocation = isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal;
802
-		$newLocationHtml = $this->linkify($newLocation) ?? $newLocation;
803
-
804
-		$data = [];
805
-		$data['meeting_when_html'] = $newMeetingWhen === '' ?: sprintf($strikethrough, $newMeetingWhen);
806
-		$data['meeting_when'] = $newMeetingWhen;
807
-		$data['meeting_title_html'] = sprintf($strikethrough, $newSummary);
808
-		$data['meeting_title'] = $newSummary !== '' ? $newSummary: $this->l10n->t('Untitled event');
809
-		$data['meeting_description_html'] = $newDescription !== '' ? sprintf($strikethrough, $newDescription) : '';
810
-		$data['meeting_description'] = $newDescription;
811
-		$data['meeting_url_html'] = $newUrl !== '' ? sprintf($strikethrough, $newUrl) : '';
812
-		$data['meeting_url'] = isset($vEvent->URL) ? (string)$vEvent->URL : '';
813
-		$data['meeting_location_html'] = $newLocationHtml !== '' ? sprintf($strikethrough, $newLocationHtml) : '';
814
-		$data['meeting_location'] = $newLocation;
815
-		return $data;
816
-	}
817
-
818
-	/**
819
-	 * Check if event took place in the past
820
-	 *
821
-	 * @param VCalendar $vObject
822
-	 * @return int
823
-	 */
824
-	public function getLastOccurrence(VCalendar $vObject) {
825
-		/** @var VEvent $component */
826
-		$component = $vObject->VEVENT;
827
-
828
-		if (isset($component->RRULE)) {
829
-			$it = new EventIterator($vObject, (string)$component->UID);
830
-			$maxDate = new \DateTime(IMipPlugin::MAX_DATE);
831
-			if ($it->isInfinite()) {
832
-				return $maxDate->getTimestamp();
833
-			}
834
-
835
-			$end = $it->getDtEnd();
836
-			while ($it->valid() && $end < $maxDate) {
837
-				$end = $it->getDtEnd();
838
-				$it->next();
839
-			}
840
-			return $end->getTimestamp();
841
-		}
842
-
843
-		/** @var Property\ICalendar\DateTime $dtStart */
844
-		$dtStart = $component->DTSTART;
845
-
846
-		if (isset($component->DTEND)) {
847
-			/** @var Property\ICalendar\DateTime $dtEnd */
848
-			$dtEnd = $component->DTEND;
849
-			return $dtEnd->getDateTime()->getTimeStamp();
850
-		}
851
-
852
-		if (isset($component->DURATION)) {
853
-			/** @var \DateTime $endDate */
854
-			$endDate = clone $dtStart->getDateTime();
855
-			// $component->DTEND->getDateTime() returns DateTimeImmutable
856
-			$endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue()));
857
-			return $endDate->getTimestamp();
858
-		}
859
-
860
-		if (!$dtStart->hasTime()) {
861
-			/** @var \DateTime $endDate */
862
-			// $component->DTSTART->getDateTime() returns DateTimeImmutable
863
-			$endDate = clone $dtStart->getDateTime();
864
-			$endDate = $endDate->modify('+1 day');
865
-			return $endDate->getTimestamp();
866
-		}
867
-
868
-		// No computation of end time possible - return start date
869
-		return $dtStart->getDateTime()->getTimeStamp();
870
-	}
871
-
872
-	/**
873
-	 * @param Property|null $attendee
874
-	 */
875
-	public function setL10n(?Property $attendee = null) {
876
-		if ($attendee === null) {
877
-			return;
878
-		}
879
-
880
-		$lang = $attendee->offsetGet('LANGUAGE');
881
-		if ($lang instanceof Parameter) {
882
-			$lang = $lang->getValue();
883
-			$this->l10n = $this->l10nFactory->get('dav', $lang);
884
-		}
885
-	}
886
-
887
-	/**
888
-	 * @param Property|null $attendee
889
-	 * @return bool
890
-	 */
891
-	public function getAttendeeRsvpOrReqForParticipant(?Property $attendee = null) {
892
-		if ($attendee === null) {
893
-			return false;
894
-		}
895
-
896
-		$rsvp = $attendee->offsetGet('RSVP');
897
-		if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) {
898
-			return true;
899
-		}
900
-		$role = $attendee->offsetGet('ROLE');
901
-		// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16
902
-		// Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set
903
-		if ($role === null
904
-			|| (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0))
905
-			|| (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0))
906
-		) {
907
-			return true;
908
-		}
909
-
910
-		// RFC 5545 3.2.17: default RSVP is false
911
-		return false;
912
-	}
913
-
914
-	/**
915
-	 * @param IEMailTemplate $template
916
-	 * @param string $method
917
-	 * @param string $sender
918
-	 * @param string $summary
919
-	 * @param string|null $partstat
920
-	 * @param bool $isModified
921
-	 */
922
-	public function addSubjectAndHeading(IEMailTemplate $template,
923
-		string $method, string $sender, string $summary, bool $isModified, ?Property $replyingAttendee = null): void {
924
-		if ($method === IMipPlugin::METHOD_CANCEL) {
925
-			// TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}"
926
-			$template->setSubject($this->l10n->t('Cancelled: %1$s', [$summary]));
927
-			$template->addHeading($this->l10n->t('"%1$s" has been canceled', [$summary]));
928
-		} elseif ($method === IMipPlugin::METHOD_REPLY) {
929
-			// TRANSLATORS Subject for email, when an invitation is replied to. Ex: "Re: {{Event Name}}"
930
-			$template->setSubject($this->l10n->t('Re: %1$s', [$summary]));
931
-			// Build the strings
932
-			$partstat = (isset($replyingAttendee)) ? $replyingAttendee->offsetGet('PARTSTAT') : null;
933
-			$partstat = ($partstat instanceof Parameter) ? $partstat->getValue() : null;
934
-			switch ($partstat) {
935
-				case 'ACCEPTED':
936
-					$template->addHeading($this->l10n->t('%1$s has accepted your invitation', [$sender]));
937
-					break;
938
-				case 'TENTATIVE':
939
-					$template->addHeading($this->l10n->t('%1$s has tentatively accepted your invitation', [$sender]));
940
-					break;
941
-				case 'DECLINED':
942
-					$template->addHeading($this->l10n->t('%1$s has declined your invitation', [$sender]));
943
-					break;
944
-				case null:
945
-				default:
946
-					$template->addHeading($this->l10n->t('%1$s has responded to your invitation', [$sender]));
947
-					break;
948
-			}
949
-		} elseif ($method === IMipPlugin::METHOD_REQUEST && $isModified) {
950
-			// TRANSLATORS Subject for email, when an invitation is updated. Ex: "Invitation updated: {{Event Name}}"
951
-			$template->setSubject($this->l10n->t('Invitation updated: %1$s', [$summary]));
952
-			$template->addHeading($this->l10n->t('%1$s updated the event "%2$s"', [$sender, $summary]));
953
-		} else {
954
-			// TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}"
955
-			$template->setSubject($this->l10n->t('Invitation: %1$s', [$summary]));
956
-			$template->addHeading($this->l10n->t('%1$s would like to invite you to "%2$s"', [$sender, $summary]));
957
-		}
958
-	}
959
-
960
-	/**
961
-	 * @param string $path
962
-	 * @return string
963
-	 */
964
-	public function getAbsoluteImagePath($path): string {
965
-		return $this->urlGenerator->getAbsoluteURL(
966
-			$this->urlGenerator->imagePath('core', $path)
967
-		);
968
-	}
969
-
970
-	/**
971
-	 * addAttendees: add organizer and attendee names/emails to iMip mail.
972
-	 *
973
-	 * Enable with DAV setting: invitation_list_attendees (default: no)
974
-	 *
975
-	 * The default is 'no', which matches old behavior, and is privacy preserving.
976
-	 *
977
-	 * To enable including attendees in invitation emails:
978
-	 *   % php occ config:app:set dav invitation_list_attendees --value yes
979
-	 *
980
-	 * @param IEMailTemplate $template
981
-	 * @param IL10N $this->l10n
982
-	 * @param VEvent $vevent
983
-	 * @author brad2014 on github.com
984
-	 */
985
-	public function addAttendees(IEMailTemplate $template, VEvent $vevent) {
986
-		if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') {
987
-			return;
988
-		}
989
-
990
-		if (isset($vevent->ORGANIZER)) {
991
-			/** @var Property | Property\ICalendar\CalAddress $organizer */
992
-			$organizer = $vevent->ORGANIZER;
993
-			$organizerEmail = substr($organizer->getNormalizedValue(), 7);
994
-			/** @var string|null $organizerName */
995
-			$organizerName = isset($organizer->CN) ? $organizer->CN->getValue() : null;
996
-			$organizerHTML = sprintf('<a href="%s">%s</a>',
997
-				htmlspecialchars($organizer->getNormalizedValue()),
998
-				htmlspecialchars($organizerName ?: $organizerEmail));
999
-			$organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail);
1000
-			if (isset($organizer['PARTSTAT'])) {
1001
-				/** @var Parameter $partstat */
1002
-				$partstat = $organizer['PARTSTAT'];
1003
-				if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
1004
-					$organizerHTML .= ' ✔︎';
1005
-					$organizerText .= ' ✔︎';
1006
-				}
1007
-			}
1008
-			$template->addBodyListItem($organizerHTML, $this->l10n->t('Organizer:'),
1009
-				$this->getAbsoluteImagePath('caldav/organizer.png'),
1010
-				$organizerText, '', IMipPlugin::IMIP_INDENT);
1011
-		}
1012
-
1013
-		$attendees = $vevent->select('ATTENDEE');
1014
-		if (count($attendees) === 0) {
1015
-			return;
1016
-		}
1017
-
1018
-		$attendeesHTML = [];
1019
-		$attendeesText = [];
1020
-		foreach ($attendees as $attendee) {
1021
-			$attendeeEmail = substr($attendee->getNormalizedValue(), 7);
1022
-			$attendeeName = isset($attendee['CN']) ? $attendee['CN']->getValue() : null;
1023
-			$attendeeHTML = sprintf('<a href="%s">%s</a>',
1024
-				htmlspecialchars($attendee->getNormalizedValue()),
1025
-				htmlspecialchars($attendeeName ?: $attendeeEmail));
1026
-			$attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail);
1027
-			if (isset($attendee['PARTSTAT'])) {
1028
-				/** @var Parameter $partstat */
1029
-				$partstat = $attendee['PARTSTAT'];
1030
-				if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
1031
-					$attendeeHTML .= ' ✔︎';
1032
-					$attendeeText .= ' ✔︎';
1033
-				}
1034
-			}
1035
-			$attendeesHTML[] = $attendeeHTML;
1036
-			$attendeesText[] = $attendeeText;
1037
-		}
1038
-
1039
-		$template->addBodyListItem(implode('<br/>', $attendeesHTML), $this->l10n->t('Attendees:'),
1040
-			$this->getAbsoluteImagePath('caldav/attendees.png'),
1041
-			implode("\n", $attendeesText), '', IMipPlugin::IMIP_INDENT);
1042
-	}
1043
-
1044
-	/**
1045
-	 * @param IEMailTemplate $template
1046
-	 * @param VEVENT $vevent
1047
-	 * @param $data
1048
-	 */
1049
-	public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) {
1050
-		$template->addBodyListItem(
1051
-			$data['meeting_title_html'] ?? $data['meeting_title'], $this->l10n->t('Title:'),
1052
-			$this->getAbsoluteImagePath('caldav/title.png'), $data['meeting_title'], '', IMipPlugin::IMIP_INDENT);
1053
-		if ($data['meeting_when'] !== '') {
1054
-			$template->addBodyListItem($data['meeting_when_html'] ?? $data['meeting_when'], $this->l10n->t('When:'),
1055
-				$this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_when'], '', IMipPlugin::IMIP_INDENT);
1056
-		}
1057
-		if ($data['meeting_location'] !== '') {
1058
-			$template->addBodyListItem($data['meeting_location_html'] ?? $data['meeting_location'], $this->l10n->t('Location:'),
1059
-				$this->getAbsoluteImagePath('caldav/location.png'), $data['meeting_location'], '', IMipPlugin::IMIP_INDENT);
1060
-		}
1061
-		if ($data['meeting_url'] !== '') {
1062
-			$template->addBodyListItem($data['meeting_url_html'] ?? $data['meeting_url'], $this->l10n->t('Link:'),
1063
-				$this->getAbsoluteImagePath('caldav/link.png'), $data['meeting_url'], '', IMipPlugin::IMIP_INDENT);
1064
-		}
1065
-		if (isset($data['meeting_occurring'])) {
1066
-			$template->addBodyListItem($data['meeting_occurring_html'] ?? $data['meeting_occurring'], $this->l10n->t('Occurring:'),
1067
-				$this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_occurring'], '', IMipPlugin::IMIP_INDENT);
1068
-		}
1069
-
1070
-		$this->addAttendees($template, $vevent);
1071
-
1072
-		/* Put description last, like an email body, since it can be arbitrarily long */
1073
-		if ($data['meeting_description']) {
1074
-			$template->addBodyListItem($data['meeting_description_html'] ?? $data['meeting_description'], $this->l10n->t('Description:'),
1075
-				$this->getAbsoluteImagePath('caldav/description.png'), $data['meeting_description'], '', IMipPlugin::IMIP_INDENT);
1076
-		}
1077
-	}
1078
-
1079
-	/**
1080
-	 * @param Message $iTipMessage
1081
-	 * @return null|Property
1082
-	 */
1083
-	public function getCurrentAttendee(Message $iTipMessage): ?Property {
1084
-		/** @var VEvent $vevent */
1085
-		$vevent = $iTipMessage->message->VEVENT;
1086
-		$attendees = $vevent->select('ATTENDEE');
1087
-		foreach ($attendees as $attendee) {
1088
-			if ($iTipMessage->method === 'REPLY' && strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
1089
-				/** @var Property $attendee */
1090
-				return $attendee;
1091
-			} elseif (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
1092
-				/** @var Property $attendee */
1093
-				return $attendee;
1094
-			}
1095
-		}
1096
-		return null;
1097
-	}
1098
-
1099
-	/**
1100
-	 * @param Message $iTipMessage
1101
-	 * @param VEvent $vevent
1102
-	 * @param int $lastOccurrence
1103
-	 * @return string
1104
-	 */
1105
-	public function createInvitationToken(Message $iTipMessage, VEvent $vevent, int $lastOccurrence): string {
1106
-		$token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC);
1107
-
1108
-		$attendee = $iTipMessage->recipient;
1109
-		$organizer = $iTipMessage->sender;
1110
-		$sequence = $iTipMessage->sequence;
1111
-		$recurrenceId = isset($vevent->{'RECURRENCE-ID'})
1112
-			? $vevent->{'RECURRENCE-ID'}->serialize() : null;
1113
-		$uid = $vevent->{'UID'}?->getValue();
1114
-
1115
-		$query = $this->db->getQueryBuilder();
1116
-		$query->insert('calendar_invitations')
1117
-			->values([
1118
-				'token' => $query->createNamedParameter($token),
1119
-				'attendee' => $query->createNamedParameter($attendee),
1120
-				'organizer' => $query->createNamedParameter($organizer),
1121
-				'sequence' => $query->createNamedParameter($sequence),
1122
-				'recurrenceid' => $query->createNamedParameter($recurrenceId),
1123
-				'expiration' => $query->createNamedParameter($lastOccurrence),
1124
-				'uid' => $query->createNamedParameter($uid)
1125
-			])
1126
-			->executeStatement();
1127
-
1128
-		return $token;
1129
-	}
1130
-
1131
-	/**
1132
-	 * @param IEMailTemplate $template
1133
-	 * @param $token
1134
-	 */
1135
-	public function addResponseButtons(IEMailTemplate $template, $token) {
1136
-		$template->addBodyButtonGroup(
1137
-			$this->l10n->t('Accept'),
1138
-			$this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [
1139
-				'token' => $token,
1140
-			]),
1141
-			$this->l10n->t('Decline'),
1142
-			$this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [
1143
-				'token' => $token,
1144
-			])
1145
-		);
1146
-	}
1147
-
1148
-	public function addMoreOptionsButton(IEMailTemplate $template, $token) {
1149
-		$moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [
1150
-			'token' => $token,
1151
-		]);
1152
-		$html = vsprintf('<small><a href="%s">%s</a></small>', [
1153
-			$moreOptionsURL, $this->l10n->t('More options …')
1154
-		]);
1155
-		$text = $this->l10n->t('More options at %s', [$moreOptionsURL]);
1156
-
1157
-		$template->addBodyText($html, $text);
1158
-	}
1159
-
1160
-	public function getReplyingAttendee(Message $iTipMessage): ?Property {
1161
-		/** @var VEvent $vevent */
1162
-		$vevent = $iTipMessage->message->VEVENT;
1163
-		$attendees = $vevent->select('ATTENDEE');
1164
-		foreach ($attendees as $attendee) {
1165
-			/** @var Property $attendee */
1166
-			if (strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
1167
-				return $attendee;
1168
-			}
1169
-		}
1170
-		return null;
1171
-	}
1172
-
1173
-	public function isRoomOrResource(Property $attendee): bool {
1174
-		$cuType = $attendee->offsetGet('CUTYPE');
1175
-		if (!$cuType instanceof Parameter) {
1176
-			return false;
1177
-		}
1178
-		$type = $cuType->getValue() ?? 'INDIVIDUAL';
1179
-		if (\in_array(strtoupper($type), ['RESOURCE', 'ROOM'], true)) {
1180
-			// Don't send emails to things
1181
-			return true;
1182
-		}
1183
-		return false;
1184
-	}
1185
-
1186
-	public function isCircle(Property $attendee): bool {
1187
-		$cuType = $attendee->offsetGet('CUTYPE');
1188
-		if (!$cuType instanceof Parameter) {
1189
-			return false;
1190
-		}
1191
-
1192
-		$uri = $attendee->getValue();
1193
-		if (!$uri) {
1194
-			return false;
1195
-		}
1196
-
1197
-		$cuTypeValue = $cuType->getValue();
1198
-		return $cuTypeValue === 'GROUP' && str_starts_with($uri, 'mailto:circle+');
1199
-	}
1200
-
1201
-	public function minimizeInterval(\DateInterval $dateInterval): array {
1202
-		// evaluate if time interval is in the past
1203
-		if ($dateInterval->invert == 1) {
1204
-			return ['interval' => 1, 'scale' => 'past'];
1205
-		}
1206
-		// evaluate interval parts and return smallest time period
1207
-		if ($dateInterval->y > 0) {
1208
-			$interval = $dateInterval->y;
1209
-			$scale = 'year';
1210
-		} elseif ($dateInterval->m > 0) {
1211
-			$interval = $dateInterval->m;
1212
-			$scale = 'month';
1213
-		} elseif ($dateInterval->d >= 7) {
1214
-			$interval = (int)($dateInterval->d / 7);
1215
-			$scale = 'week';
1216
-		} elseif ($dateInterval->d > 0) {
1217
-			$interval = $dateInterval->d;
1218
-			$scale = 'day';
1219
-		} elseif ($dateInterval->h > 0) {
1220
-			$interval = $dateInterval->h;
1221
-			$scale = 'hour';
1222
-		} else {
1223
-			$interval = $dateInterval->i;
1224
-			$scale = 'minute';
1225
-		}
1226
-
1227
-		return ['interval' => $interval, 'scale' => $scale];
1228
-	}
1229
-
1230
-	/**
1231
-	 * Localizes week day names to another language
1232
-	 *
1233
-	 * @param string $value
1234
-	 *
1235
-	 * @return string
1236
-	 */
1237
-	public function localizeDayName(string $value): string {
1238
-		return match ($value) {
1239
-			'Monday' => $this->l10n->t('Monday'),
1240
-			'Tuesday' => $this->l10n->t('Tuesday'),
1241
-			'Wednesday' => $this->l10n->t('Wednesday'),
1242
-			'Thursday' => $this->l10n->t('Thursday'),
1243
-			'Friday' => $this->l10n->t('Friday'),
1244
-			'Saturday' => $this->l10n->t('Saturday'),
1245
-			'Sunday' => $this->l10n->t('Sunday'),
1246
-		};
1247
-	}
1248
-
1249
-	/**
1250
-	 * Localizes month names to another language
1251
-	 *
1252
-	 * @param string $value
1253
-	 *
1254
-	 * @return string
1255
-	 */
1256
-	public function localizeMonthName(string $value): string {
1257
-		return match ($value) {
1258
-			'January' => $this->l10n->t('January'),
1259
-			'February' => $this->l10n->t('February'),
1260
-			'March' => $this->l10n->t('March'),
1261
-			'April' => $this->l10n->t('April'),
1262
-			'May' => $this->l10n->t('May'),
1263
-			'June' => $this->l10n->t('June'),
1264
-			'July' => $this->l10n->t('July'),
1265
-			'August' => $this->l10n->t('August'),
1266
-			'September' => $this->l10n->t('September'),
1267
-			'October' => $this->l10n->t('October'),
1268
-			'November' => $this->l10n->t('November'),
1269
-			'December' => $this->l10n->t('December'),
1270
-		};
1271
-	}
1272
-
1273
-	/**
1274
-	 * Localizes relative position names to another language
1275
-	 *
1276
-	 * @param string $value
1277
-	 *
1278
-	 * @return string
1279
-	 */
1280
-	public function localizeRelativePositionName(string $value): string {
1281
-		return match ($value) {
1282
-			'First' => $this->l10n->t('First'),
1283
-			'Second' => $this->l10n->t('Second'),
1284
-			'Third' => $this->l10n->t('Third'),
1285
-			'Fourth' => $this->l10n->t('Fourth'),
1286
-			'Fifth' => $this->l10n->t('Fifth'),
1287
-			'Last' => $this->l10n->t('Last'),
1288
-			'Second Last' => $this->l10n->t('Second Last'),
1289
-			'Third Last' => $this->l10n->t('Third Last'),
1290
-			'Fourth Last' => $this->l10n->t('Fourth Last'),
1291
-			'Fifth Last' => $this->l10n->t('Fifth Last'),
1292
-		};
1293
-	}
30
+    private IL10N $l10n;
31
+
32
+    /** @var string[] */
33
+    private const STRING_DIFF = [
34
+        'meeting_title' => 'SUMMARY',
35
+        'meeting_description' => 'DESCRIPTION',
36
+        'meeting_url' => 'URL',
37
+        'meeting_location' => 'LOCATION'
38
+    ];
39
+
40
+    public function __construct(
41
+        private URLGenerator $urlGenerator,
42
+        private IConfig $config,
43
+        private IDBConnection $db,
44
+        private ISecureRandom $random,
45
+        private L10NFactory $l10nFactory,
46
+        private ITimeFactory $timeFactory,
47
+    ) {
48
+        $language = $this->l10nFactory->findGenericLanguage();
49
+        $locale = $this->l10nFactory->findLocale($language);
50
+        $this->l10n = $this->l10nFactory->get('dav', $language, $locale);
51
+    }
52
+
53
+    /**
54
+     * @param string|null $senderName
55
+     * @param string $default
56
+     * @return string
57
+     */
58
+    public function getFrom(?string $senderName, string $default): string {
59
+        if ($senderName === null) {
60
+            return $default;
61
+        }
62
+
63
+        return $this->l10n->t('%1$s via %2$s', [$senderName, $default]);
64
+    }
65
+
66
+    public static function readPropertyWithDefault(VEvent $vevent, string $property, string $default) {
67
+        if (isset($vevent->$property)) {
68
+            $value = $vevent->$property->getValue();
69
+            if (!empty($value)) {
70
+                return $value;
71
+            }
72
+        }
73
+        return $default;
74
+    }
75
+
76
+    private function generateDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
77
+        $strikethrough = "<span style='text-decoration: line-through'>%s</span><br />%s";
78
+        if (!isset($vevent->$property)) {
79
+            return $default;
80
+        }
81
+        $newstring = $vevent->$property->getValue();
82
+        if (isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newstring) {
83
+            $oldstring = $oldVEvent->$property->getValue();
84
+            return sprintf($strikethrough, $oldstring, $newstring);
85
+        }
86
+        return $newstring;
87
+    }
88
+
89
+    /**
90
+     * Like generateDiffString() but linkifies the property values if they are urls.
91
+     */
92
+    private function generateLinkifiedDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
93
+        if (!isset($vevent->$property)) {
94
+            return $default;
95
+        }
96
+        /** @var string|null $newString */
97
+        $newString = $vevent->$property->getValue();
98
+        $oldString = isset($oldVEvent->$property) ? $oldVEvent->$property->getValue() : null;
99
+        if ($oldString !== $newString) {
100
+            return sprintf(
101
+                "<span style='text-decoration: line-through'>%s</span><br />%s",
102
+                $this->linkify($oldString) ?? $oldString ?? '',
103
+                $this->linkify($newString) ?? $newString ?? ''
104
+            );
105
+        }
106
+        return $this->linkify($newString) ?? $newString;
107
+    }
108
+
109
+    /**
110
+     * Convert a given url to a html link element or return null otherwise.
111
+     */
112
+    private function linkify(?string $url): ?string {
113
+        if ($url === null) {
114
+            return null;
115
+        }
116
+        if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
117
+            return null;
118
+        }
119
+
120
+        return sprintf('<a href="%1$s">%1$s</a>', htmlspecialchars($url));
121
+    }
122
+
123
+    /**
124
+     * @param VEvent $vEvent
125
+     * @param VEvent|null $oldVEvent
126
+     * @return array
127
+     */
128
+    public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array {
129
+
130
+        // construct event reader
131
+        $eventReaderCurrent = new EventReader($vEvent);
132
+        $eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null;
133
+        $defaultVal = '';
134
+        $data = [];
135
+        $data['meeting_when'] = $this->generateWhenString($eventReaderCurrent);
136
+
137
+        foreach (self::STRING_DIFF as $key => $property) {
138
+            $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
139
+        }
140
+
141
+        $data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal);
142
+
143
+        if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
144
+            $data['meeting_location_html'] = $locationHtml;
145
+        }
146
+
147
+        if (!empty($oldVEvent)) {
148
+            $oldMeetingWhen = $this->generateWhenString($eventReaderPrevious);
149
+            $data['meeting_title_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY', $data['meeting_title']);
150
+            $data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION', $data['meeting_description']);
151
+            $data['meeting_location_html'] = $this->generateLinkifiedDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']);
152
+
153
+            $oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal);
154
+            $data['meeting_url_html'] = !empty($oldUrl) && $oldUrl !== $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url'];
155
+
156
+            $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'];
157
+        }
158
+        // generate occurring next string
159
+        if ($eventReaderCurrent->recurs()) {
160
+            $data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent);
161
+        }
162
+        return $data;
163
+    }
164
+
165
+    /**
166
+     * @param VEvent $vEvent
167
+     * @return array
168
+     */
169
+    public function buildReplyBodyData(VEvent $vEvent): array {
170
+        // construct event reader
171
+        $eventReader = new EventReader($vEvent);
172
+        $defaultVal = '';
173
+        $data = [];
174
+        $data['meeting_when'] = $this->generateWhenString($eventReader);
175
+
176
+        foreach (self::STRING_DIFF as $key => $property) {
177
+            $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
178
+        }
179
+
180
+        if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
181
+            $data['meeting_location_html'] = $locationHtml;
182
+        }
183
+
184
+        $data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : '';
185
+
186
+        // generate occurring next string
187
+        if ($eventReader->recurs()) {
188
+            $data['meeting_occurring'] = $this->generateOccurringString($eventReader);
189
+        }
190
+
191
+        return $data;
192
+    }
193
+
194
+    /**
195
+     * generates a when string based on if a event has an recurrence or not
196
+     *
197
+     * @since 30.0.0
198
+     *
199
+     * @param EventReader $er
200
+     *
201
+     * @return string
202
+     */
203
+    public function generateWhenString(EventReader $er): string {
204
+        return match ($er->recurs()) {
205
+            true => $this->generateWhenStringRecurring($er),
206
+            false => $this->generateWhenStringSingular($er)
207
+        };
208
+    }
209
+
210
+    /**
211
+     * generates a when string for a non recurring event
212
+     *
213
+     * @since 30.0.0
214
+     *
215
+     * @param EventReader $er
216
+     *
217
+     * @return string
218
+     */
219
+    public function generateWhenStringSingular(EventReader $er): string {
220
+        // initialize
221
+        $startTime = null;
222
+        $endTime = null;
223
+        // calculate time difference from now to start of event
224
+        $occurring = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
225
+        // extract start date
226
+        $startDate = $this->l10n->l('date', $er->startDateTime(), ['width' => 'full']);
227
+        // time of the day
228
+        if (!$er->entireDay()) {
229
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
230
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
231
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
232
+        }
233
+        // generate localized when string
234
+        // TRANSLATORS
235
+        // Indicates when a calendar event will happen, shown on invitation emails
236
+        // Output produced in order:
237
+        // In 1 minute/hour/day/week/month/year on July 1, 2024 for the entire day
238
+        // In 1 minute/hour/day/week/month/year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
239
+        // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 for the entire day
240
+        // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
241
+        return match ([$occurring['scale'], $endTime !== null]) {
242
+            ['past', false] => $this->l10n->t(
243
+                'In the past on %1$s for the entire day',
244
+                [$startDate]
245
+            ),
246
+            ['minute', false] => $this->l10n->n(
247
+                'In %n minute on %1$s for the entire day',
248
+                'In %n minutes on %1$s for the entire day',
249
+                $occurring['interval'],
250
+                [$startDate]
251
+            ),
252
+            ['hour', false] => $this->l10n->n(
253
+                'In %n hour on %1$s for the entire day',
254
+                'In %n hours on %1$s for the entire day',
255
+                $occurring['interval'],
256
+                [$startDate]
257
+            ),
258
+            ['day', false] => $this->l10n->n(
259
+                'In %n day on %1$s for the entire day',
260
+                'In %n days on %1$s for the entire day',
261
+                $occurring['interval'],
262
+                [$startDate]
263
+            ),
264
+            ['week', false] => $this->l10n->n(
265
+                'In %n week on %1$s for the entire day',
266
+                'In %n weeks on %1$s for the entire day',
267
+                $occurring['interval'],
268
+                [$startDate]
269
+            ),
270
+            ['month', false] => $this->l10n->n(
271
+                'In %n month on %1$s for the entire day',
272
+                'In %n months on %1$s for the entire day',
273
+                $occurring['interval'],
274
+                [$startDate]
275
+            ),
276
+            ['year', false] => $this->l10n->n(
277
+                'In %n year on %1$s for the entire day',
278
+                'In %n years on %1$s for the entire day',
279
+                $occurring['interval'],
280
+                [$startDate]
281
+            ),
282
+            ['past', true] => $this->l10n->t(
283
+                'In the past on %1$s between %2$s - %3$s',
284
+                [$startDate, $startTime, $endTime]
285
+            ),
286
+            ['minute', true] => $this->l10n->n(
287
+                'In %n minute on %1$s between %2$s - %3$s',
288
+                'In %n minutes on %1$s between %2$s - %3$s',
289
+                $occurring['interval'],
290
+                [$startDate, $startTime, $endTime]
291
+            ),
292
+            ['hour', true] => $this->l10n->n(
293
+                'In %n hour on %1$s between %2$s - %3$s',
294
+                'In %n hours on %1$s between %2$s - %3$s',
295
+                $occurring['interval'],
296
+                [$startDate, $startTime, $endTime]
297
+            ),
298
+            ['day', true] => $this->l10n->n(
299
+                'In %n day on %1$s between %2$s - %3$s',
300
+                'In %n days on %1$s between %2$s - %3$s',
301
+                $occurring['interval'],
302
+                [$startDate, $startTime, $endTime]
303
+            ),
304
+            ['week', true] => $this->l10n->n(
305
+                'In %n week on %1$s between %2$s - %3$s',
306
+                'In %n weeks on %1$s between %2$s - %3$s',
307
+                $occurring['interval'],
308
+                [$startDate, $startTime, $endTime]
309
+            ),
310
+            ['month', true] => $this->l10n->n(
311
+                'In %n month on %1$s between %2$s - %3$s',
312
+                'In %n months on %1$s between %2$s - %3$s',
313
+                $occurring['interval'],
314
+                [$startDate, $startTime, $endTime]
315
+            ),
316
+            ['year', true] => $this->l10n->n(
317
+                'In %n year on %1$s between %2$s - %3$s',
318
+                'In %n years on %1$s between %2$s - %3$s',
319
+                $occurring['interval'],
320
+                [$startDate, $startTime, $endTime]
321
+            ),
322
+            default => $this->l10n->t('Could not generate when statement')
323
+        };
324
+    }
325
+
326
+    /**
327
+     * generates a when string based on recurrence precision/frequency
328
+     *
329
+     * @since 30.0.0
330
+     *
331
+     * @param EventReader $er
332
+     *
333
+     * @return string
334
+     */
335
+    public function generateWhenStringRecurring(EventReader $er): string {
336
+        return match ($er->recurringPrecision()) {
337
+            'daily' => $this->generateWhenStringRecurringDaily($er),
338
+            'weekly' => $this->generateWhenStringRecurringWeekly($er),
339
+            'monthly' => $this->generateWhenStringRecurringMonthly($er),
340
+            'yearly' => $this->generateWhenStringRecurringYearly($er),
341
+            'fixed' => $this->generateWhenStringRecurringFixed($er),
342
+        };
343
+    }
344
+
345
+    /**
346
+     * generates a when string for a daily precision/frequency
347
+     *
348
+     * @since 30.0.0
349
+     *
350
+     * @param EventReader $er
351
+     *
352
+     * @return string
353
+     */
354
+    public function generateWhenStringRecurringDaily(EventReader $er): string {
355
+
356
+        // initialize
357
+        $interval = (int)$er->recurringInterval();
358
+        $startTime = null;
359
+        $conclusion = null;
360
+        // time of the day
361
+        if (!$er->entireDay()) {
362
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
363
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
364
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
365
+        }
366
+        // conclusion
367
+        if ($er->recurringConcludes()) {
368
+            $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
369
+        }
370
+        // generate localized when string
371
+        // TRANSLATORS
372
+        // Indicates when a calendar event will happen, shown on invitation emails
373
+        // Output produced in order:
374
+        // Every Day for the entire day
375
+        // Every Day for the entire day until July 13, 2024
376
+        // Every Day between 8:00 AM - 9:00 AM (America/Toronto)
377
+        // Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
378
+        // Every 3 Days for the entire day
379
+        // Every 3 Days for the entire day until July 13, 2024
380
+        // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto)
381
+        // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
382
+        return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
383
+            [false, false, false] => $this->l10n->t('Every Day for the entire day'),
384
+            [false, false, true] => $this->l10n->t('Every Day for the entire day until %1$s', [$conclusion]),
385
+            [false, true, false] => $this->l10n->t('Every Day between %1$s - %2$s', [$startTime, $endTime]),
386
+            [false, true, true] => $this->l10n->t('Every Day between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
387
+            [true, false, false] => $this->l10n->t('Every %1$d Days for the entire day', [$interval]),
388
+            [true, false, true] => $this->l10n->t('Every %1$d Days for the entire day until %2$s', [$interval, $conclusion]),
389
+            [true, true, false] => $this->l10n->t('Every %1$d Days between %2$s - %3$s', [$interval, $startTime, $endTime]),
390
+            [true, true, true] => $this->l10n->t('Every %1$d Days between %2$s - %3$s until %4$s', [$interval, $startTime, $endTime, $conclusion]),
391
+            default => $this->l10n->t('Could not generate event recurrence statement')
392
+        };
393
+
394
+    }
395
+
396
+    /**
397
+     * generates a when string for a weekly precision/frequency
398
+     *
399
+     * @since 30.0.0
400
+     *
401
+     * @param EventReader $er
402
+     *
403
+     * @return string
404
+     */
405
+    public function generateWhenStringRecurringWeekly(EventReader $er): string {
406
+
407
+        // initialize
408
+        $interval = (int)$er->recurringInterval();
409
+        $startTime = null;
410
+        $conclusion = null;
411
+        // days of the week
412
+        $days = implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
413
+        // time of the day
414
+        if (!$er->entireDay()) {
415
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
416
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
417
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
418
+        }
419
+        // conclusion
420
+        if ($er->recurringConcludes()) {
421
+            $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
422
+        }
423
+        // generate localized when string
424
+        // TRANSLATORS
425
+        // Indicates when a calendar event will happen, shown on invitation emails
426
+        // Output produced in order:
427
+        // Every Week on Monday, Wednesday, Friday for the entire day
428
+        // Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024
429
+        // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
430
+        // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
431
+        // Every 2 Weeks on Monday, Wednesday, Friday for the entire day
432
+        // Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024
433
+        // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
434
+        // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
435
+        return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
436
+            [false, false, false] => $this->l10n->t('Every Week on %1$s for the entire day', [$days]),
437
+            [false, false, true] => $this->l10n->t('Every Week on %1$s for the entire day until %2$s', [$days, $conclusion]),
438
+            [false, true, false] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
439
+            [false, true, true] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
440
+            [true, false, false] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day', [$interval, $days]),
441
+            [true, false, true] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
442
+            [true, true, false] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
443
+            [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]),
444
+            default => $this->l10n->t('Could not generate event recurrence statement')
445
+        };
446
+
447
+    }
448
+
449
+    /**
450
+     * generates a when string for a monthly precision/frequency
451
+     *
452
+     * @since 30.0.0
453
+     *
454
+     * @param EventReader $er
455
+     *
456
+     * @return string
457
+     */
458
+    public function generateWhenStringRecurringMonthly(EventReader $er): string {
459
+
460
+        // initialize
461
+        $interval = (int)$er->recurringInterval();
462
+        $startTime = null;
463
+        $conclusion = null;
464
+        // days of month
465
+        if ($er->recurringPattern() === 'R') {
466
+            $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
467
+                    . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
468
+        } else {
469
+            $days = implode(', ', $er->recurringDaysOfMonth());
470
+        }
471
+        // time of the day
472
+        if (!$er->entireDay()) {
473
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
474
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
475
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
476
+        }
477
+        // conclusion
478
+        if ($er->recurringConcludes()) {
479
+            $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
480
+        }
481
+        // generate localized when string
482
+        // TRANSLATORS
483
+        // Indicates when a calendar event will happen, shown on invitation emails
484
+        // Output produced in order, output varies depending on if the event is absolute or releative:
485
+        // Absolute: Every Month on the 1, 8 for the entire day
486
+        // Relative: Every Month on the First Sunday, Saturday for the entire day
487
+        // Absolute: Every Month on the 1, 8 for the entire day until December 31, 2024
488
+        // Relative: Every Month on the First Sunday, Saturday for the entire day until December 31, 2024
489
+        // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
490
+        // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
491
+        // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
492
+        // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
493
+        // Absolute: Every 2 Months on the 1, 8 for the entire day
494
+        // Relative: Every 2 Months on the First Sunday, Saturday for the entire day
495
+        // Absolute: Every 2 Months on the 1, 8 for the entire day until December 31, 2024
496
+        // Relative: Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024
497
+        // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
498
+        // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
499
+        // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
500
+        // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
501
+        return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
502
+            [false, false, false] => $this->l10n->t('Every Month on the %1$s for the entire day', [$days]),
503
+            [false, false, true] => $this->l10n->t('Every Month on the %1$s for the entire day until %2$s', [$days, $conclusion]),
504
+            [false, true, false] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
505
+            [false, true, true] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
506
+            [true, false, false] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day', [$interval, $days]),
507
+            [true, false, true] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
508
+            [true, true, false] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
509
+            [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]),
510
+            default => $this->l10n->t('Could not generate event recurrence statement')
511
+        };
512
+    }
513
+
514
+    /**
515
+     * generates a when string for a yearly precision/frequency
516
+     *
517
+     * @since 30.0.0
518
+     *
519
+     * @param EventReader $er
520
+     *
521
+     * @return string
522
+     */
523
+    public function generateWhenStringRecurringYearly(EventReader $er): string {
524
+
525
+        // initialize
526
+        $interval = (int)$er->recurringInterval();
527
+        $startTime = null;
528
+        $conclusion = null;
529
+        // months of year
530
+        $months = implode(', ', array_map(function ($value) { return $this->localizeMonthName($value); }, $er->recurringMonthsOfYearNamed()));
531
+        // days of month
532
+        if ($er->recurringPattern() === 'R') {
533
+            $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
534
+                    . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
535
+        } else {
536
+            $days = $er->startDateTime()->format('jS');
537
+        }
538
+        // time of the day
539
+        if (!$er->entireDay()) {
540
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
541
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
542
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
543
+        }
544
+        // conclusion
545
+        if ($er->recurringConcludes()) {
546
+            $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
547
+        }
548
+        // generate localized when string
549
+        // TRANSLATORS
550
+        // Indicates when a calendar event will happen, shown on invitation emails
551
+        // Output produced in order, output varies depending on if the event is absolute or releative:
552
+        // Absolute: Every Year in July on the 1st for the entire day
553
+        // Relative: Every Year in July on the First Sunday, Saturday for the entire day
554
+        // Absolute: Every Year in July on the 1st for the entire day until July 31, 2026
555
+        // Relative: Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026
556
+        // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
557
+        // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
558
+        // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
559
+        // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
560
+        // Absolute: Every 2 Years in July on the 1st for the entire day
561
+        // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day
562
+        // Absolute: Every 2 Years in July on the 1st for the entire day until July 31, 2026
563
+        // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026
564
+        // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
565
+        // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
566
+        // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
567
+        // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
568
+        return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
569
+            [false, false, false] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day', [$months, $days]),
570
+            [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]),
571
+            [false, true, false] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s', [$months, $days, $startTime, $endTime]),
572
+            [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]),
573
+            [true, false, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day', [$interval, $months, $days]),
574
+            [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]),
575
+            [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]),
576
+            [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]),
577
+            default => $this->l10n->t('Could not generate event recurrence statement')
578
+        };
579
+    }
580
+
581
+    /**
582
+     * generates a when string for a fixed precision/frequency
583
+     *
584
+     * @since 30.0.0
585
+     *
586
+     * @param EventReader $er
587
+     *
588
+     * @return string
589
+     */
590
+    public function generateWhenStringRecurringFixed(EventReader $er): string {
591
+        // initialize
592
+        $startTime = null;
593
+        $conclusion = null;
594
+        // time of the day
595
+        if (!$er->entireDay()) {
596
+            $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
597
+            $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
598
+            $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
599
+        }
600
+        // conclusion
601
+        $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
602
+        // generate localized when string
603
+        // TRANSLATORS
604
+        // Indicates when a calendar event will happen, shown on invitation emails
605
+        // Output produced in order:
606
+        // On specific dates for the entire day until July 13, 2024
607
+        // On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
608
+        return match ($startTime !== null) {
609
+            false => $this->l10n->t('On specific dates for the entire day until %1$s', [$conclusion]),
610
+            true => $this->l10n->t('On specific dates between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
611
+        };
612
+    }
613
+
614
+    /**
615
+     * generates a occurring next string for a recurring event
616
+     *
617
+     * @since 30.0.0
618
+     *
619
+     * @param EventReader $er
620
+     *
621
+     * @return string
622
+     */
623
+    public function generateOccurringString(EventReader $er): string {
624
+
625
+        // initialize
626
+        $occurrence = null;
627
+        $occurrence2 = null;
628
+        $occurrence3 = null;
629
+        // reset to initial occurrence
630
+        $er->recurrenceRewind();
631
+        // forward to current date
632
+        $er->recurrenceAdvanceTo($this->timeFactory->getDateTime());
633
+        // calculate time difference from now to start of next event occurrence and minimize it
634
+        $occurrenceIn = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
635
+        // store next occurrence value
636
+        $occurrence = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
637
+        // forward one occurrence
638
+        $er->recurrenceAdvance();
639
+        // evaluate if occurrence is valid
640
+        if ($er->recurrenceDate() !== null) {
641
+            // store following occurrence value
642
+            $occurrence2 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
643
+            // forward one occurrence
644
+            $er->recurrenceAdvance();
645
+            // evaluate if occurrence is valid
646
+            if ($er->recurrenceDate()) {
647
+                // store following occurrence value
648
+                $occurrence3 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
649
+            }
650
+        }
651
+        // generate localized when string
652
+        // TRANSLATORS
653
+        // Indicates when a calendar event will happen, shown on invitation emails
654
+        // Output produced in order:
655
+        // In 1 minute/hour/day/week/month/year on July 1, 2024
656
+        // In 1 minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024
657
+        // In 1 minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024 and July 5, 2024
658
+        // In 2 minutes/hours/days/weeks/months/years on July 1, 2024
659
+        // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024
660
+        // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024 and July 5, 2024
661
+        return match ([$occurrenceIn['scale'], $occurrence2 !== null, $occurrence3 !== null]) {
662
+            ['past', false, false] => $this->l10n->t(
663
+                'In the past on %1$s',
664
+                [$occurrence]
665
+            ),
666
+            ['minute', false, false] => $this->l10n->n(
667
+                'In %n minute on %1$s',
668
+                'In %n minutes on %1$s',
669
+                $occurrenceIn['interval'],
670
+                [$occurrence]
671
+            ),
672
+            ['hour', false, false] => $this->l10n->n(
673
+                'In %n hour on %1$s',
674
+                'In %n hours on %1$s',
675
+                $occurrenceIn['interval'],
676
+                [$occurrence]
677
+            ),
678
+            ['day', false, false] => $this->l10n->n(
679
+                'In %n day on %1$s',
680
+                'In %n days on %1$s',
681
+                $occurrenceIn['interval'],
682
+                [$occurrence]
683
+            ),
684
+            ['week', false, false] => $this->l10n->n(
685
+                'In %n week on %1$s',
686
+                'In %n weeks on %1$s',
687
+                $occurrenceIn['interval'],
688
+                [$occurrence]
689
+            ),
690
+            ['month', false, false] => $this->l10n->n(
691
+                'In %n month on %1$s',
692
+                'In %n months on %1$s',
693
+                $occurrenceIn['interval'],
694
+                [$occurrence]
695
+            ),
696
+            ['year', false, false] => $this->l10n->n(
697
+                'In %n year on %1$s',
698
+                'In %n years on %1$s',
699
+                $occurrenceIn['interval'],
700
+                [$occurrence]
701
+            ),
702
+            ['past', true, false] => $this->l10n->t(
703
+                'In the past on %1$s then on %2$s',
704
+                [$occurrence, $occurrence2]
705
+            ),
706
+            ['minute', true, false] => $this->l10n->n(
707
+                'In %n minute on %1$s then on %2$s',
708
+                'In %n minutes on %1$s then on %2$s',
709
+                $occurrenceIn['interval'],
710
+                [$occurrence, $occurrence2]
711
+            ),
712
+            ['hour', true, false] => $this->l10n->n(
713
+                'In %n hour on %1$s then on %2$s',
714
+                'In %n hours on %1$s then on %2$s',
715
+                $occurrenceIn['interval'],
716
+                [$occurrence, $occurrence2]
717
+            ),
718
+            ['day', true, false] => $this->l10n->n(
719
+                'In %n day on %1$s then on %2$s',
720
+                'In %n days on %1$s then on %2$s',
721
+                $occurrenceIn['interval'],
722
+                [$occurrence, $occurrence2]
723
+            ),
724
+            ['week', true, false] => $this->l10n->n(
725
+                'In %n week on %1$s then on %2$s',
726
+                'In %n weeks on %1$s then on %2$s',
727
+                $occurrenceIn['interval'],
728
+                [$occurrence, $occurrence2]
729
+            ),
730
+            ['month', true, false] => $this->l10n->n(
731
+                'In %n month on %1$s then on %2$s',
732
+                'In %n months on %1$s then on %2$s',
733
+                $occurrenceIn['interval'],
734
+                [$occurrence, $occurrence2]
735
+            ),
736
+            ['year', true, false] => $this->l10n->n(
737
+                'In %n year on %1$s then on %2$s',
738
+                'In %n years on %1$s then on %2$s',
739
+                $occurrenceIn['interval'],
740
+                [$occurrence, $occurrence2]
741
+            ),
742
+            ['past', true, true] => $this->l10n->t(
743
+                'In the past on %1$s then on %2$s and %3$s',
744
+                [$occurrence, $occurrence2, $occurrence3]
745
+            ),
746
+            ['minute', true, true] => $this->l10n->n(
747
+                'In %n minute on %1$s then on %2$s and %3$s',
748
+                'In %n minutes on %1$s then on %2$s and %3$s',
749
+                $occurrenceIn['interval'],
750
+                [$occurrence, $occurrence2, $occurrence3]
751
+            ),
752
+            ['hour', true, true] => $this->l10n->n(
753
+                'In %n hour on %1$s then on %2$s and %3$s',
754
+                'In %n hours on %1$s then on %2$s and %3$s',
755
+                $occurrenceIn['interval'],
756
+                [$occurrence, $occurrence2, $occurrence3]
757
+            ),
758
+            ['day', true, true] => $this->l10n->n(
759
+                'In %n day on %1$s then on %2$s and %3$s',
760
+                'In %n days on %1$s then on %2$s and %3$s',
761
+                $occurrenceIn['interval'],
762
+                [$occurrence, $occurrence2, $occurrence3]
763
+            ),
764
+            ['week', true, true] => $this->l10n->n(
765
+                'In %n week on %1$s then on %2$s and %3$s',
766
+                'In %n weeks on %1$s then on %2$s and %3$s',
767
+                $occurrenceIn['interval'],
768
+                [$occurrence, $occurrence2, $occurrence3]
769
+            ),
770
+            ['month', true, true] => $this->l10n->n(
771
+                'In %n month on %1$s then on %2$s and %3$s',
772
+                'In %n months on %1$s then on %2$s and %3$s',
773
+                $occurrenceIn['interval'],
774
+                [$occurrence, $occurrence2, $occurrence3]
775
+            ),
776
+            ['year', true, true] => $this->l10n->n(
777
+                'In %n year on %1$s then on %2$s and %3$s',
778
+                'In %n years on %1$s then on %2$s and %3$s',
779
+                $occurrenceIn['interval'],
780
+                [$occurrence, $occurrence2, $occurrence3]
781
+            ),
782
+            default => $this->l10n->t('Could not generate next recurrence statement')
783
+        };
784
+
785
+    }
786
+
787
+    /**
788
+     * @param VEvent $vEvent
789
+     * @return array
790
+     */
791
+    public function buildCancelledBodyData(VEvent $vEvent): array {
792
+        // construct event reader
793
+        $eventReaderCurrent = new EventReader($vEvent);
794
+        $defaultVal = '';
795
+        $strikethrough = "<span style='text-decoration: line-through'>%s</span>";
796
+
797
+        $newMeetingWhen = $this->generateWhenString($eventReaderCurrent);
798
+        $newSummary = isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event');
799
+        $newDescription = isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal;
800
+        $newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? sprintf('<a href="%1$s">%1$s</a>', $vEvent->URL) : $defaultVal;
801
+        $newLocation = isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal;
802
+        $newLocationHtml = $this->linkify($newLocation) ?? $newLocation;
803
+
804
+        $data = [];
805
+        $data['meeting_when_html'] = $newMeetingWhen === '' ?: sprintf($strikethrough, $newMeetingWhen);
806
+        $data['meeting_when'] = $newMeetingWhen;
807
+        $data['meeting_title_html'] = sprintf($strikethrough, $newSummary);
808
+        $data['meeting_title'] = $newSummary !== '' ? $newSummary: $this->l10n->t('Untitled event');
809
+        $data['meeting_description_html'] = $newDescription !== '' ? sprintf($strikethrough, $newDescription) : '';
810
+        $data['meeting_description'] = $newDescription;
811
+        $data['meeting_url_html'] = $newUrl !== '' ? sprintf($strikethrough, $newUrl) : '';
812
+        $data['meeting_url'] = isset($vEvent->URL) ? (string)$vEvent->URL : '';
813
+        $data['meeting_location_html'] = $newLocationHtml !== '' ? sprintf($strikethrough, $newLocationHtml) : '';
814
+        $data['meeting_location'] = $newLocation;
815
+        return $data;
816
+    }
817
+
818
+    /**
819
+     * Check if event took place in the past
820
+     *
821
+     * @param VCalendar $vObject
822
+     * @return int
823
+     */
824
+    public function getLastOccurrence(VCalendar $vObject) {
825
+        /** @var VEvent $component */
826
+        $component = $vObject->VEVENT;
827
+
828
+        if (isset($component->RRULE)) {
829
+            $it = new EventIterator($vObject, (string)$component->UID);
830
+            $maxDate = new \DateTime(IMipPlugin::MAX_DATE);
831
+            if ($it->isInfinite()) {
832
+                return $maxDate->getTimestamp();
833
+            }
834
+
835
+            $end = $it->getDtEnd();
836
+            while ($it->valid() && $end < $maxDate) {
837
+                $end = $it->getDtEnd();
838
+                $it->next();
839
+            }
840
+            return $end->getTimestamp();
841
+        }
842
+
843
+        /** @var Property\ICalendar\DateTime $dtStart */
844
+        $dtStart = $component->DTSTART;
845
+
846
+        if (isset($component->DTEND)) {
847
+            /** @var Property\ICalendar\DateTime $dtEnd */
848
+            $dtEnd = $component->DTEND;
849
+            return $dtEnd->getDateTime()->getTimeStamp();
850
+        }
851
+
852
+        if (isset($component->DURATION)) {
853
+            /** @var \DateTime $endDate */
854
+            $endDate = clone $dtStart->getDateTime();
855
+            // $component->DTEND->getDateTime() returns DateTimeImmutable
856
+            $endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue()));
857
+            return $endDate->getTimestamp();
858
+        }
859
+
860
+        if (!$dtStart->hasTime()) {
861
+            /** @var \DateTime $endDate */
862
+            // $component->DTSTART->getDateTime() returns DateTimeImmutable
863
+            $endDate = clone $dtStart->getDateTime();
864
+            $endDate = $endDate->modify('+1 day');
865
+            return $endDate->getTimestamp();
866
+        }
867
+
868
+        // No computation of end time possible - return start date
869
+        return $dtStart->getDateTime()->getTimeStamp();
870
+    }
871
+
872
+    /**
873
+     * @param Property|null $attendee
874
+     */
875
+    public function setL10n(?Property $attendee = null) {
876
+        if ($attendee === null) {
877
+            return;
878
+        }
879
+
880
+        $lang = $attendee->offsetGet('LANGUAGE');
881
+        if ($lang instanceof Parameter) {
882
+            $lang = $lang->getValue();
883
+            $this->l10n = $this->l10nFactory->get('dav', $lang);
884
+        }
885
+    }
886
+
887
+    /**
888
+     * @param Property|null $attendee
889
+     * @return bool
890
+     */
891
+    public function getAttendeeRsvpOrReqForParticipant(?Property $attendee = null) {
892
+        if ($attendee === null) {
893
+            return false;
894
+        }
895
+
896
+        $rsvp = $attendee->offsetGet('RSVP');
897
+        if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) {
898
+            return true;
899
+        }
900
+        $role = $attendee->offsetGet('ROLE');
901
+        // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16
902
+        // Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set
903
+        if ($role === null
904
+            || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0))
905
+            || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0))
906
+        ) {
907
+            return true;
908
+        }
909
+
910
+        // RFC 5545 3.2.17: default RSVP is false
911
+        return false;
912
+    }
913
+
914
+    /**
915
+     * @param IEMailTemplate $template
916
+     * @param string $method
917
+     * @param string $sender
918
+     * @param string $summary
919
+     * @param string|null $partstat
920
+     * @param bool $isModified
921
+     */
922
+    public function addSubjectAndHeading(IEMailTemplate $template,
923
+        string $method, string $sender, string $summary, bool $isModified, ?Property $replyingAttendee = null): void {
924
+        if ($method === IMipPlugin::METHOD_CANCEL) {
925
+            // TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}"
926
+            $template->setSubject($this->l10n->t('Cancelled: %1$s', [$summary]));
927
+            $template->addHeading($this->l10n->t('"%1$s" has been canceled', [$summary]));
928
+        } elseif ($method === IMipPlugin::METHOD_REPLY) {
929
+            // TRANSLATORS Subject for email, when an invitation is replied to. Ex: "Re: {{Event Name}}"
930
+            $template->setSubject($this->l10n->t('Re: %1$s', [$summary]));
931
+            // Build the strings
932
+            $partstat = (isset($replyingAttendee)) ? $replyingAttendee->offsetGet('PARTSTAT') : null;
933
+            $partstat = ($partstat instanceof Parameter) ? $partstat->getValue() : null;
934
+            switch ($partstat) {
935
+                case 'ACCEPTED':
936
+                    $template->addHeading($this->l10n->t('%1$s has accepted your invitation', [$sender]));
937
+                    break;
938
+                case 'TENTATIVE':
939
+                    $template->addHeading($this->l10n->t('%1$s has tentatively accepted your invitation', [$sender]));
940
+                    break;
941
+                case 'DECLINED':
942
+                    $template->addHeading($this->l10n->t('%1$s has declined your invitation', [$sender]));
943
+                    break;
944
+                case null:
945
+                default:
946
+                    $template->addHeading($this->l10n->t('%1$s has responded to your invitation', [$sender]));
947
+                    break;
948
+            }
949
+        } elseif ($method === IMipPlugin::METHOD_REQUEST && $isModified) {
950
+            // TRANSLATORS Subject for email, when an invitation is updated. Ex: "Invitation updated: {{Event Name}}"
951
+            $template->setSubject($this->l10n->t('Invitation updated: %1$s', [$summary]));
952
+            $template->addHeading($this->l10n->t('%1$s updated the event "%2$s"', [$sender, $summary]));
953
+        } else {
954
+            // TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}"
955
+            $template->setSubject($this->l10n->t('Invitation: %1$s', [$summary]));
956
+            $template->addHeading($this->l10n->t('%1$s would like to invite you to "%2$s"', [$sender, $summary]));
957
+        }
958
+    }
959
+
960
+    /**
961
+     * @param string $path
962
+     * @return string
963
+     */
964
+    public function getAbsoluteImagePath($path): string {
965
+        return $this->urlGenerator->getAbsoluteURL(
966
+            $this->urlGenerator->imagePath('core', $path)
967
+        );
968
+    }
969
+
970
+    /**
971
+     * addAttendees: add organizer and attendee names/emails to iMip mail.
972
+     *
973
+     * Enable with DAV setting: invitation_list_attendees (default: no)
974
+     *
975
+     * The default is 'no', which matches old behavior, and is privacy preserving.
976
+     *
977
+     * To enable including attendees in invitation emails:
978
+     *   % php occ config:app:set dav invitation_list_attendees --value yes
979
+     *
980
+     * @param IEMailTemplate $template
981
+     * @param IL10N $this->l10n
982
+     * @param VEvent $vevent
983
+     * @author brad2014 on github.com
984
+     */
985
+    public function addAttendees(IEMailTemplate $template, VEvent $vevent) {
986
+        if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') {
987
+            return;
988
+        }
989
+
990
+        if (isset($vevent->ORGANIZER)) {
991
+            /** @var Property | Property\ICalendar\CalAddress $organizer */
992
+            $organizer = $vevent->ORGANIZER;
993
+            $organizerEmail = substr($organizer->getNormalizedValue(), 7);
994
+            /** @var string|null $organizerName */
995
+            $organizerName = isset($organizer->CN) ? $organizer->CN->getValue() : null;
996
+            $organizerHTML = sprintf('<a href="%s">%s</a>',
997
+                htmlspecialchars($organizer->getNormalizedValue()),
998
+                htmlspecialchars($organizerName ?: $organizerEmail));
999
+            $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail);
1000
+            if (isset($organizer['PARTSTAT'])) {
1001
+                /** @var Parameter $partstat */
1002
+                $partstat = $organizer['PARTSTAT'];
1003
+                if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
1004
+                    $organizerHTML .= ' ✔︎';
1005
+                    $organizerText .= ' ✔︎';
1006
+                }
1007
+            }
1008
+            $template->addBodyListItem($organizerHTML, $this->l10n->t('Organizer:'),
1009
+                $this->getAbsoluteImagePath('caldav/organizer.png'),
1010
+                $organizerText, '', IMipPlugin::IMIP_INDENT);
1011
+        }
1012
+
1013
+        $attendees = $vevent->select('ATTENDEE');
1014
+        if (count($attendees) === 0) {
1015
+            return;
1016
+        }
1017
+
1018
+        $attendeesHTML = [];
1019
+        $attendeesText = [];
1020
+        foreach ($attendees as $attendee) {
1021
+            $attendeeEmail = substr($attendee->getNormalizedValue(), 7);
1022
+            $attendeeName = isset($attendee['CN']) ? $attendee['CN']->getValue() : null;
1023
+            $attendeeHTML = sprintf('<a href="%s">%s</a>',
1024
+                htmlspecialchars($attendee->getNormalizedValue()),
1025
+                htmlspecialchars($attendeeName ?: $attendeeEmail));
1026
+            $attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail);
1027
+            if (isset($attendee['PARTSTAT'])) {
1028
+                /** @var Parameter $partstat */
1029
+                $partstat = $attendee['PARTSTAT'];
1030
+                if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
1031
+                    $attendeeHTML .= ' ✔︎';
1032
+                    $attendeeText .= ' ✔︎';
1033
+                }
1034
+            }
1035
+            $attendeesHTML[] = $attendeeHTML;
1036
+            $attendeesText[] = $attendeeText;
1037
+        }
1038
+
1039
+        $template->addBodyListItem(implode('<br/>', $attendeesHTML), $this->l10n->t('Attendees:'),
1040
+            $this->getAbsoluteImagePath('caldav/attendees.png'),
1041
+            implode("\n", $attendeesText), '', IMipPlugin::IMIP_INDENT);
1042
+    }
1043
+
1044
+    /**
1045
+     * @param IEMailTemplate $template
1046
+     * @param VEVENT $vevent
1047
+     * @param $data
1048
+     */
1049
+    public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) {
1050
+        $template->addBodyListItem(
1051
+            $data['meeting_title_html'] ?? $data['meeting_title'], $this->l10n->t('Title:'),
1052
+            $this->getAbsoluteImagePath('caldav/title.png'), $data['meeting_title'], '', IMipPlugin::IMIP_INDENT);
1053
+        if ($data['meeting_when'] !== '') {
1054
+            $template->addBodyListItem($data['meeting_when_html'] ?? $data['meeting_when'], $this->l10n->t('When:'),
1055
+                $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_when'], '', IMipPlugin::IMIP_INDENT);
1056
+        }
1057
+        if ($data['meeting_location'] !== '') {
1058
+            $template->addBodyListItem($data['meeting_location_html'] ?? $data['meeting_location'], $this->l10n->t('Location:'),
1059
+                $this->getAbsoluteImagePath('caldav/location.png'), $data['meeting_location'], '', IMipPlugin::IMIP_INDENT);
1060
+        }
1061
+        if ($data['meeting_url'] !== '') {
1062
+            $template->addBodyListItem($data['meeting_url_html'] ?? $data['meeting_url'], $this->l10n->t('Link:'),
1063
+                $this->getAbsoluteImagePath('caldav/link.png'), $data['meeting_url'], '', IMipPlugin::IMIP_INDENT);
1064
+        }
1065
+        if (isset($data['meeting_occurring'])) {
1066
+            $template->addBodyListItem($data['meeting_occurring_html'] ?? $data['meeting_occurring'], $this->l10n->t('Occurring:'),
1067
+                $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_occurring'], '', IMipPlugin::IMIP_INDENT);
1068
+        }
1069
+
1070
+        $this->addAttendees($template, $vevent);
1071
+
1072
+        /* Put description last, like an email body, since it can be arbitrarily long */
1073
+        if ($data['meeting_description']) {
1074
+            $template->addBodyListItem($data['meeting_description_html'] ?? $data['meeting_description'], $this->l10n->t('Description:'),
1075
+                $this->getAbsoluteImagePath('caldav/description.png'), $data['meeting_description'], '', IMipPlugin::IMIP_INDENT);
1076
+        }
1077
+    }
1078
+
1079
+    /**
1080
+     * @param Message $iTipMessage
1081
+     * @return null|Property
1082
+     */
1083
+    public function getCurrentAttendee(Message $iTipMessage): ?Property {
1084
+        /** @var VEvent $vevent */
1085
+        $vevent = $iTipMessage->message->VEVENT;
1086
+        $attendees = $vevent->select('ATTENDEE');
1087
+        foreach ($attendees as $attendee) {
1088
+            if ($iTipMessage->method === 'REPLY' && strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
1089
+                /** @var Property $attendee */
1090
+                return $attendee;
1091
+            } elseif (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
1092
+                /** @var Property $attendee */
1093
+                return $attendee;
1094
+            }
1095
+        }
1096
+        return null;
1097
+    }
1098
+
1099
+    /**
1100
+     * @param Message $iTipMessage
1101
+     * @param VEvent $vevent
1102
+     * @param int $lastOccurrence
1103
+     * @return string
1104
+     */
1105
+    public function createInvitationToken(Message $iTipMessage, VEvent $vevent, int $lastOccurrence): string {
1106
+        $token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC);
1107
+
1108
+        $attendee = $iTipMessage->recipient;
1109
+        $organizer = $iTipMessage->sender;
1110
+        $sequence = $iTipMessage->sequence;
1111
+        $recurrenceId = isset($vevent->{'RECURRENCE-ID'})
1112
+            ? $vevent->{'RECURRENCE-ID'}->serialize() : null;
1113
+        $uid = $vevent->{'UID'}?->getValue();
1114
+
1115
+        $query = $this->db->getQueryBuilder();
1116
+        $query->insert('calendar_invitations')
1117
+            ->values([
1118
+                'token' => $query->createNamedParameter($token),
1119
+                'attendee' => $query->createNamedParameter($attendee),
1120
+                'organizer' => $query->createNamedParameter($organizer),
1121
+                'sequence' => $query->createNamedParameter($sequence),
1122
+                'recurrenceid' => $query->createNamedParameter($recurrenceId),
1123
+                'expiration' => $query->createNamedParameter($lastOccurrence),
1124
+                'uid' => $query->createNamedParameter($uid)
1125
+            ])
1126
+            ->executeStatement();
1127
+
1128
+        return $token;
1129
+    }
1130
+
1131
+    /**
1132
+     * @param IEMailTemplate $template
1133
+     * @param $token
1134
+     */
1135
+    public function addResponseButtons(IEMailTemplate $template, $token) {
1136
+        $template->addBodyButtonGroup(
1137
+            $this->l10n->t('Accept'),
1138
+            $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [
1139
+                'token' => $token,
1140
+            ]),
1141
+            $this->l10n->t('Decline'),
1142
+            $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [
1143
+                'token' => $token,
1144
+            ])
1145
+        );
1146
+    }
1147
+
1148
+    public function addMoreOptionsButton(IEMailTemplate $template, $token) {
1149
+        $moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [
1150
+            'token' => $token,
1151
+        ]);
1152
+        $html = vsprintf('<small><a href="%s">%s</a></small>', [
1153
+            $moreOptionsURL, $this->l10n->t('More options …')
1154
+        ]);
1155
+        $text = $this->l10n->t('More options at %s', [$moreOptionsURL]);
1156
+
1157
+        $template->addBodyText($html, $text);
1158
+    }
1159
+
1160
+    public function getReplyingAttendee(Message $iTipMessage): ?Property {
1161
+        /** @var VEvent $vevent */
1162
+        $vevent = $iTipMessage->message->VEVENT;
1163
+        $attendees = $vevent->select('ATTENDEE');
1164
+        foreach ($attendees as $attendee) {
1165
+            /** @var Property $attendee */
1166
+            if (strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
1167
+                return $attendee;
1168
+            }
1169
+        }
1170
+        return null;
1171
+    }
1172
+
1173
+    public function isRoomOrResource(Property $attendee): bool {
1174
+        $cuType = $attendee->offsetGet('CUTYPE');
1175
+        if (!$cuType instanceof Parameter) {
1176
+            return false;
1177
+        }
1178
+        $type = $cuType->getValue() ?? 'INDIVIDUAL';
1179
+        if (\in_array(strtoupper($type), ['RESOURCE', 'ROOM'], true)) {
1180
+            // Don't send emails to things
1181
+            return true;
1182
+        }
1183
+        return false;
1184
+    }
1185
+
1186
+    public function isCircle(Property $attendee): bool {
1187
+        $cuType = $attendee->offsetGet('CUTYPE');
1188
+        if (!$cuType instanceof Parameter) {
1189
+            return false;
1190
+        }
1191
+
1192
+        $uri = $attendee->getValue();
1193
+        if (!$uri) {
1194
+            return false;
1195
+        }
1196
+
1197
+        $cuTypeValue = $cuType->getValue();
1198
+        return $cuTypeValue === 'GROUP' && str_starts_with($uri, 'mailto:circle+');
1199
+    }
1200
+
1201
+    public function minimizeInterval(\DateInterval $dateInterval): array {
1202
+        // evaluate if time interval is in the past
1203
+        if ($dateInterval->invert == 1) {
1204
+            return ['interval' => 1, 'scale' => 'past'];
1205
+        }
1206
+        // evaluate interval parts and return smallest time period
1207
+        if ($dateInterval->y > 0) {
1208
+            $interval = $dateInterval->y;
1209
+            $scale = 'year';
1210
+        } elseif ($dateInterval->m > 0) {
1211
+            $interval = $dateInterval->m;
1212
+            $scale = 'month';
1213
+        } elseif ($dateInterval->d >= 7) {
1214
+            $interval = (int)($dateInterval->d / 7);
1215
+            $scale = 'week';
1216
+        } elseif ($dateInterval->d > 0) {
1217
+            $interval = $dateInterval->d;
1218
+            $scale = 'day';
1219
+        } elseif ($dateInterval->h > 0) {
1220
+            $interval = $dateInterval->h;
1221
+            $scale = 'hour';
1222
+        } else {
1223
+            $interval = $dateInterval->i;
1224
+            $scale = 'minute';
1225
+        }
1226
+
1227
+        return ['interval' => $interval, 'scale' => $scale];
1228
+    }
1229
+
1230
+    /**
1231
+     * Localizes week day names to another language
1232
+     *
1233
+     * @param string $value
1234
+     *
1235
+     * @return string
1236
+     */
1237
+    public function localizeDayName(string $value): string {
1238
+        return match ($value) {
1239
+            'Monday' => $this->l10n->t('Monday'),
1240
+            'Tuesday' => $this->l10n->t('Tuesday'),
1241
+            'Wednesday' => $this->l10n->t('Wednesday'),
1242
+            'Thursday' => $this->l10n->t('Thursday'),
1243
+            'Friday' => $this->l10n->t('Friday'),
1244
+            'Saturday' => $this->l10n->t('Saturday'),
1245
+            'Sunday' => $this->l10n->t('Sunday'),
1246
+        };
1247
+    }
1248
+
1249
+    /**
1250
+     * Localizes month names to another language
1251
+     *
1252
+     * @param string $value
1253
+     *
1254
+     * @return string
1255
+     */
1256
+    public function localizeMonthName(string $value): string {
1257
+        return match ($value) {
1258
+            'January' => $this->l10n->t('January'),
1259
+            'February' => $this->l10n->t('February'),
1260
+            'March' => $this->l10n->t('March'),
1261
+            'April' => $this->l10n->t('April'),
1262
+            'May' => $this->l10n->t('May'),
1263
+            'June' => $this->l10n->t('June'),
1264
+            'July' => $this->l10n->t('July'),
1265
+            'August' => $this->l10n->t('August'),
1266
+            'September' => $this->l10n->t('September'),
1267
+            'October' => $this->l10n->t('October'),
1268
+            'November' => $this->l10n->t('November'),
1269
+            'December' => $this->l10n->t('December'),
1270
+        };
1271
+    }
1272
+
1273
+    /**
1274
+     * Localizes relative position names to another language
1275
+     *
1276
+     * @param string $value
1277
+     *
1278
+     * @return string
1279
+     */
1280
+    public function localizeRelativePositionName(string $value): string {
1281
+        return match ($value) {
1282
+            'First' => $this->l10n->t('First'),
1283
+            'Second' => $this->l10n->t('Second'),
1284
+            'Third' => $this->l10n->t('Third'),
1285
+            'Fourth' => $this->l10n->t('Fourth'),
1286
+            'Fifth' => $this->l10n->t('Fifth'),
1287
+            'Last' => $this->l10n->t('Last'),
1288
+            'Second Last' => $this->l10n->t('Second Last'),
1289
+            'Third Last' => $this->l10n->t('Third Last'),
1290
+            'Fourth Last' => $this->l10n->t('Fourth Last'),
1291
+            'Fifth Last' => $this->l10n->t('Fifth Last'),
1292
+        };
1293
+    }
1294 1294
 }
Please login to merge, or discard this patch.
apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php 1 patch
Indentation   +2172 added lines, -2172 removed lines patch added patch discarded remove patch
@@ -24,2177 +24,2177 @@
 block discarded – undo
24 24
 use Test\TestCase;
25 25
 
26 26
 class IMipServiceTest extends TestCase {
27
-	private URLGenerator&MockObject $urlGenerator;
28
-	private IConfig&MockObject $config;
29
-	private IDBConnection&MockObject $db;
30
-	private ISecureRandom&MockObject $random;
31
-	private IFactory&MockObject $l10nFactory;
32
-	private IL10N&MockObject $l10n;
33
-	private ITimeFactory&MockObject $timeFactory;
34
-	private IMipService $service;
35
-
36
-
37
-	private VCalendar $vCalendar1a;
38
-	private VCalendar $vCalendar1b;
39
-	private VCalendar $vCalendar2;
40
-	private VCalendar $vCalendar3;
41
-	/** @var DateTime DateTime object that will be returned by DateTime() or DateTime('now') */
42
-	public static $datetimeNow;
43
-
44
-	protected function setUp(): void {
45
-		parent::setUp();
46
-
47
-		$this->urlGenerator = $this->createMock(URLGenerator::class);
48
-		$this->config = $this->createMock(IConfig::class);
49
-		$this->db = $this->createMock(IDBConnection::class);
50
-		$this->random = $this->createMock(ISecureRandom::class);
51
-		$this->l10nFactory = $this->createMock(IFactory::class);
52
-		$this->l10n = $this->createMock(IL10N::class);
53
-		$this->timeFactory = $this->createMock(ITimeFactory::class);
54
-		$this->l10nFactory->expects(self::once())
55
-			->method('findGenericLanguage')
56
-			->willReturn('en');
57
-		$this->l10nFactory->expects(self::once())
58
-			->method('get')
59
-			->with('dav', 'en')
60
-			->willReturn($this->l10n);
61
-		$this->service = new IMipService(
62
-			$this->urlGenerator,
63
-			$this->config,
64
-			$this->db,
65
-			$this->random,
66
-			$this->l10nFactory,
67
-			$this->timeFactory
68
-		);
69
-
70
-		// construct calendar with a 1 hour event and same start/end time zones
71
-		$this->vCalendar1a = new VCalendar();
72
-		$vEvent = $this->vCalendar1a->add('VEVENT', []);
73
-		$vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
74
-		$vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']);
75
-		$vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']);
76
-		$vEvent->add('SUMMARY', 'Testing Event');
77
-		$vEvent->add('ORGANIZER', 'mailto:[email protected]', ['CN' => 'Organizer']);
78
-		$vEvent->add('ATTENDEE', 'mailto:[email protected]', [
79
-			'CN' => 'Attendee One',
80
-			'CUTYPE' => 'INDIVIDUAL',
81
-			'PARTSTAT' => 'NEEDS-ACTION',
82
-			'ROLE' => 'REQ-PARTICIPANT',
83
-			'RSVP' => 'TRUE'
84
-		]);
85
-
86
-		// construct calendar with a 1 hour event and different start/end time zones
87
-		$this->vCalendar1b = new VCalendar();
88
-		$vEvent = $this->vCalendar1b->add('VEVENT', []);
89
-		$vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
90
-		$vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']);
91
-		$vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Vancouver']);
92
-		$vEvent->add('SUMMARY', 'Testing Event');
93
-		$vEvent->add('ORGANIZER', 'mailto:[email protected]', ['CN' => 'Organizer']);
94
-		$vEvent->add('ATTENDEE', 'mailto:[email protected]', [
95
-			'CN' => 'Attendee One',
96
-			'CUTYPE' => 'INDIVIDUAL',
97
-			'PARTSTAT' => 'NEEDS-ACTION',
98
-			'ROLE' => 'REQ-PARTICIPANT',
99
-			'RSVP' => 'TRUE'
100
-		]);
101
-
102
-		// construct calendar with a full day event
103
-		$this->vCalendar2 = new VCalendar();
104
-		// time zone component
105
-		$vTimeZone = $this->vCalendar2->add('VTIMEZONE');
106
-		$vTimeZone->add('TZID', 'America/Toronto');
107
-		// event component
108
-		$vEvent = $this->vCalendar2->add('VEVENT', []);
109
-		$vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
110
-		$vEvent->add('DTSTART', '20240701');
111
-		$vEvent->add('DTEND', '20240702');
112
-		$vEvent->add('SUMMARY', 'Testing Event');
113
-		$vEvent->add('ORGANIZER', 'mailto:[email protected]', ['CN' => 'Organizer']);
114
-		$vEvent->add('ATTENDEE', 'mailto:[email protected]', [
115
-			'CN' => 'Attendee One',
116
-			'CUTYPE' => 'INDIVIDUAL',
117
-			'PARTSTAT' => 'NEEDS-ACTION',
118
-			'ROLE' => 'REQ-PARTICIPANT',
119
-			'RSVP' => 'TRUE'
120
-		]);
121
-
122
-		// construct calendar with a multi day event
123
-		$this->vCalendar3 = new VCalendar();
124
-		// time zone component
125
-		$vTimeZone = $this->vCalendar3->add('VTIMEZONE');
126
-		$vTimeZone->add('TZID', 'America/Toronto');
127
-		// event component
128
-		$vEvent = $this->vCalendar3->add('VEVENT', []);
129
-		$vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
130
-		$vEvent->add('DTSTART', '20240701');
131
-		$vEvent->add('DTEND', '20240706');
132
-		$vEvent->add('SUMMARY', 'Testing Event');
133
-		$vEvent->add('ORGANIZER', 'mailto:[email protected]', ['CN' => 'Organizer']);
134
-		$vEvent->add('ATTENDEE', 'mailto:[email protected]', [
135
-			'CN' => 'Attendee One',
136
-			'CUTYPE' => 'INDIVIDUAL',
137
-			'PARTSTAT' => 'NEEDS-ACTION',
138
-			'ROLE' => 'REQ-PARTICIPANT',
139
-			'RSVP' => 'TRUE'
140
-		]);
141
-	}
142
-
143
-	public function testGetFrom(): void {
144
-		$senderName = 'Detective McQueen';
145
-		$default = 'Twin Lakes Police Department - Darkside Division';
146
-		$expected = 'Detective McQueen via Twin Lakes Police Department - Darkside Division';
147
-
148
-		$this->l10n->expects(self::once())
149
-			->method('t')
150
-			->willReturn($expected);
151
-
152
-		$actual = $this->service->getFrom($senderName, $default);
153
-		$this->assertEquals($expected, $actual);
154
-	}
155
-
156
-	public function testBuildBodyDataCreated(): void {
157
-
158
-		// construct l10n return(s)
159
-		$this->l10n->method('l')->willReturnCallback(
160
-			function ($v1, $v2, $v3) {
161
-				return match (true) {
162
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
163
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
164
-					$v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'full'] => 'July 1, 2024'
165
-				};
166
-			}
167
-		);
168
-		$this->l10n->method('n')->willReturnMap([
169
-			[
170
-				'In %n day on %1$s between %2$s - %3$s',
171
-				'In %n days on %1$s between %2$s - %3$s',
172
-				1,
173
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
174
-				'In 1 day on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
175
-			]
176
-		]);
177
-		// construct time factory return(s)
178
-		$this->timeFactory->method('getDateTime')->willReturnCallback(
179
-			function ($v1, $v2) {
180
-				return match (true) {
181
-					$v1 == 'now' && $v2 == null => (new \DateTime('20240630T000000'))
182
-				};
183
-			}
184
-		);
185
-		/** test singleton partial day event*/
186
-		$vCalendar = clone $this->vCalendar1a;
187
-		// construct event reader
188
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
189
-		// define expected output
190
-		$expected = [
191
-			'meeting_when' => $this->service->generateWhenString($eventReader),
192
-			'meeting_description' => '',
193
-			'meeting_title' => 'Testing Event',
194
-			'meeting_location' => '',
195
-			'meeting_url' => '',
196
-			'meeting_url_html' => '',
197
-		];
198
-		// generate actual output
199
-		$actual = $this->service->buildBodyData($vCalendar->VEVENT[0], null);
200
-		// test output
201
-		$this->assertEquals($expected, $actual);
202
-	}
203
-
204
-	public function testBuildBodyDataUpdate(): void {
205
-
206
-		// construct l10n return(s)
207
-		$this->l10n->method('l')->willReturnCallback(
208
-			function ($v1, $v2, $v3) {
209
-				return match (true) {
210
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
211
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
212
-					$v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'full'] => 'July 1, 2024'
213
-				};
214
-			}
215
-		);
216
-		$this->l10n->method('n')->willReturnMap([
217
-			[
218
-				'In %n day on %1$s between %2$s - %3$s',
219
-				'In %n days on %1$s between %2$s - %3$s',
220
-				1,
221
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
222
-				'In 1 day on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
223
-			]
224
-		]);
225
-		// construct time factory return(s)
226
-		$this->timeFactory->method('getDateTime')->willReturnCallback(
227
-			function ($v1, $v2) {
228
-				return match (true) {
229
-					$v1 == 'now' && $v2 == null => (new \DateTime('20240630T000000'))
230
-				};
231
-			}
232
-		);
233
-		/** test singleton partial day event*/
234
-		$vCalendarNew = clone $this->vCalendar1a;
235
-		$vCalendarOld = clone $this->vCalendar1a;
236
-		// construct event reader
237
-		$eventReaderNew = new EventReader($vCalendarNew, $vCalendarNew->VEVENT[0]->UID->getValue());
238
-		// alter old event label/title
239
-		$vCalendarOld->VEVENT[0]->SUMMARY->setValue('Testing Singleton Event');
240
-		// define expected output
241
-		$expected = [
242
-			'meeting_when' => $this->service->generateWhenString($eventReaderNew),
243
-			'meeting_description' => '',
244
-			'meeting_title' => 'Testing Event',
245
-			'meeting_location' => '',
246
-			'meeting_url' => '',
247
-			'meeting_url_html' => '',
248
-			'meeting_when_html' => $this->service->generateWhenString($eventReaderNew),
249
-			'meeting_title_html' => sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", 'Testing Singleton Event', 'Testing Event'),
250
-			'meeting_description_html' => '',
251
-			'meeting_location_html' => ''
252
-		];
253
-		// generate actual output
254
-		$actual = $this->service->buildBodyData($vCalendarNew->VEVENT[0], $vCalendarOld->VEVENT[0]);
255
-		// test output
256
-		$this->assertEquals($expected, $actual);
257
-	}
258
-
259
-	public function testGetLastOccurrenceRRULE(): void {
260
-		$vCalendar = new VCalendar();
261
-		$vCalendar->add('VEVENT', [
262
-			'UID' => 'uid-1234',
263
-			'LAST-MODIFIED' => 123456,
264
-			'SEQUENCE' => 2,
265
-			'SUMMARY' => 'Fellowship meeting',
266
-			'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
267
-			'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z',
268
-		]);
269
-
270
-		$occurrence = $this->service->getLastOccurrence($vCalendar);
271
-		$this->assertEquals(1454284800, $occurrence);
272
-	}
273
-
274
-	public function testGetLastOccurrenceEndDate(): void {
275
-		$vCalendar = new VCalendar();
276
-		$vCalendar->add('VEVENT', [
277
-			'UID' => 'uid-1234',
278
-			'LAST-MODIFIED' => 123456,
279
-			'SEQUENCE' => 2,
280
-			'SUMMARY' => 'Fellowship meeting',
281
-			'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
282
-			'DTEND' => new \DateTime('2017-01-01 00:00:00'),
283
-		]);
284
-
285
-		$occurrence = $this->service->getLastOccurrence($vCalendar);
286
-		$this->assertEquals(1483228800, $occurrence);
287
-	}
288
-
289
-	public function testGetLastOccurrenceDuration(): void {
290
-		$vCalendar = new VCalendar();
291
-		$vCalendar->add('VEVENT', [
292
-			'UID' => 'uid-1234',
293
-			'LAST-MODIFIED' => 123456,
294
-			'SEQUENCE' => 2,
295
-			'SUMMARY' => 'Fellowship meeting',
296
-			'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
297
-			'DURATION' => 'P12W',
298
-		]);
299
-
300
-		$occurrence = $this->service->getLastOccurrence($vCalendar);
301
-		$this->assertEquals(1458864000, $occurrence);
302
-	}
303
-
304
-	public function testGetLastOccurrenceAllDay(): void {
305
-		$vCalendar = new VCalendar();
306
-		$vEvent = $vCalendar->add('VEVENT', [
307
-			'UID' => 'uid-1234',
308
-			'LAST-MODIFIED' => 123456,
309
-			'SEQUENCE' => 2,
310
-			'SUMMARY' => 'Fellowship meeting',
311
-			'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
312
-		]);
313
-
314
-		// rewrite from DateTime to Date
315
-		$vEvent->DTSTART['VALUE'] = 'DATE';
316
-
317
-		$occurrence = $this->service->getLastOccurrence($vCalendar);
318
-		$this->assertEquals(1451692800, $occurrence);
319
-	}
320
-
321
-	public function testGetLastOccurrenceFallback(): void {
322
-		$vCalendar = new VCalendar();
323
-		$vCalendar->add('VEVENT', [
324
-			'UID' => 'uid-1234',
325
-			'LAST-MODIFIED' => 123456,
326
-			'SEQUENCE' => 2,
327
-			'SUMMARY' => 'Fellowship meeting',
328
-			'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
329
-		]);
330
-
331
-		$occurrence = $this->service->getLastOccurrence($vCalendar);
332
-		$this->assertEquals(1451606400, $occurrence);
333
-	}
334
-
335
-	public function testGenerateWhenStringSingular(): void {
336
-
337
-		// construct l10n return(s)
338
-		$this->l10n->method('l')->willReturnCallback(
339
-			function ($v1, $v2, $v3) {
340
-				return match (true) {
341
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
342
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
343
-					$v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'full'] => 'July 1, 2024',
344
-					$v1 === 'date' && $v2 == (new \DateTime('20240701T000000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'full'] => 'July 1, 2024'
345
-				};
346
-			}
347
-		);
348
-		$this->l10n->method('t')->willReturnMap([
349
-			[
350
-				'In the past on %1$s for the entire day',
351
-				['July 1, 2024'],
352
-				'In the past on July 1, 2024 for the entire day'
353
-			],
354
-			[
355
-				'In the past on %1$s between %2$s - %3$s',
356
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
357
-				'In the past on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
358
-			],
359
-		]);
360
-		$this->l10n->method('n')->willReturnMap([
361
-			// singular entire day
362
-			[
363
-				'In %n minute on %1$s for the entire day',
364
-				'In %n minutes on %1$s for the entire day',
365
-				1,
366
-				['July 1, 2024'],
367
-				'In 1 minute on July 1, 2024 for the entire day'
368
-			],
369
-			[
370
-				'In %n hour on %1$s for the entire day',
371
-				'In %n hours on %1$s for the entire day',
372
-				1,
373
-				['July 1, 2024'],
374
-				'In 1 hour on July 1, 2024 for the entire day'
375
-			],
376
-			[
377
-				'In %n day on %1$s for the entire day',
378
-				'In %n days on %1$s for the entire day',
379
-				1,
380
-				['July 1, 2024'],
381
-				'In 1 day on July 1, 2024 for the entire day'
382
-			],
383
-			[
384
-				'In %n week on %1$s for the entire day',
385
-				'In %n weeks on %1$s for the entire day',
386
-				1,
387
-				['July 1, 2024'],
388
-				'In 1 week on July 1, 2024 for the entire day'
389
-			],
390
-			[
391
-				'In %n month on %1$s for the entire day',
392
-				'In %n months on %1$s for the entire day',
393
-				1,
394
-				['July 1, 2024'],
395
-				'In 1 month on July 1, 2024 for the entire day'
396
-			],
397
-			[
398
-				'In %n year on %1$s for the entire day',
399
-				'In %n years on %1$s for the entire day',
400
-				1,
401
-				['July 1, 2024'],
402
-				'In 1 year on July 1, 2024 for the entire day'
403
-			],
404
-			// plural entire day
405
-			[
406
-				'In %n minute on %1$s for the entire day',
407
-				'In %n minutes on %1$s for the entire day',
408
-				2,
409
-				['July 1, 2024'],
410
-				'In 2 minutes on July 1, 2024 for the entire day'
411
-			],
412
-			[
413
-				'In %n hour on %1$s for the entire day',
414
-				'In %n hours on %1$s for the entire day',
415
-				2,
416
-				['July 1, 2024'],
417
-				'In 2 hours on July 1, 2024 for the entire day'
418
-			],
419
-			[
420
-				'In %n day on %1$s for the entire day',
421
-				'In %n days on %1$s for the entire day',
422
-				2,
423
-				['July 1, 2024'],
424
-				'In 2 days on July 1, 2024 for the entire day'
425
-			],
426
-			[
427
-				'In %n week on %1$s for the entire day',
428
-				'In %n weeks on %1$s for the entire day',
429
-				2,
430
-				['July 1, 2024'],
431
-				'In 2 weeks on July 1, 2024 for the entire day'
432
-			],
433
-			[
434
-				'In %n month on %1$s for the entire day',
435
-				'In %n months on %1$s for the entire day',
436
-				2,
437
-				['July 1, 2024'],
438
-				'In 2 months on July 1, 2024 for the entire day'
439
-			],
440
-			[
441
-				'In %n year on %1$s for the entire day',
442
-				'In %n years on %1$s for the entire day',
443
-				2,
444
-				['July 1, 2024'],
445
-				'In 2 years on July 1, 2024 for the entire day'
446
-			],
447
-			// singular partial day
448
-			[
449
-				'In %n minute on %1$s between %2$s - %3$s',
450
-				'In %n minutes on %1$s between %2$s - %3$s',
451
-				1,
452
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
453
-				'In 1 minute on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
454
-			],
455
-			[
456
-				'In %n hour on %1$s between %2$s - %3$s',
457
-				'In %n hours on %1$s between %2$s - %3$s',
458
-				1,
459
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
460
-				'In 1 hour on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
461
-			],
462
-			[
463
-				'In %n day on %1$s between %2$s - %3$s',
464
-				'In %n days on %1$s between %2$s - %3$s',
465
-				1,
466
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
467
-				'In 1 day on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
468
-			],
469
-			[
470
-				'In %n week on %1$s between %2$s - %3$s',
471
-				'In %n weeks on %1$s between %2$s - %3$s',
472
-				1,
473
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
474
-				'In 1 week on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
475
-			],
476
-			[
477
-				'In %n month on %1$s between %2$s - %3$s',
478
-				'In %n months on %1$s between %2$s - %3$s',
479
-				1,
480
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
481
-				'In 1 month on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
482
-			],
483
-			[
484
-				'In %n year on %1$s between %2$s - %3$s',
485
-				'In %n years on %1$s between %2$s - %3$s',
486
-				1,
487
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
488
-				'In 1 year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
489
-			],
490
-			// plural partial day
491
-			[
492
-				'In %n minute on %1$s between %2$s - %3$s',
493
-				'In %n minutes on %1$s between %2$s - %3$s',
494
-				2,
495
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
496
-				'In 2 minutes on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
497
-			],
498
-			[
499
-				'In %n hour on %1$s between %2$s - %3$s',
500
-				'In %n hours on %1$s between %2$s - %3$s',
501
-				2,
502
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
503
-				'In 2 hours on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
504
-			],
505
-			[
506
-				'In %n day on %1$s between %2$s - %3$s',
507
-				'In %n days on %1$s between %2$s - %3$s',
508
-				2,
509
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
510
-				'In 2 days on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
511
-			],
512
-			[
513
-				'In %n week on %1$s between %2$s - %3$s',
514
-				'In %n weeks on %1$s between %2$s - %3$s',
515
-				2,
516
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
517
-				'In 2 weeks on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
518
-			],
519
-			[
520
-				'In %n month on %1$s between %2$s - %3$s',
521
-				'In %n months on %1$s between %2$s - %3$s',
522
-				2,
523
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
524
-				'In 2 months on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
525
-			],
526
-			[
527
-				'In %n year on %1$s between %2$s - %3$s',
528
-				'In %n years on %1$s between %2$s - %3$s',
529
-				2,
530
-				['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
531
-				'In 2 years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
532
-			],
533
-		]);
534
-
535
-		// construct time factory return(s)
536
-		$this->timeFactory->method('getDateTime')->willReturnOnConsecutiveCalls(
537
-			// past interval test dates
538
-			(new \DateTime('20240702T170000', (new \DateTimeZone('America/Toronto')))),
539
-			(new \DateTime('20240703T170000', (new \DateTimeZone('America/Toronto')))),
540
-			(new \DateTime('20240702T170000', (new \DateTimeZone('America/Toronto')))),
541
-			(new \DateTime('20240703T170000', (new \DateTimeZone('America/Toronto')))),
542
-			// minute interval test dates
543
-			(new \DateTime('20240701T075900', (new \DateTimeZone('America/Toronto')))),
544
-			(new \DateTime('20240630T235900', (new \DateTimeZone('America/Toronto')))),
545
-			(new \DateTime('20240701T075800', (new \DateTimeZone('America/Toronto')))),
546
-			(new \DateTime('20240630T235800', (new \DateTimeZone('America/Toronto')))),
547
-			// hour interval test dates
548
-			(new \DateTime('20240701T070000', (new \DateTimeZone('America/Toronto')))),
549
-			(new \DateTime('20240630T230000', (new \DateTimeZone('America/Toronto')))),
550
-			(new \DateTime('20240701T060000', (new \DateTimeZone('America/Toronto')))),
551
-			(new \DateTime('20240630T220000', (new \DateTimeZone('America/Toronto')))),
552
-			// day interval test dates
553
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
554
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
555
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
556
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
557
-			// week interval test dates
558
-			(new \DateTime('20240621T170000', (new \DateTimeZone('America/Toronto')))),
559
-			(new \DateTime('20240621T170000', (new \DateTimeZone('America/Toronto')))),
560
-			(new \DateTime('20240614T170000', (new \DateTimeZone('America/Toronto')))),
561
-			(new \DateTime('20240614T170000', (new \DateTimeZone('America/Toronto')))),
562
-			// month interval test dates
563
-			(new \DateTime('20240530T170000', (new \DateTimeZone('America/Toronto')))),
564
-			(new \DateTime('20240530T170000', (new \DateTimeZone('America/Toronto')))),
565
-			(new \DateTime('20240430T170000', (new \DateTimeZone('America/Toronto')))),
566
-			(new \DateTime('20240430T170000', (new \DateTimeZone('America/Toronto')))),
567
-			// year interval test dates
568
-			(new \DateTime('20230630T170000', (new \DateTimeZone('America/Toronto')))),
569
-			(new \DateTime('20230630T170000', (new \DateTimeZone('America/Toronto')))),
570
-			(new \DateTime('20220630T170000', (new \DateTimeZone('America/Toronto')))),
571
-			(new \DateTime('20220630T170000', (new \DateTimeZone('America/Toronto'))))
572
-		);
573
-
574
-		/** test partial day event in 1 day in the past*/
575
-		$vCalendar = clone $this->vCalendar1a;
576
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
577
-		$this->assertEquals(
578
-			'In the past on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
579
-			$this->service->generateWhenString($eventReader)
580
-		);
581
-
582
-		/** test entire day event in 1 day in the past*/
583
-		$vCalendar = clone $this->vCalendar2;
584
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
585
-		$this->assertEquals(
586
-			'In the past on July 1, 2024 for the entire day',
587
-			$this->service->generateWhenString($eventReader)
588
-		);
589
-
590
-		/** test partial day event in 2 days in the past*/
591
-		$vCalendar = clone $this->vCalendar1a;
592
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
593
-		$this->assertEquals(
594
-			'In the past on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
595
-			$this->service->generateWhenString($eventReader)
596
-		);
597
-
598
-		/** test entire day event in 2 days in the past*/
599
-		$vCalendar = clone $this->vCalendar2;
600
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
601
-		$this->assertEquals(
602
-			'In the past on July 1, 2024 for the entire day',
603
-			$this->service->generateWhenString($eventReader)
604
-		);
605
-
606
-		/** test partial day event in 1 minute*/
607
-		$vCalendar = clone $this->vCalendar1a;
608
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
609
-		$this->assertEquals(
610
-			'In 1 minute on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
611
-			$this->service->generateWhenString($eventReader)
612
-		);
613
-
614
-		/** test entire day event in 1 minute*/
615
-		$vCalendar = clone $this->vCalendar2;
616
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
617
-		$this->assertEquals(
618
-			'In 1 minute on July 1, 2024 for the entire day',
619
-			$this->service->generateWhenString($eventReader)
620
-		);
621
-
622
-		/** test partial day event in 2 minutes*/
623
-		$vCalendar = clone $this->vCalendar1a;
624
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
625
-		$this->assertEquals(
626
-			'In 2 minutes on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
627
-			$this->service->generateWhenString($eventReader)
628
-		);
629
-
630
-		/** test entire day event in 2 minutes*/
631
-		$vCalendar = clone $this->vCalendar2;
632
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
633
-		$this->assertEquals(
634
-			'In 2 minutes on July 1, 2024 for the entire day',
635
-			$this->service->generateWhenString($eventReader)
636
-		);
637
-
638
-		/** test partial day event in 1 hour*/
639
-		$vCalendar = clone $this->vCalendar1a;
640
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
641
-		$this->assertEquals(
642
-			'In 1 hour on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
643
-			$this->service->generateWhenString($eventReader)
644
-		);
645
-
646
-		/** test entire day event in 1 hour*/
647
-		$vCalendar = clone $this->vCalendar2;
648
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
649
-		$this->assertEquals(
650
-			'In 1 hour on July 1, 2024 for the entire day',
651
-			$this->service->generateWhenString($eventReader)
652
-		);
653
-
654
-		/** test partial day event in 2 hours*/
655
-		$vCalendar = clone $this->vCalendar1a;
656
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
657
-		$this->assertEquals(
658
-			'In 2 hours on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
659
-			$this->service->generateWhenString($eventReader)
660
-		);
661
-
662
-		/** test entire day event in 2 hours*/
663
-		$vCalendar = clone $this->vCalendar2;
664
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
665
-		$this->assertEquals(
666
-			'In 2 hours on July 1, 2024 for the entire day',
667
-			$this->service->generateWhenString($eventReader)
668
-		);
669
-
670
-		/** test patrial day event in 1 day*/
671
-		$vCalendar = clone $this->vCalendar1a;
672
-		// construct event reader
673
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
674
-		// test output
675
-		$this->assertEquals(
676
-			'In 1 day on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
677
-			$this->service->generateWhenString($eventReader)
678
-		);
679
-
680
-		/** test entire day event in 1 day*/
681
-		$vCalendar = clone $this->vCalendar2;
682
-		// construct event reader
683
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
684
-		// test output
685
-		$this->assertEquals(
686
-			'In 1 day on July 1, 2024 for the entire day',
687
-			$this->service->generateWhenString($eventReader)
688
-		);
689
-
690
-		/** test patrial day event in 2 days*/
691
-		$vCalendar = clone $this->vCalendar1a;
692
-		// construct event reader
693
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
694
-		// test output
695
-		$this->assertEquals(
696
-			'In 2 days on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
697
-			$this->service->generateWhenString($eventReader)
698
-		);
699
-
700
-		/** test entire day event in 2 days*/
701
-		$vCalendar = clone $this->vCalendar2;
702
-		// construct event reader
703
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
704
-		// test output
705
-		$this->assertEquals(
706
-			'In 2 days on July 1, 2024 for the entire day',
707
-			$this->service->generateWhenString($eventReader)
708
-		);
709
-
710
-		/** test patrial day event in 1 week*/
711
-		$vCalendar = clone $this->vCalendar1a;
712
-		// construct event reader
713
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
714
-		// test output
715
-		$this->assertEquals(
716
-			'In 1 week on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
717
-			$this->service->generateWhenString($eventReader)
718
-		);
719
-
720
-		/** test entire day event in 1 week*/
721
-		$vCalendar = clone $this->vCalendar2;
722
-		// construct event reader
723
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
724
-		// test output
725
-		$this->assertEquals(
726
-			'In 1 week on July 1, 2024 for the entire day',
727
-			$this->service->generateWhenString($eventReader)
728
-		);
729
-
730
-		/** test patrial day event in 2 weeks*/
731
-		$vCalendar = clone $this->vCalendar1a;
732
-		// construct event reader
733
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
734
-		// test output
735
-		$this->assertEquals(
736
-			'In 2 weeks on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
737
-			$this->service->generateWhenString($eventReader)
738
-		);
739
-
740
-		/** test entire day event in 2 weeks*/
741
-		$vCalendar = clone $this->vCalendar2;
742
-		// construct event reader
743
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
744
-		// test output
745
-		$this->assertEquals(
746
-			'In 2 weeks on July 1, 2024 for the entire day',
747
-			$this->service->generateWhenString($eventReader)
748
-		);
749
-
750
-		/** test patrial day event in 1 month*/
751
-		$vCalendar = clone $this->vCalendar1a;
752
-		// construct event reader
753
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
754
-		// test output
755
-		$this->assertEquals(
756
-			'In 1 month on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
757
-			$this->service->generateWhenString($eventReader)
758
-		);
759
-
760
-		/** test entire day event in 1 month*/
761
-		$vCalendar = clone $this->vCalendar2;
762
-		// construct event reader
763
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
764
-		// test output
765
-		$this->assertEquals(
766
-			'In 1 month on July 1, 2024 for the entire day',
767
-			$this->service->generateWhenString($eventReader)
768
-		);
769
-
770
-		/** test patrial day event in 2 months*/
771
-		$vCalendar = clone $this->vCalendar1a;
772
-		// construct event reader
773
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
774
-		// test output
775
-		$this->assertEquals(
776
-			'In 2 months on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
777
-			$this->service->generateWhenString($eventReader)
778
-		);
779
-
780
-		/** test entire day event in 2 months*/
781
-		$vCalendar = clone $this->vCalendar2;
782
-		// construct event reader
783
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
784
-		// test output
785
-		$this->assertEquals(
786
-			'In 2 months on July 1, 2024 for the entire day',
787
-			$this->service->generateWhenString($eventReader)
788
-		);
789
-
790
-		/** test patrial day event in 1 year*/
791
-		$vCalendar = clone $this->vCalendar1a;
792
-		// construct event reader
793
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
794
-		// test output
795
-		$this->assertEquals(
796
-			'In 1 year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
797
-			$this->service->generateWhenString($eventReader)
798
-		);
799
-
800
-		/** test entire day event in 1 year*/
801
-		$vCalendar = clone $this->vCalendar2;
802
-		// construct event reader
803
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
804
-		// test output
805
-		$this->assertEquals(
806
-			'In 1 year on July 1, 2024 for the entire day',
807
-			$this->service->generateWhenString($eventReader)
808
-		);
809
-
810
-		/** test patrial day event in 2 years*/
811
-		$vCalendar = clone $this->vCalendar1a;
812
-		// construct event reader
813
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
814
-		// test output
815
-		$this->assertEquals(
816
-			'In 2 years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
817
-			$this->service->generateWhenString($eventReader)
818
-		);
819
-
820
-		/** test entire day event in 2 years*/
821
-		$vCalendar = clone $this->vCalendar2;
822
-		// construct event reader
823
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
824
-		// test output
825
-		$this->assertEquals(
826
-			'In 2 years on July 1, 2024 for the entire day',
827
-			$this->service->generateWhenString($eventReader)
828
-		);
829
-
830
-	}
831
-
832
-	public function testGenerateWhenStringRecurringDaily(): void {
833
-
834
-		// construct l10n return maps
835
-		$this->l10n->method('l')->willReturnCallback(
836
-			function ($v1, $v2, $v3) {
837
-				return match (true) {
838
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
839
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
840
-					$v1 === 'date' && $v2 == (new \DateTime('20240713T080000', (new \DateTimeZone('UTC')))) && $v3 == ['width' => 'long'] => 'July 13, 2024'
841
-				};
842
-			}
843
-		);
844
-		$this->l10n->method('t')->willReturnMap([
845
-			['Every Day for the entire day', [], 'Every Day for the entire day'],
846
-			['Every Day for the entire day until %1$s', ['July 13, 2024'], 'Every Day for the entire day until July 13, 2024'],
847
-			['Every Day between %1$s - %2$s', ['8:00 AM', '9:00 AM (America/Toronto)'], 'Every Day between 8:00 AM - 9:00 AM (America/Toronto)'],
848
-			['Every Day between %1$s - %2$s until %3$s', ['8:00 AM', '9:00 AM (America/Toronto)', 'July 13, 2024'], 'Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024'],
849
-			['Every %1$d Days for the entire day', [3], 'Every 3 Days for the entire day'],
850
-			['Every %1$d Days for the entire day until %2$s', [3, 'July 13, 2024'], 'Every 3 Days for the entire day until July 13, 2024'],
851
-			['Every %1$d Days between %2$s - %3$s', [3, '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto)'],
852
-			['Every %1$d Days between %2$s - %3$s until %4$s', [3, '8:00 AM', '9:00 AM (America/Toronto)', 'July 13, 2024'], 'Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024'],
853
-			['Could not generate event recurrence statement', [], 'Could not generate event recurrence statement'],
854
-		]);
855
-
856
-		/** test partial day event with every day interval and no conclusion*/
857
-		$vCalendar = clone $this->vCalendar1a;
858
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=1;');
859
-		// construct event reader
860
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
861
-		// test output
862
-		$this->assertEquals(
863
-			'Every Day between 8:00 AM - 9:00 AM (America/Toronto)',
864
-			$this->service->generateWhenString($eventReader)
865
-		);
866
-
867
-		/** test partial day event with every day interval and conclusion*/
868
-		$vCalendar = clone $this->vCalendar1a;
869
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=1;UNTIL=20240713T080000Z');
870
-		// construct event reader
871
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
872
-		// test output
873
-		$this->assertEquals(
874
-			'Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024',
875
-			$this->service->generateWhenString($eventReader)
876
-		);
877
-
878
-		/** test partial day event every 3rd day interval and no conclusion*/
879
-		$vCalendar = clone $this->vCalendar1a;
880
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=3;');
881
-		// construct event reader
882
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
883
-		// test output
884
-		$this->assertEquals(
885
-			'Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto)',
886
-			$this->service->generateWhenString($eventReader)
887
-		);
888
-
889
-		/** test partial day event with every 3rd day interval and conclusion*/
890
-		$vCalendar = clone $this->vCalendar1a;
891
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=3;UNTIL=20240713T080000Z');
892
-		// construct event reader
893
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
894
-		// test output
895
-		$this->assertEquals(
896
-			'Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024',
897
-			$this->service->generateWhenString($eventReader)
898
-		);
899
-
900
-		/** test entire day event with every day interval and no conclusion*/
901
-		$vCalendar = clone $this->vCalendar2;
902
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=1;');
903
-		// construct event reader
904
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
905
-		// test output
906
-		$this->assertEquals(
907
-			'Every Day for the entire day',
908
-			$this->service->generateWhenString($eventReader)
909
-		);
910
-
911
-		/** test entire day event with every day interval and conclusion*/
912
-		$vCalendar = clone $this->vCalendar2;
913
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=1;UNTIL=20240713T080000Z');
914
-		// construct event reader
915
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
916
-		// test output
917
-		$this->assertEquals(
918
-			'Every Day for the entire day until July 13, 2024',
919
-			$this->service->generateWhenString($eventReader)
920
-		);
921
-
922
-		/** test entire day event with every 3rd day interval and no conclusion*/
923
-		$vCalendar = clone $this->vCalendar2;
924
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=3;');
925
-		// construct event reader
926
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
927
-		// test output
928
-		$this->assertEquals(
929
-			'Every 3 Days for the entire day',
930
-			$this->service->generateWhenString($eventReader)
931
-		);
932
-
933
-		/** test entire day event with every 3rd day interval and conclusion*/
934
-		$vCalendar = clone $this->vCalendar2;
935
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=3;UNTIL=20240713T080000Z');
936
-		// construct event reader
937
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
938
-		// test output
939
-		$this->assertEquals(
940
-			'Every 3 Days for the entire day until July 13, 2024',
941
-			$this->service->generateWhenString($eventReader)
942
-		);
943
-
944
-	}
945
-
946
-	public function testGenerateWhenStringRecurringWeekly(): void {
947
-
948
-		// construct l10n return maps
949
-		$this->l10n->method('l')->willReturnCallback(
950
-			function ($v1, $v2, $v3) {
951
-				return match (true) {
952
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
953
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
954
-					$v1 === 'date' && $v2 == (new \DateTime('20240722T080000', (new \DateTimeZone('UTC')))) && $v3 == ['width' => 'long'] => 'July 13, 2024'
955
-				};
956
-			}
957
-		);
958
-		$this->l10n->method('t')->willReturnMap([
959
-			['Every Week on %1$s for the entire day', ['Monday, Wednesday, Friday'], 'Every Week on Monday, Wednesday, Friday for the entire day'],
960
-			['Every Week on %1$s for the entire day until %2$s', ['Monday, Wednesday, Friday', 'July 13, 2024'], 'Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024'],
961
-			['Every Week on %1$s between %2$s - %3$s', ['Monday, Wednesday, Friday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)'],
962
-			['Every Week on %1$s between %2$s - %3$s until %4$s', ['Monday, Wednesday, Friday', '8:00 AM', '9:00 AM (America/Toronto)', 'July 13, 2024'], 'Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024'],
963
-			['Every %1$d Weeks on %2$s for the entire day', [2, 'Monday, Wednesday, Friday'], 'Every 2 Weeks on Monday, Wednesday, Friday for the entire day'],
964
-			['Every %1$d Weeks on %2$s for the entire day until %3$s', [2, 'Monday, Wednesday, Friday', 'July 13, 2024'], 'Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024'],
965
-			['Every %1$d Weeks on %2$s between %3$s - %4$s', [2, 'Monday, Wednesday, Friday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)'],
966
-			['Every %1$d Weeks on %2$s between %3$s - %4$s until %5$s', [2, 'Monday, Wednesday, Friday', '8:00 AM', '9:00 AM (America/Toronto)', 'July 13, 2024'], 'Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024'],
967
-			['Could not generate event recurrence statement', [], 'Could not generate event recurrence statement'],
968
-			['Monday', [], 'Monday'],
969
-			['Wednesday', [], 'Wednesday'],
970
-			['Friday', [], 'Friday'],
971
-		]);
972
-
973
-		/** test partial day event with every week interval on Mon, Wed, Fri and no conclusion*/
974
-		$vCalendar = clone $this->vCalendar1a;
975
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR');
976
-		// construct event reader
977
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
978
-		// test output
979
-		$this->assertEquals(
980
-			'Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)',
981
-			$this->service->generateWhenString($eventReader)
982
-		);
983
-
984
-		/** test partial day event with every week interval on Mon, Wed, Fri and conclusion*/
985
-		$vCalendar = clone $this->vCalendar1a;
986
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20240722T080000Z;');
987
-		// construct event reader
988
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
989
-		// test output
990
-		$this->assertEquals(
991
-			'Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024',
992
-			$this->service->generateWhenString($eventReader)
993
-		);
994
-
995
-		/** test partial day event with every 2nd week interval on Mon, Wed, Fri and no conclusion*/
996
-		$vCalendar = clone $this->vCalendar1a;
997
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;');
998
-		// construct event reader
999
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1000
-		// test output
1001
-		$this->assertEquals(
1002
-			'Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)',
1003
-			$this->service->generateWhenString($eventReader)
1004
-		);
1005
-
1006
-		/** test partial day event with every 2nd week interval on Mon, Wed, Fri and conclusion*/
1007
-		$vCalendar = clone $this->vCalendar1a;
1008
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;UNTIL=20240722T080000Z;');
1009
-		// construct event reader
1010
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1011
-		// test output
1012
-		$this->assertEquals(
1013
-			'Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024',
1014
-			$this->service->generateWhenString($eventReader)
1015
-		);
1016
-
1017
-		/** test entire day event with every week interval on Mon, Wed, Fri and no conclusion*/
1018
-		$vCalendar = clone $this->vCalendar2;
1019
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;');
1020
-		// construct event reader
1021
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1022
-		// test output
1023
-		$this->assertEquals(
1024
-			'Every Week on Monday, Wednesday, Friday for the entire day',
1025
-			$this->service->generateWhenString($eventReader)
1026
-		);
1027
-
1028
-		/** test entire day event with every week interval on Mon, Wed, Fri and conclusion*/
1029
-		$vCalendar = clone $this->vCalendar2;
1030
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20240722T080000Z;');
1031
-		// construct event reader
1032
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1033
-		// test output
1034
-		$this->assertEquals(
1035
-			'Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024',
1036
-			$this->service->generateWhenString($eventReader)
1037
-		);
1038
-
1039
-		/** test entire day event with every 2nd week interval on Mon, Wed, Fri and no conclusion*/
1040
-		$vCalendar = clone $this->vCalendar2;
1041
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;');
1042
-		// construct event reader
1043
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1044
-		// test output
1045
-		$this->assertEquals(
1046
-			'Every 2 Weeks on Monday, Wednesday, Friday for the entire day',
1047
-			$this->service->generateWhenString($eventReader)
1048
-		);
1049
-
1050
-		/** test entire day event with every 2nd week interval on Mon, Wed, Fri and conclusion*/
1051
-		$vCalendar = clone $this->vCalendar2;
1052
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;UNTIL=20240722T080000Z;');
1053
-		// construct event reader
1054
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1055
-		// test output
1056
-		$this->assertEquals(
1057
-			'Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024',
1058
-			$this->service->generateWhenString($eventReader)
1059
-		);
1060
-
1061
-	}
1062
-
1063
-	public function testGenerateWhenStringRecurringMonthly(): void {
1064
-
1065
-		// construct l10n return maps
1066
-		$this->l10n->method('l')->willReturnCallback(
1067
-			function ($v1, $v2, $v3) {
1068
-				return match (true) {
1069
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
1070
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
1071
-					$v1 === 'date' && $v2 == (new \DateTime('20241231T080000', (new \DateTimeZone('UTC')))) && $v3 == ['width' => 'long'] => 'December 31, 2024'
1072
-				};
1073
-			}
1074
-		);
1075
-		$this->l10n->method('t')->willReturnMap([
1076
-			['Every Month on the %1$s for the entire day', ['1, 8'], 'Every Month on the 1, 8 for the entire day'],
1077
-			['Every Month on the %1$s for the entire day until %2$s', ['1, 8', 'December 31, 2024'], 'Every Month on the 1, 8 for the entire day until December 31, 2024'],
1078
-			['Every Month on the %1$s between %2$s - %3$s', ['1, 8', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)'],
1079
-			['Every Month on the %1$s between %2$s - %3$s until %4$s', ['1, 8', '8:00 AM', '9:00 AM (America/Toronto)', 'December 31, 2024'], 'Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024'],
1080
-			['Every %1$d Months on the %2$s for the entire day', [2, '1, 8'], 'Every 2 Months on the 1, 8 for the entire day'],
1081
-			['Every %1$d Months on the %2$s for the entire day until %3$s', [2, '1, 8', 'December 31, 2024'], 'Every 2 Months on the 1, 8 for the entire day until December 31, 2024'],
1082
-			['Every %1$d Months on the %2$s between %3$s - %4$s', [2, '1, 8', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)'],
1083
-			['Every %1$d Months on the %2$s between %3$s - %4$s until %5$s', [2, '1, 8', '8:00 AM', '9:00 AM (America/Toronto)', 'December 31, 2024'], 'Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024'],
1084
-			['Every Month on the %1$s for the entire day', ['First Sunday, Saturday'], 'Every Month on the First Sunday, Saturday for the entire day'],
1085
-			['Every Month on the %1$s for the entire day until %2$s', ['First Sunday, Saturday', 'December 31, 2024'], 'Every Month on the First Sunday, Saturday for the entire day until December 31, 2024'],
1086
-			['Every Month on the %1$s between %2$s - %3$s', ['First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)'],
1087
-			['Every Month on the %1$s between %2$s - %3$s until %4$s', ['First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)', 'December 31, 2024'], 'Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024'],
1088
-			['Every %1$d Months on the %2$s for the entire day', [2, 'First Sunday, Saturday'], 'Every 2 Months on the First Sunday, Saturday for the entire day'],
1089
-			['Every %1$d Months on the %2$s for the entire day until %3$s', [2, 'First Sunday, Saturday', 'December 31, 2024'], 'Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024'],
1090
-			['Every %1$d Months on the %2$s between %3$s - %4$s', [2, 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)'],
1091
-			['Every %1$d Months on the %2$s between %3$s - %4$s until %5$s', [2, 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)', 'December 31, 2024'], 'Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024'],
1092
-			['Could not generate event recurrence statement', [], 'Could not generate event recurrence statement'],
1093
-			['Saturday', [], 'Saturday'],
1094
-			['Sunday', [], 'Sunday'],
1095
-			['First', [], 'First'],
1096
-		]);
1097
-
1098
-		/** test absolute partial day event with every month interval on 1st, 8th and no conclusion*/
1099
-		$vCalendar = clone $this->vCalendar1a;
1100
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;');
1101
-		// construct event reader
1102
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1103
-		// test output
1104
-		$this->assertEquals(
1105
-			'Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)',
1106
-			$this->service->generateWhenString($eventReader)
1107
-		);
1108
-
1109
-		/** test absolute partial day event with every Month interval on 1st, 8th and conclusion*/
1110
-		$vCalendar = clone $this->vCalendar1a;
1111
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;UNTIL=20241231T080000Z;');
1112
-		// construct event reader
1113
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1114
-		// test output
1115
-		$this->assertEquals(
1116
-			'Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024',
1117
-			$this->service->generateWhenString($eventReader)
1118
-		);
1119
-
1120
-		/** test absolute partial day event with every 2nd Month interval on 1st, 8th and no conclusion*/
1121
-		$vCalendar = clone $this->vCalendar1a;
1122
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;INTERVAL=2;');
1123
-		// construct event reader
1124
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1125
-		// test output
1126
-		$this->assertEquals(
1127
-			'Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)',
1128
-			$this->service->generateWhenString($eventReader)
1129
-		);
1130
-
1131
-		/** test absolute partial day event with every 2nd Month interval on 1st, 8th and conclusion*/
1132
-		$vCalendar = clone $this->vCalendar1a;
1133
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;INTERVAL=2;UNTIL=20241231T080000Z;');
1134
-		// construct event reader
1135
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1136
-		// test output
1137
-		$this->assertEquals(
1138
-			'Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024',
1139
-			$this->service->generateWhenString($eventReader)
1140
-		);
1141
-
1142
-		/** test absolute entire day event with every Month interval on 1st, 8th and no conclusion*/
1143
-		$vCalendar = clone $this->vCalendar2;
1144
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;');
1145
-		// construct event reader
1146
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1147
-		// test output
1148
-		$this->assertEquals(
1149
-			'Every Month on the 1, 8 for the entire day',
1150
-			$this->service->generateWhenString($eventReader)
1151
-		);
1152
-
1153
-		/** test absolute entire day event with every Month interval on 1st, 8th and conclusion*/
1154
-		$vCalendar = clone $this->vCalendar2;
1155
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;UNTIL=20241231T080000Z;');
1156
-		// construct event reader
1157
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1158
-		// test output
1159
-		$this->assertEquals(
1160
-			'Every Month on the 1, 8 for the entire day until December 31, 2024',
1161
-			$this->service->generateWhenString($eventReader)
1162
-		);
1163
-
1164
-		/** test absolute entire day event with every 2nd Month interval on 1st, 8th and no conclusion*/
1165
-		$vCalendar = clone $this->vCalendar2;
1166
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;INTERVAL=2;');
1167
-		// construct event reader
1168
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1169
-		// test output
1170
-		$this->assertEquals(
1171
-			'Every 2 Months on the 1, 8 for the entire day',
1172
-			$this->service->generateWhenString($eventReader)
1173
-		);
1174
-
1175
-		/** test absolute entire day event with every 2nd Month interval on 1st, 8th and conclusion*/
1176
-		$vCalendar = clone $this->vCalendar2;
1177
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;INTERVAL=2;UNTIL=20241231T080000Z;');
1178
-		// construct event reader
1179
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1180
-		// test output
1181
-		$this->assertEquals(
1182
-			'Every 2 Months on the 1, 8 for the entire day until December 31, 2024',
1183
-			$this->service->generateWhenString($eventReader)
1184
-		);
1185
-
1186
-		/** test relative partial day event with every month interval on the 1st Saturday, Sunday and no conclusion*/
1187
-		$vCalendar = clone $this->vCalendar1a;
1188
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;');
1189
-		// construct event reader
1190
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1191
-		// test output
1192
-		$this->assertEquals(
1193
-			'Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)',
1194
-			$this->service->generateWhenString($eventReader)
1195
-		);
1196
-
1197
-		/** test relative partial day event with every Month interval on the 1st Saturday, Sunday and conclusion*/
1198
-		$vCalendar = clone $this->vCalendar1a;
1199
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;UNTIL=20241231T080000Z;');
1200
-		// construct event reader
1201
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1202
-		// test output
1203
-		$this->assertEquals(
1204
-			'Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024',
1205
-			$this->service->generateWhenString($eventReader)
1206
-		);
1207
-
1208
-		/** test relative partial day event with every 2nd Month interval on the 1st Saturday, Sunday and no conclusion*/
1209
-		$vCalendar = clone $this->vCalendar1a;
1210
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;');
1211
-		// construct event reader
1212
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1213
-		// test output
1214
-		$this->assertEquals(
1215
-			'Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)',
1216
-			$this->service->generateWhenString($eventReader)
1217
-		);
1218
-
1219
-		/** test relative partial day event with every 2nd Month interval on the 1st Saturday, Sunday and conclusion*/
1220
-		$vCalendar = clone $this->vCalendar1a;
1221
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;UNTIL=20241231T080000Z;');
1222
-		// construct event reader
1223
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1224
-		// test output
1225
-		$this->assertEquals(
1226
-			'Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024',
1227
-			$this->service->generateWhenString($eventReader)
1228
-		);
1229
-
1230
-		/** test relative entire day event with every Month interval on the 1st Saturday, Sunday and no conclusion*/
1231
-		$vCalendar = clone $this->vCalendar2;
1232
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;');
1233
-		// construct event reader
1234
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1235
-		// test output
1236
-		$this->assertEquals(
1237
-			'Every Month on the First Sunday, Saturday for the entire day',
1238
-			$this->service->generateWhenString($eventReader)
1239
-		);
1240
-
1241
-		/** test relative entire day event with every Month interval on the 1st Saturday, Sunday and conclusion*/
1242
-		$vCalendar = clone $this->vCalendar2;
1243
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;UNTIL=20241231T080000Z;');
1244
-		// construct event reader
1245
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1246
-		// test output
1247
-		$this->assertEquals(
1248
-			'Every Month on the First Sunday, Saturday for the entire day until December 31, 2024',
1249
-			$this->service->generateWhenString($eventReader)
1250
-		);
1251
-
1252
-		/** test relative entire day event with every 2nd Month interval on the 1st Saturday, Sunday and no conclusion*/
1253
-		$vCalendar = clone $this->vCalendar2;
1254
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;');
1255
-		// construct event reader
1256
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1257
-		// test output
1258
-		$this->assertEquals(
1259
-			'Every 2 Months on the First Sunday, Saturday for the entire day',
1260
-			$this->service->generateWhenString($eventReader)
1261
-		);
1262
-
1263
-		/** test relative entire day event with every 2nd Month interval on the 1st Saturday, Sunday and conclusion*/
1264
-		$vCalendar = clone $this->vCalendar2;
1265
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;UNTIL=20241231T080000Z;');
1266
-		// construct event reader
1267
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1268
-		// test output
1269
-		$this->assertEquals(
1270
-			'Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024',
1271
-			$this->service->generateWhenString($eventReader)
1272
-		);
1273
-
1274
-	}
1275
-
1276
-	public function testGenerateWhenStringRecurringYearly(): void {
1277
-
1278
-		// construct l10n return maps
1279
-		$this->l10n->method('l')->willReturnCallback(
1280
-			function ($v1, $v2, $v3) {
1281
-				return match (true) {
1282
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
1283
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
1284
-					$v1 === 'date' && $v2 == (new \DateTime('20260731T040000', (new \DateTimeZone('UTC')))) && $v3 == ['width' => 'long'] => 'July 31, 2026'
1285
-				};
1286
-			}
1287
-		);
1288
-		$this->l10n->method('t')->willReturnMap([
1289
-			['Every Year in %1$s on the %2$s for the entire day', ['July', '1st'], 'Every Year in July on the 1st for the entire day'],
1290
-			['Every Year in %1$s on the %2$s for the entire day until %3$s', ['July', '1st', 'July 31, 2026'], 'Every Year in July on the 1st for the entire day until July 31, 2026'],
1291
-			['Every Year in %1$s on the %2$s between %3$s - %4$s', ['July', '1st', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)'],
1292
-			['Every Year in %1$s on the %2$s between %3$s - %4$s until %5$s', ['July', '1st', '8:00 AM', '9:00 AM (America/Toronto)', 'July 31, 2026'], 'Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026'],
1293
-			['Every %1$d Years in %2$s on the %3$s for the entire day', [2, 'July', '1st'], 'Every 2 Years in July on the 1st for the entire day'],
1294
-			['Every %1$d Years in %2$s on the %3$s for the entire day until %4$s', [2, 'July', '1st', 'July 31, 2026'], 'Every 2 Years in July on the 1st for the entire day until July 31, 2026'],
1295
-			['Every %1$d Years in %2$s on the %3$s between %4$s - %5$s', [2, 'July', '1st', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)'],
1296
-			['Every %1$d Years in %2$s on the %3$s between %4$s - %5$s until %6$s', [2, 'July', '1st', '8:00 AM', '9:00 AM (America/Toronto)', 'July 31, 2026'], 'Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026'],
1297
-			['Every Year in %1$s on the %2$s for the entire day', ['July', 'First Sunday, Saturday'], 'Every Year in July on the First Sunday, Saturday for the entire day'],
1298
-			['Every Year in %1$s on the %2$s for the entire day until %3$s', ['July', 'First Sunday, Saturday', 'July 31, 2026'], 'Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026'],
1299
-			['Every Year in %1$s on the %2$s between %3$s - %4$s', ['July', 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)'],
1300
-			['Every Year in %1$s on the %2$s between %3$s - %4$s until %5$s', ['July', 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)', 'July 31, 2026'], 'Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026'],
1301
-			['Every %1$d Years in %2$s on the %3$s for the entire day', [2, 'July', 'First Sunday, Saturday'], 'Every 2 Years in July on the First Sunday, Saturday for the entire day'],
1302
-			['Every %1$d Years in %2$s on the %3$s for the entire day until %4$s', [2, 'July', 'First Sunday, Saturday', 'July 31, 2026'], 'Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026'],
1303
-			['Every %1$d Years in %2$s on the %3$s between %4$s - %5$s', [2, 'July', 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)'],
1304
-			['Every %1$d Years in %2$s on the %3$s between %4$s - %5$s until %6$s', [2, 'July', 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)', 'July 31, 2026'], 'Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026'],
1305
-			['Could not generate event recurrence statement', [], 'Could not generate event recurrence statement'],
1306
-			['July', [], 'July'],
1307
-			['Saturday', [], 'Saturday'],
1308
-			['Sunday', [], 'Sunday'],
1309
-			['First', [], 'First'],
1310
-		]);
1311
-
1312
-		/** test absolute partial day event with every year interval on July 1 and no conclusion*/
1313
-		$vCalendar = clone $this->vCalendar1a;
1314
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;');
1315
-		// construct event reader
1316
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1317
-		// test output
1318
-		$this->assertEquals(
1319
-			'Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)',
1320
-			$this->service->generateWhenString($eventReader)
1321
-		);
1322
-
1323
-		/** test absolute partial day event with every year interval on July 1 and conclusion*/
1324
-		$vCalendar = clone $this->vCalendar1a;
1325
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;UNTIL=20260731T040000Z');
1326
-		// construct event reader
1327
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1328
-		// test output
1329
-		$this->assertEquals(
1330
-			'Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026',
1331
-			$this->service->generateWhenString($eventReader)
1332
-		);
1333
-
1334
-		/** test absolute partial day event with every 2nd year interval on July 1 and no conclusion*/
1335
-		$vCalendar = clone $this->vCalendar1a;
1336
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;INTERVAL=2;');
1337
-		// construct event reader
1338
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1339
-		// test output
1340
-		$this->assertEquals(
1341
-			'Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)',
1342
-			$this->service->generateWhenString($eventReader)
1343
-		);
1344
-
1345
-		/** test absolute partial day event with every 2nd year interval on July 1 and conclusion*/
1346
-		$vCalendar = clone $this->vCalendar1a;
1347
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;INTERVAL=2;UNTIL=20260731T040000Z;');
1348
-		// construct event reader
1349
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1350
-		// test output
1351
-		$this->assertEquals(
1352
-			'Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026',
1353
-			$this->service->generateWhenString($eventReader)
1354
-		);
1355
-
1356
-		/** test absolute entire day event with every year interval on July 1 and no conclusion*/
1357
-		$vCalendar = clone $this->vCalendar2;
1358
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;');
1359
-		// construct event reader
1360
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1361
-		// test output
1362
-		$this->assertEquals(
1363
-			'Every Year in July on the 1st for the entire day',
1364
-			$this->service->generateWhenString($eventReader)
1365
-		);
1366
-
1367
-		/** test absolute entire day event with every year interval on July 1 and conclusion*/
1368
-		$vCalendar = clone $this->vCalendar2;
1369
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;UNTIL=20260731T040000Z;');
1370
-		// construct event reader
1371
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1372
-		// test output
1373
-		$this->assertEquals(
1374
-			'Every Year in July on the 1st for the entire day until July 31, 2026',
1375
-			$this->service->generateWhenString($eventReader)
1376
-		);
1377
-
1378
-		/** test absolute entire day event with every 2nd year interval on July 1 and no conclusion*/
1379
-		$vCalendar = clone $this->vCalendar2;
1380
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;INTERVAL=2;');
1381
-		// construct event reader
1382
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1383
-		// test output
1384
-		$this->assertEquals(
1385
-			'Every 2 Years in July on the 1st for the entire day',
1386
-			$this->service->generateWhenString($eventReader)
1387
-		);
1388
-
1389
-		/** test absolute entire day event with every 2nd year interval on July 1 and conclusion*/
1390
-		$vCalendar = clone $this->vCalendar2;
1391
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;INTERVAL=2;UNTIL=20260731T040000Z;');
1392
-		// construct event reader
1393
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1394
-		// test output
1395
-		$this->assertEquals(
1396
-			'Every 2 Years in July on the 1st for the entire day until July 31, 2026',
1397
-			$this->service->generateWhenString($eventReader)
1398
-		);
1399
-
1400
-		/** test relative partial day event with every year interval on the 1st Saturday, Sunday in July and no conclusion*/
1401
-		$vCalendar = clone $this->vCalendar1a;
1402
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;');
1403
-		// construct event reader
1404
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1405
-		// test output
1406
-		$this->assertEquals(
1407
-			'Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)',
1408
-			$this->service->generateWhenString($eventReader)
1409
-		);
1410
-
1411
-		/** test relative partial day event with every year interval on the 1st Saturday, Sunday in July and conclusion*/
1412
-		$vCalendar = clone $this->vCalendar1a;
1413
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;UNTIL=20260731T040000Z;');
1414
-		// construct event reader
1415
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1416
-		// test output
1417
-		$this->assertEquals(
1418
-			'Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026',
1419
-			$this->service->generateWhenString($eventReader)
1420
-		);
1421
-
1422
-		/** test relative partial day event with every 2nd year interval on the 1st Saturday, Sunday in July and no conclusion*/
1423
-		$vCalendar = clone $this->vCalendar1a;
1424
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;');
1425
-		// construct event reader
1426
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1427
-		// test output
1428
-		$this->assertEquals(
1429
-			'Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)',
1430
-			$this->service->generateWhenString($eventReader)
1431
-		);
1432
-
1433
-		/** test relative partial day event with every 2nd year interval on the 1st Saturday, Sunday in July and conclusion*/
1434
-		$vCalendar = clone $this->vCalendar1a;
1435
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;UNTIL=20260731T040000Z;');
1436
-		// construct event reader
1437
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1438
-		// test output
1439
-		$this->assertEquals(
1440
-			'Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026',
1441
-			$this->service->generateWhenString($eventReader)
1442
-		);
1443
-
1444
-		/** test relative entire day event with every year interval on the 1st Saturday, Sunday in July and no conclusion*/
1445
-		$vCalendar = clone $this->vCalendar2;
1446
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;');
1447
-		// construct event reader
1448
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1449
-		// test output
1450
-		$this->assertEquals(
1451
-			'Every Year in July on the First Sunday, Saturday for the entire day',
1452
-			$this->service->generateWhenString($eventReader)
1453
-		);
1454
-
1455
-		/** test relative entire day event with every year interval on the 1st Saturday, Sunday in July and conclusion*/
1456
-		$vCalendar = clone $this->vCalendar2;
1457
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;UNTIL=20260731T040000Z;');
1458
-		// construct event reader
1459
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1460
-		// test output
1461
-		$this->assertEquals(
1462
-			'Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026',
1463
-			$this->service->generateWhenString($eventReader)
1464
-		);
1465
-
1466
-		/** test relative entire day event with every 2nd year interval on the 1st Saturday, Sunday in July and no conclusion*/
1467
-		$vCalendar = clone $this->vCalendar2;
1468
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;');
1469
-		// construct event reader
1470
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1471
-		// test output
1472
-		$this->assertEquals(
1473
-			'Every 2 Years in July on the First Sunday, Saturday for the entire day',
1474
-			$this->service->generateWhenString($eventReader)
1475
-		);
1476
-
1477
-		/** test relative entire day event with every 2nd year interval on the 1st Saturday, Sunday in July and conclusion*/
1478
-		$vCalendar = clone $this->vCalendar2;
1479
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;UNTIL=20260731T040000Z;');
1480
-		// construct event reader
1481
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1482
-		// test output
1483
-		$this->assertEquals(
1484
-			'Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026',
1485
-			$this->service->generateWhenString($eventReader)
1486
-		);
1487
-
1488
-	}
1489
-
1490
-	public function testGenerateWhenStringRecurringFixed(): void {
1491
-
1492
-		// construct l10n return maps
1493
-		$this->l10n->method('l')->willReturnCallback(
1494
-			function ($v1, $v2, $v3) {
1495
-				return match (true) {
1496
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
1497
-					$v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
1498
-					$v1 === 'date' && $v2 == (new \DateTime('20240713T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 13, 2024'
1499
-				};
1500
-			}
1501
-		);
1502
-		$this->l10n->method('t')->willReturnMap([
1503
-			['On specific dates for the entire day until %1$s', ['July 13, 2024'], 'On specific dates for the entire day until July 13, 2024'],
1504
-			['On specific dates between %1$s - %2$s until %3$s', ['8:00 AM', '9:00 AM (America/Toronto)', 'July 13, 2024'], 'On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024'],
1505
-		]);
1506
-
1507
-		/** test partial day event with every day interval and conclusion*/
1508
-		$vCalendar = clone $this->vCalendar1a;
1509
-		$vCalendar->VEVENT[0]->add('RDATE', '20240703T080000,20240709T080000,20240713T080000');
1510
-		// construct event reader
1511
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1512
-		// test output
1513
-		$this->assertEquals(
1514
-			'On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024',
1515
-			$this->service->generateWhenString($eventReader)
1516
-		);
1517
-
1518
-		/** test entire day event with every day interval and no conclusion*/
1519
-		$vCalendar = clone $this->vCalendar2;
1520
-		$vCalendar->VEVENT[0]->add('RDATE', '20240703T080000,20240709T080000,20240713T080000');
1521
-		// construct event reader
1522
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1523
-		// test output
1524
-		$this->assertEquals(
1525
-			'On specific dates for the entire day until July 13, 2024',
1526
-			$this->service->generateWhenString($eventReader)
1527
-		);
1528
-
1529
-	}
1530
-
1531
-	public function testGenerateOccurringStringWithRrule(): void {
1532
-
1533
-		// construct l10n return(s)
1534
-		$this->l10n->method('l')->willReturnCallback(
1535
-			function ($v1, $v2, $v3) {
1536
-				return match (true) {
1537
-					$v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 1, 2024',
1538
-					$v1 === 'date' && $v2 == (new \DateTime('20240703T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 3, 2024',
1539
-					$v1 === 'date' && $v2 == (new \DateTime('20240705T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 5, 2024'
1540
-				};
1541
-			}
1542
-		);
1543
-		$this->l10n->method('n')->willReturnMap([
1544
-			// singular
1545
-			[
1546
-				'In %n day on %1$s',
1547
-				'In %n days on %1$s',
1548
-				1,
1549
-				['July 1, 2024'],
1550
-				'In 1 day on July 1, 2024'
1551
-			],
1552
-			[
1553
-				'In %n day on %1$s then on %2$s',
1554
-				'In %n days on %1$s then on %2$s',
1555
-				1,
1556
-				['July 1, 2024', 'July 3, 2024'],
1557
-				'In 1 day on July 1, 2024 then on July 3, 2024'
1558
-			],
1559
-			[
1560
-				'In %n day on %1$s then on %2$s and %3$s',
1561
-				'In %n days on %1$s then on %2$s and %3$s',
1562
-				1,
1563
-				['July 1, 2024', 'July 3, 2024', 'July 5, 2024'],
1564
-				'In 1 day on July 1, 2024 then on July 3, 2024 and July 5, 2024'
1565
-			],
1566
-			// plural
1567
-			[
1568
-				'In %n day on %1$s',
1569
-				'In %n days on %1$s',
1570
-				2,
1571
-				['July 1, 2024'],
1572
-				'In 2 days on July 1, 2024'
1573
-			],
1574
-			[
1575
-				'In %n day on %1$s then on %2$s',
1576
-				'In %n days on %1$s then on %2$s',
1577
-				2,
1578
-				['July 1, 2024', 'July 3, 2024'],
1579
-				'In 2 days on July 1, 2024 then on July 3, 2024'
1580
-			],
1581
-			[
1582
-				'In %n day on %1$s then on %2$s and %3$s',
1583
-				'In %n days on %1$s then on %2$s and %3$s',
1584
-				2,
1585
-				['July 1, 2024', 'July 3, 2024', 'July 5, 2024'],
1586
-				'In 2 days on July 1, 2024 then on July 3, 2024 and July 5, 2024'
1587
-			],
1588
-		]);
1589
-
1590
-		// construct time factory return(s)
1591
-		$this->timeFactory->method('getDateTime')->willReturnOnConsecutiveCalls(
1592
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1593
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1594
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1595
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1596
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1597
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1598
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1599
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1600
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1601
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1602
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1603
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1604
-		);
1605
-
1606
-		/** test patrial day recurring event in 1 day with single occurrence remaining */
1607
-		$vCalendar = clone $this->vCalendar1a;
1608
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
1609
-		// construct event reader
1610
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1611
-		// test output
1612
-		$this->assertEquals(
1613
-			'In 1 day on July 1, 2024',
1614
-			$this->service->generateOccurringString($eventReader)
1615
-		);
1616
-
1617
-		/** test patrial day recurring event in 1 day with two occurrences remaining */
1618
-		$vCalendar = clone $this->vCalendar1a;
1619
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
1620
-		// construct event reader
1621
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1622
-		// test output
1623
-		$this->assertEquals(
1624
-			'In 1 day on July 1, 2024 then on July 3, 2024',
1625
-			$this->service->generateOccurringString($eventReader)
1626
-		);
1627
-
1628
-		/** test patrial day recurring event in 1 day with three occurrences remaining */
1629
-		$vCalendar = clone $this->vCalendar1a;
1630
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
1631
-		// construct event reader
1632
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1633
-		// test output
1634
-		$this->assertEquals(
1635
-			'In 1 day on July 1, 2024 then on July 3, 2024 and July 5, 2024',
1636
-			$this->service->generateOccurringString($eventReader)
1637
-		);
1638
-
1639
-		/** test patrial day recurring event in 2 days with single occurrence remaining */
1640
-		$vCalendar = clone $this->vCalendar1a;
1641
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
1642
-		// construct event reader
1643
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1644
-		// test output
1645
-		$this->assertEquals(
1646
-			'In 2 days on July 1, 2024',
1647
-			$this->service->generateOccurringString($eventReader)
1648
-		);
1649
-
1650
-		/** test patrial day recurring event in 2 days with two occurrences remaining */
1651
-		$vCalendar = clone $this->vCalendar1a;
1652
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
1653
-		// construct event reader
1654
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1655
-		// test output
1656
-		$this->assertEquals(
1657
-			'In 2 days on July 1, 2024 then on July 3, 2024',
1658
-			$this->service->generateOccurringString($eventReader)
1659
-		);
1660
-
1661
-		/** test patrial day recurring event in 2 days with three occurrences remaining */
1662
-		$vCalendar = clone $this->vCalendar1a;
1663
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
1664
-		// construct event reader
1665
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1666
-		// test output
1667
-		$this->assertEquals(
1668
-			'In 2 days on July 1, 2024 then on July 3, 2024 and July 5, 2024',
1669
-			$this->service->generateOccurringString($eventReader)
1670
-		);
1671
-	}
1672
-
1673
-	public function testGenerateOccurringStringWithRdate(): void {
1674
-
1675
-		// construct l10n return(s)
1676
-		$this->l10n->method('l')->willReturnCallback(
1677
-			function ($v1, $v2, $v3) {
1678
-				return match (true) {
1679
-					$v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 1, 2024',
1680
-					$v1 === 'date' && $v2 == (new \DateTime('20240703T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 3, 2024',
1681
-					$v1 === 'date' && $v2 == (new \DateTime('20240705T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 5, 2024'
1682
-				};
1683
-			}
1684
-		);
1685
-		$this->l10n->method('n')->willReturnMap([
1686
-			// singular
1687
-			[
1688
-				'In %n day on %1$s',
1689
-				'In %n days on %1$s',
1690
-				1,
1691
-				['July 1, 2024'],
1692
-				'In 1 day on July 1, 2024'
1693
-			],
1694
-			[
1695
-				'In %n day on %1$s then on %2$s',
1696
-				'In %n days on %1$s then on %2$s',
1697
-				1,
1698
-				['July 1, 2024', 'July 3, 2024'],
1699
-				'In 1 day on July 1, 2024 then on July 3, 2024'
1700
-			],
1701
-			[
1702
-				'In %n day on %1$s then on %2$s and %3$s',
1703
-				'In %n days on %1$s then on %2$s and %3$s',
1704
-				1,
1705
-				['July 1, 2024', 'July 3, 2024', 'July 5, 2024'],
1706
-				'In 1 day on July 1, 2024 then on July 3, 2024 and July 5, 2024'
1707
-			],
1708
-			// plural
1709
-			[
1710
-				'In %n day on %1$s',
1711
-				'In %n days on %1$s',
1712
-				2,
1713
-				['July 1, 2024'],
1714
-				'In 2 days on July 1, 2024'
1715
-			],
1716
-			[
1717
-				'In %n day on %1$s then on %2$s',
1718
-				'In %n days on %1$s then on %2$s',
1719
-				2,
1720
-				['July 1, 2024', 'July 3, 2024'],
1721
-				'In 2 days on July 1, 2024 then on July 3, 2024'
1722
-			],
1723
-			[
1724
-				'In %n day on %1$s then on %2$s and %3$s',
1725
-				'In %n days on %1$s then on %2$s and %3$s',
1726
-				2,
1727
-				['July 1, 2024', 'July 3, 2024', 'July 5, 2024'],
1728
-				'In 2 days on July 1, 2024 then on July 3, 2024 and July 5, 2024'
1729
-			],
1730
-		]);
1731
-
1732
-		// construct time factory return(s)
1733
-		$this->timeFactory->method('getDateTime')->willReturnOnConsecutiveCalls(
1734
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1735
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1736
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1737
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1738
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1739
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1740
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1741
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1742
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1743
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1744
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1745
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1746
-		);
1747
-
1748
-		/** test patrial day recurring event in 1 day with single occurrence remaining */
1749
-		$vCalendar = clone $this->vCalendar1a;
1750
-		$vCalendar->VEVENT[0]->add('RDATE', '20240701T080000');
1751
-		// construct event reader
1752
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1753
-		// test output
1754
-		$this->assertEquals(
1755
-			'In 1 day on July 1, 2024',
1756
-			$this->service->generateOccurringString($eventReader),
1757
-			'test patrial day recurring event in 1 day with single occurrence remaining'
1758
-		);
1759
-
1760
-		/** test patrial day recurring event in 1 day with two occurrences remaining */
1761
-		$vCalendar = clone $this->vCalendar1a;
1762
-		$vCalendar->VEVENT[0]->add('RDATE', '20240701T080000,20240703T080000');
1763
-		// construct event reader
1764
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1765
-		// test output
1766
-		$this->assertEquals(
1767
-			'In 1 day on July 1, 2024 then on July 3, 2024',
1768
-			$this->service->generateOccurringString($eventReader),
1769
-			'test patrial day recurring event in 1 day with two occurrences remaining'
1770
-		);
1771
-
1772
-		/** test patrial day recurring event in 1 day with three occurrences remaining */
1773
-		$vCalendar = clone $this->vCalendar1a;
1774
-		$vCalendar->VEVENT[0]->add('RDATE', '20240701T080000,20240703T080000,20240705T080000');
1775
-		// construct event reader
1776
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1777
-		// test output
1778
-		$this->assertEquals(
1779
-			'In 1 day on July 1, 2024 then on July 3, 2024 and July 5, 2024',
1780
-			$this->service->generateOccurringString($eventReader),
1781
-			''
1782
-		);
1783
-
1784
-		/** test patrial day recurring event in 2 days with single occurrences remaining */
1785
-		$vCalendar = clone $this->vCalendar1a;
1786
-		$vCalendar->VEVENT[0]->add('RDATE', '20240701T080000');
1787
-		// construct event reader
1788
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1789
-		// test output
1790
-		$this->assertEquals(
1791
-			'In 2 days on July 1, 2024',
1792
-			$this->service->generateOccurringString($eventReader),
1793
-			''
1794
-		);
1795
-
1796
-		/** test patrial day recurring event in 2 days with two occurrences remaining */
1797
-		$vCalendar = clone $this->vCalendar1a;
1798
-		$vCalendar->VEVENT[0]->add('RDATE', '20240701T080000');
1799
-		$vCalendar->VEVENT[0]->add('RDATE', '20240703T080000');
1800
-		// construct event reader
1801
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1802
-		// test output
1803
-		$this->assertEquals(
1804
-			'In 2 days on July 1, 2024 then on July 3, 2024',
1805
-			$this->service->generateOccurringString($eventReader),
1806
-			''
1807
-		);
1808
-
1809
-		/** test patrial day recurring event in 2 days with three occurrences remaining */
1810
-		$vCalendar = clone $this->vCalendar1a;
1811
-		$vCalendar->VEVENT[0]->add('RDATE', '20240701T080000');
1812
-		$vCalendar->VEVENT[0]->add('RDATE', '20240703T080000');
1813
-		$vCalendar->VEVENT[0]->add('RDATE', '20240705T080000');
1814
-		// construct event reader
1815
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1816
-		// test output
1817
-		$this->assertEquals(
1818
-			'In 2 days on July 1, 2024 then on July 3, 2024 and July 5, 2024',
1819
-			$this->service->generateOccurringString($eventReader),
1820
-			'test patrial day recurring event in 2 days with three occurrences remaining'
1821
-		);
1822
-	}
1823
-
1824
-	public function testGenerateOccurringStringWithOneExdate(): void {
1825
-
1826
-		// construct l10n return(s)
1827
-		$this->l10n->method('l')->willReturnCallback(
1828
-			function ($v1, $v2, $v3) {
1829
-				return match (true) {
1830
-					$v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 1, 2024',
1831
-					$v1 === 'date' && $v2 == (new \DateTime('20240705T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 5, 2024',
1832
-					$v1 === 'date' && $v2 == (new \DateTime('20240707T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 7, 2024'
1833
-				};
1834
-			}
1835
-		);
1836
-		$this->l10n->method('n')->willReturnMap([
1837
-			// singular
1838
-			[
1839
-				'In %n day on %1$s',
1840
-				'In %n days on %1$s',
1841
-				1,
1842
-				['July 1, 2024'],
1843
-				'In 1 day on July 1, 2024'
1844
-			],
1845
-			[
1846
-				'In %n day on %1$s then on %2$s',
1847
-				'In %n days on %1$s then on %2$s',
1848
-				1,
1849
-				['July 1, 2024', 'July 5, 2024'],
1850
-				'In 1 day on July 1, 2024 then on July 5, 2024'
1851
-			],
1852
-			[
1853
-				'In %n day on %1$s then on %2$s and %3$s',
1854
-				'In %n days on %1$s then on %2$s and %3$s',
1855
-				1,
1856
-				['July 1, 2024', 'July 5, 2024', 'July 7, 2024'],
1857
-				'In 1 day on July 1, 2024 then on July 5, 2024 and July 7, 2024'
1858
-			],
1859
-			// plural
1860
-			[
1861
-				'In %n day on %1$s',
1862
-				'In %n days on %1$s',
1863
-				2,
1864
-				['July 1, 2024'],
1865
-				'In 2 days on July 1, 2024'
1866
-			],
1867
-			[
1868
-				'In %n day on %1$s then on %2$s',
1869
-				'In %n days on %1$s then on %2$s',
1870
-				2,
1871
-				['July 1, 2024', 'July 5, 2024'],
1872
-				'In 2 days on July 1, 2024 then on July 5, 2024'
1873
-			],
1874
-			[
1875
-				'In %n day on %1$s then on %2$s and %3$s',
1876
-				'In %n days on %1$s then on %2$s and %3$s',
1877
-				2,
1878
-				['July 1, 2024', 'July 5, 2024', 'July 7, 2024'],
1879
-				'In 2 days on July 1, 2024 then on July 5, 2024 and July 7, 2024'
1880
-			],
1881
-		]);
1882
-
1883
-		// construct time factory return(s)
1884
-		$this->timeFactory->method('getDateTime')->willReturnOnConsecutiveCalls(
1885
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1886
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1887
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1888
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1889
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1890
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1891
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1892
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1893
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1894
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1895
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1896
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1897
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1898
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1899
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1900
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1901
-		);
1902
-
1903
-		/** test patrial day recurring event in 1 day with single occurrence remaining and one exception */
1904
-		$vCalendar = clone $this->vCalendar1a;
1905
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
1906
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1907
-		// construct event reader
1908
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1909
-		// test output
1910
-		$this->assertEquals(
1911
-			'In 1 day on July 1, 2024',
1912
-			$this->service->generateOccurringString($eventReader),
1913
-			'test patrial day recurring event in 1 day with single occurrence remaining and one exception'
1914
-		);
1915
-
1916
-		/** test patrial day recurring event in 1 day with two occurrences remaining and one exception */
1917
-		$vCalendar = clone $this->vCalendar1a;
1918
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
1919
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1920
-		// construct event reader
1921
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1922
-		// test output
1923
-		$this->assertEquals(
1924
-			'In 1 day on July 1, 2024',
1925
-			$this->service->generateOccurringString($eventReader),
1926
-			'test patrial day recurring event in 1 day with two occurrences remaining and one exception'
1927
-		);
1928
-
1929
-		/** test patrial day recurring event in 1 day with three occurrences remaining and one exception */
1930
-		$vCalendar = clone $this->vCalendar1a;
1931
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
1932
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1933
-		// construct event reader
1934
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1935
-		// test output
1936
-		$this->assertEquals(
1937
-			'In 1 day on July 1, 2024 then on July 5, 2024',
1938
-			$this->service->generateOccurringString($eventReader),
1939
-			'test patrial day recurring event in 1 day with three occurrences remaining and one exception'
1940
-		);
1941
-
1942
-		/** test patrial day recurring event in 1 day with four occurrences remaining and one exception */
1943
-		$vCalendar = clone $this->vCalendar1a;
1944
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=4');
1945
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1946
-		// construct event reader
1947
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1948
-		// test output
1949
-		$this->assertEquals(
1950
-			'In 1 day on July 1, 2024 then on July 5, 2024 and July 7, 2024',
1951
-			$this->service->generateOccurringString($eventReader),
1952
-			'test patrial day recurring event in 1 day with four occurrences remaining and one exception'
1953
-		);
1954
-
1955
-		/** test patrial day recurring event in 2 days with single occurrences remaining and one exception */
1956
-		$vCalendar = clone $this->vCalendar1a;
1957
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
1958
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1959
-		// construct event reader
1960
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1961
-		// test output
1962
-		$this->assertEquals(
1963
-			'In 2 days on July 1, 2024',
1964
-			$this->service->generateOccurringString($eventReader),
1965
-			'test patrial day recurring event in 2 days with single occurrences remaining and one exception'
1966
-		);
1967
-
1968
-		/** test patrial day recurring event in 2 days with two occurrences remaining and one exception */
1969
-		$vCalendar = clone $this->vCalendar1a;
1970
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
1971
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1972
-		// construct event reader
1973
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1974
-		// test output
1975
-		$this->assertEquals(
1976
-			'In 2 days on July 1, 2024',
1977
-			$this->service->generateOccurringString($eventReader),
1978
-			'test patrial day recurring event in 2 days with two occurrences remaining and one exception'
1979
-		);
1980
-
1981
-		/** test patrial day recurring event in 2 days with three occurrences remaining and one exception */
1982
-		$vCalendar = clone $this->vCalendar1a;
1983
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
1984
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1985
-		// construct event reader
1986
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1987
-		// test output
1988
-		$this->assertEquals(
1989
-			'In 2 days on July 1, 2024 then on July 5, 2024',
1990
-			$this->service->generateOccurringString($eventReader),
1991
-			'test patrial day recurring event in 2 days with three occurrences remaining and one exception'
1992
-		);
1993
-
1994
-		/** test patrial day recurring event in 2 days with four occurrences remaining and one exception */
1995
-		$vCalendar = clone $this->vCalendar1a;
1996
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=4');
1997
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1998
-		// construct event reader
1999
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2000
-		// test output
2001
-		$this->assertEquals(
2002
-			'In 2 days on July 1, 2024 then on July 5, 2024 and July 7, 2024',
2003
-			$this->service->generateOccurringString($eventReader),
2004
-			'test patrial day recurring event in 2 days with four occurrences remaining and one exception'
2005
-		);
2006
-	}
2007
-
2008
-	public function testGenerateOccurringStringWithTwoExdate(): void {
2009
-
2010
-		// construct l10n return(s)
2011
-		$this->l10n->method('l')->willReturnCallback(
2012
-			function ($v1, $v2, $v3) {
2013
-				return match (true) {
2014
-					$v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 1, 2024',
2015
-					$v1 === 'date' && $v2 == (new \DateTime('20240705T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 5, 2024',
2016
-					$v1 === 'date' && $v2 == (new \DateTime('20240709T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 9, 2024'
2017
-				};
2018
-			}
2019
-		);
2020
-		$this->l10n->method('n')->willReturnMap([
2021
-			// singular
2022
-			[
2023
-				'In %n day on %1$s',
2024
-				'In %n days on %1$s',
2025
-				1,
2026
-				['July 1, 2024'],
2027
-				'In 1 day on July 1, 2024'
2028
-			],
2029
-			[
2030
-				'In %n day on %1$s then on %2$s',
2031
-				'In %n days on %1$s then on %2$s',
2032
-				1,
2033
-				['July 1, 2024', 'July 5, 2024'],
2034
-				'In 1 day on July 1, 2024 then on July 5, 2024'
2035
-			],
2036
-			[
2037
-				'In %n day on %1$s then on %2$s and %3$s',
2038
-				'In %n days on %1$s then on %2$s and %3$s',
2039
-				1,
2040
-				['July 1, 2024', 'July 5, 2024', 'July 9, 2024'],
2041
-				'In 1 day on July 1, 2024 then on July 5, 2024 and July 9, 2024'
2042
-			],
2043
-			// plural
2044
-			[
2045
-				'In %n day on %1$s',
2046
-				'In %n days on %1$s',
2047
-				2,
2048
-				['July 1, 2024'],
2049
-				'In 2 days on July 1, 2024'
2050
-			],
2051
-			[
2052
-				'In %n day on %1$s then on %2$s',
2053
-				'In %n days on %1$s then on %2$s',
2054
-				2,
2055
-				['July 1, 2024', 'July 5, 2024'],
2056
-				'In 2 days on July 1, 2024 then on July 5, 2024'
2057
-			],
2058
-			[
2059
-				'In %n day on %1$s then on %2$s and %3$s',
2060
-				'In %n days on %1$s then on %2$s and %3$s',
2061
-				2,
2062
-				['July 1, 2024', 'July 5, 2024', 'July 9, 2024'],
2063
-				'In 2 days on July 1, 2024 then on July 5, 2024 and July 9, 2024'
2064
-			],
2065
-		]);
2066
-
2067
-		// construct time factory return(s)
2068
-		$this->timeFactory->method('getDateTime')->willReturnOnConsecutiveCalls(
2069
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2070
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2071
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2072
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2073
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2074
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2075
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2076
-			(new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2077
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2078
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2079
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2080
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2081
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2082
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2083
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2084
-			(new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2085
-		);
2086
-
2087
-		/** test patrial day recurring event in 1 day with single occurrence remaining and two exception */
2088
-		$vCalendar = clone $this->vCalendar1a;
2089
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
2090
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2091
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2092
-		// construct event reader
2093
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2094
-		// test output
2095
-		$this->assertEquals(
2096
-			'In 1 day on July 1, 2024',
2097
-			$this->service->generateOccurringString($eventReader),
2098
-			'test patrial day recurring event in 1 day with single occurrence remaining and two exception'
2099
-		);
2100
-
2101
-		/** test patrial day recurring event in 1 day with two occurrences remaining and two exception */
2102
-		$vCalendar = clone $this->vCalendar1a;
2103
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
2104
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2105
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2106
-		// construct event reader
2107
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2108
-		// test output
2109
-		$this->assertEquals(
2110
-			'In 1 day on July 1, 2024',
2111
-			$this->service->generateOccurringString($eventReader),
2112
-			'test patrial day recurring event in 1 day with two occurrences remaining and two exception'
2113
-		);
2114
-
2115
-		/** test patrial day recurring event in 1 day with three occurrences remaining and two exception */
2116
-		$vCalendar = clone $this->vCalendar1a;
2117
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
2118
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2119
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2120
-		// construct event reader
2121
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2122
-		// test output
2123
-		$this->assertEquals(
2124
-			'In 1 day on July 1, 2024 then on July 5, 2024',
2125
-			$this->service->generateOccurringString($eventReader),
2126
-			'test patrial day recurring event in 1 day with three occurrences remaining and two exception'
2127
-		);
2128
-
2129
-		/** test patrial day recurring event in 1 day with four occurrences remaining and two exception */
2130
-		$vCalendar = clone $this->vCalendar1a;
2131
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=5');
2132
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2133
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2134
-		// construct event reader
2135
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2136
-		// test output
2137
-		$this->assertEquals(
2138
-			'In 1 day on July 1, 2024 then on July 5, 2024 and July 9, 2024',
2139
-			$this->service->generateOccurringString($eventReader),
2140
-			'test patrial day recurring event in 1 day with four occurrences remaining and two exception'
2141
-		);
2142
-
2143
-		/** test patrial day recurring event in 2 days with single occurrences remaining and two exception */
2144
-		$vCalendar = clone $this->vCalendar1a;
2145
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
2146
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2147
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2148
-		// construct event reader
2149
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2150
-		// test output
2151
-		$this->assertEquals(
2152
-			'In 2 days on July 1, 2024',
2153
-			$this->service->generateOccurringString($eventReader),
2154
-			'test patrial day recurring event in 2 days with single occurrences remaining and two exception'
2155
-		);
2156
-
2157
-		/** test patrial day recurring event in 2 days with two occurrences remaining and two exception */
2158
-		$vCalendar = clone $this->vCalendar1a;
2159
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
2160
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2161
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2162
-		// construct event reader
2163
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2164
-		// test output
2165
-		$this->assertEquals(
2166
-			'In 2 days on July 1, 2024',
2167
-			$this->service->generateOccurringString($eventReader),
2168
-			'test patrial day recurring event in 2 days with two occurrences remaining and two exception'
2169
-		);
2170
-
2171
-		/** test patrial day recurring event in 2 days with three occurrences remaining and two exception */
2172
-		$vCalendar = clone $this->vCalendar1a;
2173
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
2174
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2175
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2176
-		// construct event reader
2177
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2178
-		// test output
2179
-		$this->assertEquals(
2180
-			'In 2 days on July 1, 2024 then on July 5, 2024',
2181
-			$this->service->generateOccurringString($eventReader),
2182
-			'test patrial day recurring event in 2 days with three occurrences remaining and two exception'
2183
-		);
2184
-
2185
-		/** test patrial day recurring event in 2 days with five occurrences remaining and two exception */
2186
-		$vCalendar = clone $this->vCalendar1a;
2187
-		$vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=5');
2188
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2189
-		$vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2190
-		// construct event reader
2191
-		$eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2192
-		// test output
2193
-		$this->assertEquals(
2194
-			'In 2 days on July 1, 2024 then on July 5, 2024 and July 9, 2024',
2195
-			$this->service->generateOccurringString($eventReader),
2196
-			'test patrial day recurring event in 2 days with five occurrences remaining and two exception'
2197
-		);
2198
-	}
27
+    private URLGenerator&MockObject $urlGenerator;
28
+    private IConfig&MockObject $config;
29
+    private IDBConnection&MockObject $db;
30
+    private ISecureRandom&MockObject $random;
31
+    private IFactory&MockObject $l10nFactory;
32
+    private IL10N&MockObject $l10n;
33
+    private ITimeFactory&MockObject $timeFactory;
34
+    private IMipService $service;
35
+
36
+
37
+    private VCalendar $vCalendar1a;
38
+    private VCalendar $vCalendar1b;
39
+    private VCalendar $vCalendar2;
40
+    private VCalendar $vCalendar3;
41
+    /** @var DateTime DateTime object that will be returned by DateTime() or DateTime('now') */
42
+    public static $datetimeNow;
43
+
44
+    protected function setUp(): void {
45
+        parent::setUp();
46
+
47
+        $this->urlGenerator = $this->createMock(URLGenerator::class);
48
+        $this->config = $this->createMock(IConfig::class);
49
+        $this->db = $this->createMock(IDBConnection::class);
50
+        $this->random = $this->createMock(ISecureRandom::class);
51
+        $this->l10nFactory = $this->createMock(IFactory::class);
52
+        $this->l10n = $this->createMock(IL10N::class);
53
+        $this->timeFactory = $this->createMock(ITimeFactory::class);
54
+        $this->l10nFactory->expects(self::once())
55
+            ->method('findGenericLanguage')
56
+            ->willReturn('en');
57
+        $this->l10nFactory->expects(self::once())
58
+            ->method('get')
59
+            ->with('dav', 'en')
60
+            ->willReturn($this->l10n);
61
+        $this->service = new IMipService(
62
+            $this->urlGenerator,
63
+            $this->config,
64
+            $this->db,
65
+            $this->random,
66
+            $this->l10nFactory,
67
+            $this->timeFactory
68
+        );
69
+
70
+        // construct calendar with a 1 hour event and same start/end time zones
71
+        $this->vCalendar1a = new VCalendar();
72
+        $vEvent = $this->vCalendar1a->add('VEVENT', []);
73
+        $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
74
+        $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']);
75
+        $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']);
76
+        $vEvent->add('SUMMARY', 'Testing Event');
77
+        $vEvent->add('ORGANIZER', 'mailto:[email protected]', ['CN' => 'Organizer']);
78
+        $vEvent->add('ATTENDEE', 'mailto:[email protected]', [
79
+            'CN' => 'Attendee One',
80
+            'CUTYPE' => 'INDIVIDUAL',
81
+            'PARTSTAT' => 'NEEDS-ACTION',
82
+            'ROLE' => 'REQ-PARTICIPANT',
83
+            'RSVP' => 'TRUE'
84
+        ]);
85
+
86
+        // construct calendar with a 1 hour event and different start/end time zones
87
+        $this->vCalendar1b = new VCalendar();
88
+        $vEvent = $this->vCalendar1b->add('VEVENT', []);
89
+        $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
90
+        $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']);
91
+        $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Vancouver']);
92
+        $vEvent->add('SUMMARY', 'Testing Event');
93
+        $vEvent->add('ORGANIZER', 'mailto:[email protected]', ['CN' => 'Organizer']);
94
+        $vEvent->add('ATTENDEE', 'mailto:[email protected]', [
95
+            'CN' => 'Attendee One',
96
+            'CUTYPE' => 'INDIVIDUAL',
97
+            'PARTSTAT' => 'NEEDS-ACTION',
98
+            'ROLE' => 'REQ-PARTICIPANT',
99
+            'RSVP' => 'TRUE'
100
+        ]);
101
+
102
+        // construct calendar with a full day event
103
+        $this->vCalendar2 = new VCalendar();
104
+        // time zone component
105
+        $vTimeZone = $this->vCalendar2->add('VTIMEZONE');
106
+        $vTimeZone->add('TZID', 'America/Toronto');
107
+        // event component
108
+        $vEvent = $this->vCalendar2->add('VEVENT', []);
109
+        $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
110
+        $vEvent->add('DTSTART', '20240701');
111
+        $vEvent->add('DTEND', '20240702');
112
+        $vEvent->add('SUMMARY', 'Testing Event');
113
+        $vEvent->add('ORGANIZER', 'mailto:[email protected]', ['CN' => 'Organizer']);
114
+        $vEvent->add('ATTENDEE', 'mailto:[email protected]', [
115
+            'CN' => 'Attendee One',
116
+            'CUTYPE' => 'INDIVIDUAL',
117
+            'PARTSTAT' => 'NEEDS-ACTION',
118
+            'ROLE' => 'REQ-PARTICIPANT',
119
+            'RSVP' => 'TRUE'
120
+        ]);
121
+
122
+        // construct calendar with a multi day event
123
+        $this->vCalendar3 = new VCalendar();
124
+        // time zone component
125
+        $vTimeZone = $this->vCalendar3->add('VTIMEZONE');
126
+        $vTimeZone->add('TZID', 'America/Toronto');
127
+        // event component
128
+        $vEvent = $this->vCalendar3->add('VEVENT', []);
129
+        $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
130
+        $vEvent->add('DTSTART', '20240701');
131
+        $vEvent->add('DTEND', '20240706');
132
+        $vEvent->add('SUMMARY', 'Testing Event');
133
+        $vEvent->add('ORGANIZER', 'mailto:[email protected]', ['CN' => 'Organizer']);
134
+        $vEvent->add('ATTENDEE', 'mailto:[email protected]', [
135
+            'CN' => 'Attendee One',
136
+            'CUTYPE' => 'INDIVIDUAL',
137
+            'PARTSTAT' => 'NEEDS-ACTION',
138
+            'ROLE' => 'REQ-PARTICIPANT',
139
+            'RSVP' => 'TRUE'
140
+        ]);
141
+    }
142
+
143
+    public function testGetFrom(): void {
144
+        $senderName = 'Detective McQueen';
145
+        $default = 'Twin Lakes Police Department - Darkside Division';
146
+        $expected = 'Detective McQueen via Twin Lakes Police Department - Darkside Division';
147
+
148
+        $this->l10n->expects(self::once())
149
+            ->method('t')
150
+            ->willReturn($expected);
151
+
152
+        $actual = $this->service->getFrom($senderName, $default);
153
+        $this->assertEquals($expected, $actual);
154
+    }
155
+
156
+    public function testBuildBodyDataCreated(): void {
157
+
158
+        // construct l10n return(s)
159
+        $this->l10n->method('l')->willReturnCallback(
160
+            function ($v1, $v2, $v3) {
161
+                return match (true) {
162
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
163
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
164
+                    $v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'full'] => 'July 1, 2024'
165
+                };
166
+            }
167
+        );
168
+        $this->l10n->method('n')->willReturnMap([
169
+            [
170
+                'In %n day on %1$s between %2$s - %3$s',
171
+                'In %n days on %1$s between %2$s - %3$s',
172
+                1,
173
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
174
+                'In 1 day on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
175
+            ]
176
+        ]);
177
+        // construct time factory return(s)
178
+        $this->timeFactory->method('getDateTime')->willReturnCallback(
179
+            function ($v1, $v2) {
180
+                return match (true) {
181
+                    $v1 == 'now' && $v2 == null => (new \DateTime('20240630T000000'))
182
+                };
183
+            }
184
+        );
185
+        /** test singleton partial day event*/
186
+        $vCalendar = clone $this->vCalendar1a;
187
+        // construct event reader
188
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
189
+        // define expected output
190
+        $expected = [
191
+            'meeting_when' => $this->service->generateWhenString($eventReader),
192
+            'meeting_description' => '',
193
+            'meeting_title' => 'Testing Event',
194
+            'meeting_location' => '',
195
+            'meeting_url' => '',
196
+            'meeting_url_html' => '',
197
+        ];
198
+        // generate actual output
199
+        $actual = $this->service->buildBodyData($vCalendar->VEVENT[0], null);
200
+        // test output
201
+        $this->assertEquals($expected, $actual);
202
+    }
203
+
204
+    public function testBuildBodyDataUpdate(): void {
205
+
206
+        // construct l10n return(s)
207
+        $this->l10n->method('l')->willReturnCallback(
208
+            function ($v1, $v2, $v3) {
209
+                return match (true) {
210
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
211
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
212
+                    $v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'full'] => 'July 1, 2024'
213
+                };
214
+            }
215
+        );
216
+        $this->l10n->method('n')->willReturnMap([
217
+            [
218
+                'In %n day on %1$s between %2$s - %3$s',
219
+                'In %n days on %1$s between %2$s - %3$s',
220
+                1,
221
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
222
+                'In 1 day on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
223
+            ]
224
+        ]);
225
+        // construct time factory return(s)
226
+        $this->timeFactory->method('getDateTime')->willReturnCallback(
227
+            function ($v1, $v2) {
228
+                return match (true) {
229
+                    $v1 == 'now' && $v2 == null => (new \DateTime('20240630T000000'))
230
+                };
231
+            }
232
+        );
233
+        /** test singleton partial day event*/
234
+        $vCalendarNew = clone $this->vCalendar1a;
235
+        $vCalendarOld = clone $this->vCalendar1a;
236
+        // construct event reader
237
+        $eventReaderNew = new EventReader($vCalendarNew, $vCalendarNew->VEVENT[0]->UID->getValue());
238
+        // alter old event label/title
239
+        $vCalendarOld->VEVENT[0]->SUMMARY->setValue('Testing Singleton Event');
240
+        // define expected output
241
+        $expected = [
242
+            'meeting_when' => $this->service->generateWhenString($eventReaderNew),
243
+            'meeting_description' => '',
244
+            'meeting_title' => 'Testing Event',
245
+            'meeting_location' => '',
246
+            'meeting_url' => '',
247
+            'meeting_url_html' => '',
248
+            'meeting_when_html' => $this->service->generateWhenString($eventReaderNew),
249
+            'meeting_title_html' => sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", 'Testing Singleton Event', 'Testing Event'),
250
+            'meeting_description_html' => '',
251
+            'meeting_location_html' => ''
252
+        ];
253
+        // generate actual output
254
+        $actual = $this->service->buildBodyData($vCalendarNew->VEVENT[0], $vCalendarOld->VEVENT[0]);
255
+        // test output
256
+        $this->assertEquals($expected, $actual);
257
+    }
258
+
259
+    public function testGetLastOccurrenceRRULE(): void {
260
+        $vCalendar = new VCalendar();
261
+        $vCalendar->add('VEVENT', [
262
+            'UID' => 'uid-1234',
263
+            'LAST-MODIFIED' => 123456,
264
+            'SEQUENCE' => 2,
265
+            'SUMMARY' => 'Fellowship meeting',
266
+            'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
267
+            'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z',
268
+        ]);
269
+
270
+        $occurrence = $this->service->getLastOccurrence($vCalendar);
271
+        $this->assertEquals(1454284800, $occurrence);
272
+    }
273
+
274
+    public function testGetLastOccurrenceEndDate(): void {
275
+        $vCalendar = new VCalendar();
276
+        $vCalendar->add('VEVENT', [
277
+            'UID' => 'uid-1234',
278
+            'LAST-MODIFIED' => 123456,
279
+            'SEQUENCE' => 2,
280
+            'SUMMARY' => 'Fellowship meeting',
281
+            'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
282
+            'DTEND' => new \DateTime('2017-01-01 00:00:00'),
283
+        ]);
284
+
285
+        $occurrence = $this->service->getLastOccurrence($vCalendar);
286
+        $this->assertEquals(1483228800, $occurrence);
287
+    }
288
+
289
+    public function testGetLastOccurrenceDuration(): void {
290
+        $vCalendar = new VCalendar();
291
+        $vCalendar->add('VEVENT', [
292
+            'UID' => 'uid-1234',
293
+            'LAST-MODIFIED' => 123456,
294
+            'SEQUENCE' => 2,
295
+            'SUMMARY' => 'Fellowship meeting',
296
+            'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
297
+            'DURATION' => 'P12W',
298
+        ]);
299
+
300
+        $occurrence = $this->service->getLastOccurrence($vCalendar);
301
+        $this->assertEquals(1458864000, $occurrence);
302
+    }
303
+
304
+    public function testGetLastOccurrenceAllDay(): void {
305
+        $vCalendar = new VCalendar();
306
+        $vEvent = $vCalendar->add('VEVENT', [
307
+            'UID' => 'uid-1234',
308
+            'LAST-MODIFIED' => 123456,
309
+            'SEQUENCE' => 2,
310
+            'SUMMARY' => 'Fellowship meeting',
311
+            'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
312
+        ]);
313
+
314
+        // rewrite from DateTime to Date
315
+        $vEvent->DTSTART['VALUE'] = 'DATE';
316
+
317
+        $occurrence = $this->service->getLastOccurrence($vCalendar);
318
+        $this->assertEquals(1451692800, $occurrence);
319
+    }
320
+
321
+    public function testGetLastOccurrenceFallback(): void {
322
+        $vCalendar = new VCalendar();
323
+        $vCalendar->add('VEVENT', [
324
+            'UID' => 'uid-1234',
325
+            'LAST-MODIFIED' => 123456,
326
+            'SEQUENCE' => 2,
327
+            'SUMMARY' => 'Fellowship meeting',
328
+            'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
329
+        ]);
330
+
331
+        $occurrence = $this->service->getLastOccurrence($vCalendar);
332
+        $this->assertEquals(1451606400, $occurrence);
333
+    }
334
+
335
+    public function testGenerateWhenStringSingular(): void {
336
+
337
+        // construct l10n return(s)
338
+        $this->l10n->method('l')->willReturnCallback(
339
+            function ($v1, $v2, $v3) {
340
+                return match (true) {
341
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
342
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
343
+                    $v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'full'] => 'July 1, 2024',
344
+                    $v1 === 'date' && $v2 == (new \DateTime('20240701T000000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'full'] => 'July 1, 2024'
345
+                };
346
+            }
347
+        );
348
+        $this->l10n->method('t')->willReturnMap([
349
+            [
350
+                'In the past on %1$s for the entire day',
351
+                ['July 1, 2024'],
352
+                'In the past on July 1, 2024 for the entire day'
353
+            ],
354
+            [
355
+                'In the past on %1$s between %2$s - %3$s',
356
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
357
+                'In the past on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
358
+            ],
359
+        ]);
360
+        $this->l10n->method('n')->willReturnMap([
361
+            // singular entire day
362
+            [
363
+                'In %n minute on %1$s for the entire day',
364
+                'In %n minutes on %1$s for the entire day',
365
+                1,
366
+                ['July 1, 2024'],
367
+                'In 1 minute on July 1, 2024 for the entire day'
368
+            ],
369
+            [
370
+                'In %n hour on %1$s for the entire day',
371
+                'In %n hours on %1$s for the entire day',
372
+                1,
373
+                ['July 1, 2024'],
374
+                'In 1 hour on July 1, 2024 for the entire day'
375
+            ],
376
+            [
377
+                'In %n day on %1$s for the entire day',
378
+                'In %n days on %1$s for the entire day',
379
+                1,
380
+                ['July 1, 2024'],
381
+                'In 1 day on July 1, 2024 for the entire day'
382
+            ],
383
+            [
384
+                'In %n week on %1$s for the entire day',
385
+                'In %n weeks on %1$s for the entire day',
386
+                1,
387
+                ['July 1, 2024'],
388
+                'In 1 week on July 1, 2024 for the entire day'
389
+            ],
390
+            [
391
+                'In %n month on %1$s for the entire day',
392
+                'In %n months on %1$s for the entire day',
393
+                1,
394
+                ['July 1, 2024'],
395
+                'In 1 month on July 1, 2024 for the entire day'
396
+            ],
397
+            [
398
+                'In %n year on %1$s for the entire day',
399
+                'In %n years on %1$s for the entire day',
400
+                1,
401
+                ['July 1, 2024'],
402
+                'In 1 year on July 1, 2024 for the entire day'
403
+            ],
404
+            // plural entire day
405
+            [
406
+                'In %n minute on %1$s for the entire day',
407
+                'In %n minutes on %1$s for the entire day',
408
+                2,
409
+                ['July 1, 2024'],
410
+                'In 2 minutes on July 1, 2024 for the entire day'
411
+            ],
412
+            [
413
+                'In %n hour on %1$s for the entire day',
414
+                'In %n hours on %1$s for the entire day',
415
+                2,
416
+                ['July 1, 2024'],
417
+                'In 2 hours on July 1, 2024 for the entire day'
418
+            ],
419
+            [
420
+                'In %n day on %1$s for the entire day',
421
+                'In %n days on %1$s for the entire day',
422
+                2,
423
+                ['July 1, 2024'],
424
+                'In 2 days on July 1, 2024 for the entire day'
425
+            ],
426
+            [
427
+                'In %n week on %1$s for the entire day',
428
+                'In %n weeks on %1$s for the entire day',
429
+                2,
430
+                ['July 1, 2024'],
431
+                'In 2 weeks on July 1, 2024 for the entire day'
432
+            ],
433
+            [
434
+                'In %n month on %1$s for the entire day',
435
+                'In %n months on %1$s for the entire day',
436
+                2,
437
+                ['July 1, 2024'],
438
+                'In 2 months on July 1, 2024 for the entire day'
439
+            ],
440
+            [
441
+                'In %n year on %1$s for the entire day',
442
+                'In %n years on %1$s for the entire day',
443
+                2,
444
+                ['July 1, 2024'],
445
+                'In 2 years on July 1, 2024 for the entire day'
446
+            ],
447
+            // singular partial day
448
+            [
449
+                'In %n minute on %1$s between %2$s - %3$s',
450
+                'In %n minutes on %1$s between %2$s - %3$s',
451
+                1,
452
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
453
+                'In 1 minute on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
454
+            ],
455
+            [
456
+                'In %n hour on %1$s between %2$s - %3$s',
457
+                'In %n hours on %1$s between %2$s - %3$s',
458
+                1,
459
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
460
+                'In 1 hour on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
461
+            ],
462
+            [
463
+                'In %n day on %1$s between %2$s - %3$s',
464
+                'In %n days on %1$s between %2$s - %3$s',
465
+                1,
466
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
467
+                'In 1 day on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
468
+            ],
469
+            [
470
+                'In %n week on %1$s between %2$s - %3$s',
471
+                'In %n weeks on %1$s between %2$s - %3$s',
472
+                1,
473
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
474
+                'In 1 week on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
475
+            ],
476
+            [
477
+                'In %n month on %1$s between %2$s - %3$s',
478
+                'In %n months on %1$s between %2$s - %3$s',
479
+                1,
480
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
481
+                'In 1 month on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
482
+            ],
483
+            [
484
+                'In %n year on %1$s between %2$s - %3$s',
485
+                'In %n years on %1$s between %2$s - %3$s',
486
+                1,
487
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
488
+                'In 1 year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
489
+            ],
490
+            // plural partial day
491
+            [
492
+                'In %n minute on %1$s between %2$s - %3$s',
493
+                'In %n minutes on %1$s between %2$s - %3$s',
494
+                2,
495
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
496
+                'In 2 minutes on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
497
+            ],
498
+            [
499
+                'In %n hour on %1$s between %2$s - %3$s',
500
+                'In %n hours on %1$s between %2$s - %3$s',
501
+                2,
502
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
503
+                'In 2 hours on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
504
+            ],
505
+            [
506
+                'In %n day on %1$s between %2$s - %3$s',
507
+                'In %n days on %1$s between %2$s - %3$s',
508
+                2,
509
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
510
+                'In 2 days on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
511
+            ],
512
+            [
513
+                'In %n week on %1$s between %2$s - %3$s',
514
+                'In %n weeks on %1$s between %2$s - %3$s',
515
+                2,
516
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
517
+                'In 2 weeks on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
518
+            ],
519
+            [
520
+                'In %n month on %1$s between %2$s - %3$s',
521
+                'In %n months on %1$s between %2$s - %3$s',
522
+                2,
523
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
524
+                'In 2 months on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
525
+            ],
526
+            [
527
+                'In %n year on %1$s between %2$s - %3$s',
528
+                'In %n years on %1$s between %2$s - %3$s',
529
+                2,
530
+                ['July 1, 2024', '8:00 AM', '9:00 AM (America/Toronto)'],
531
+                'In 2 years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)'
532
+            ],
533
+        ]);
534
+
535
+        // construct time factory return(s)
536
+        $this->timeFactory->method('getDateTime')->willReturnOnConsecutiveCalls(
537
+            // past interval test dates
538
+            (new \DateTime('20240702T170000', (new \DateTimeZone('America/Toronto')))),
539
+            (new \DateTime('20240703T170000', (new \DateTimeZone('America/Toronto')))),
540
+            (new \DateTime('20240702T170000', (new \DateTimeZone('America/Toronto')))),
541
+            (new \DateTime('20240703T170000', (new \DateTimeZone('America/Toronto')))),
542
+            // minute interval test dates
543
+            (new \DateTime('20240701T075900', (new \DateTimeZone('America/Toronto')))),
544
+            (new \DateTime('20240630T235900', (new \DateTimeZone('America/Toronto')))),
545
+            (new \DateTime('20240701T075800', (new \DateTimeZone('America/Toronto')))),
546
+            (new \DateTime('20240630T235800', (new \DateTimeZone('America/Toronto')))),
547
+            // hour interval test dates
548
+            (new \DateTime('20240701T070000', (new \DateTimeZone('America/Toronto')))),
549
+            (new \DateTime('20240630T230000', (new \DateTimeZone('America/Toronto')))),
550
+            (new \DateTime('20240701T060000', (new \DateTimeZone('America/Toronto')))),
551
+            (new \DateTime('20240630T220000', (new \DateTimeZone('America/Toronto')))),
552
+            // day interval test dates
553
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
554
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
555
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
556
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
557
+            // week interval test dates
558
+            (new \DateTime('20240621T170000', (new \DateTimeZone('America/Toronto')))),
559
+            (new \DateTime('20240621T170000', (new \DateTimeZone('America/Toronto')))),
560
+            (new \DateTime('20240614T170000', (new \DateTimeZone('America/Toronto')))),
561
+            (new \DateTime('20240614T170000', (new \DateTimeZone('America/Toronto')))),
562
+            // month interval test dates
563
+            (new \DateTime('20240530T170000', (new \DateTimeZone('America/Toronto')))),
564
+            (new \DateTime('20240530T170000', (new \DateTimeZone('America/Toronto')))),
565
+            (new \DateTime('20240430T170000', (new \DateTimeZone('America/Toronto')))),
566
+            (new \DateTime('20240430T170000', (new \DateTimeZone('America/Toronto')))),
567
+            // year interval test dates
568
+            (new \DateTime('20230630T170000', (new \DateTimeZone('America/Toronto')))),
569
+            (new \DateTime('20230630T170000', (new \DateTimeZone('America/Toronto')))),
570
+            (new \DateTime('20220630T170000', (new \DateTimeZone('America/Toronto')))),
571
+            (new \DateTime('20220630T170000', (new \DateTimeZone('America/Toronto'))))
572
+        );
573
+
574
+        /** test partial day event in 1 day in the past*/
575
+        $vCalendar = clone $this->vCalendar1a;
576
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
577
+        $this->assertEquals(
578
+            'In the past on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
579
+            $this->service->generateWhenString($eventReader)
580
+        );
581
+
582
+        /** test entire day event in 1 day in the past*/
583
+        $vCalendar = clone $this->vCalendar2;
584
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
585
+        $this->assertEquals(
586
+            'In the past on July 1, 2024 for the entire day',
587
+            $this->service->generateWhenString($eventReader)
588
+        );
589
+
590
+        /** test partial day event in 2 days in the past*/
591
+        $vCalendar = clone $this->vCalendar1a;
592
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
593
+        $this->assertEquals(
594
+            'In the past on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
595
+            $this->service->generateWhenString($eventReader)
596
+        );
597
+
598
+        /** test entire day event in 2 days in the past*/
599
+        $vCalendar = clone $this->vCalendar2;
600
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
601
+        $this->assertEquals(
602
+            'In the past on July 1, 2024 for the entire day',
603
+            $this->service->generateWhenString($eventReader)
604
+        );
605
+
606
+        /** test partial day event in 1 minute*/
607
+        $vCalendar = clone $this->vCalendar1a;
608
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
609
+        $this->assertEquals(
610
+            'In 1 minute on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
611
+            $this->service->generateWhenString($eventReader)
612
+        );
613
+
614
+        /** test entire day event in 1 minute*/
615
+        $vCalendar = clone $this->vCalendar2;
616
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
617
+        $this->assertEquals(
618
+            'In 1 minute on July 1, 2024 for the entire day',
619
+            $this->service->generateWhenString($eventReader)
620
+        );
621
+
622
+        /** test partial day event in 2 minutes*/
623
+        $vCalendar = clone $this->vCalendar1a;
624
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
625
+        $this->assertEquals(
626
+            'In 2 minutes on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
627
+            $this->service->generateWhenString($eventReader)
628
+        );
629
+
630
+        /** test entire day event in 2 minutes*/
631
+        $vCalendar = clone $this->vCalendar2;
632
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
633
+        $this->assertEquals(
634
+            'In 2 minutes on July 1, 2024 for the entire day',
635
+            $this->service->generateWhenString($eventReader)
636
+        );
637
+
638
+        /** test partial day event in 1 hour*/
639
+        $vCalendar = clone $this->vCalendar1a;
640
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
641
+        $this->assertEquals(
642
+            'In 1 hour on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
643
+            $this->service->generateWhenString($eventReader)
644
+        );
645
+
646
+        /** test entire day event in 1 hour*/
647
+        $vCalendar = clone $this->vCalendar2;
648
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
649
+        $this->assertEquals(
650
+            'In 1 hour on July 1, 2024 for the entire day',
651
+            $this->service->generateWhenString($eventReader)
652
+        );
653
+
654
+        /** test partial day event in 2 hours*/
655
+        $vCalendar = clone $this->vCalendar1a;
656
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
657
+        $this->assertEquals(
658
+            'In 2 hours on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
659
+            $this->service->generateWhenString($eventReader)
660
+        );
661
+
662
+        /** test entire day event in 2 hours*/
663
+        $vCalendar = clone $this->vCalendar2;
664
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
665
+        $this->assertEquals(
666
+            'In 2 hours on July 1, 2024 for the entire day',
667
+            $this->service->generateWhenString($eventReader)
668
+        );
669
+
670
+        /** test patrial day event in 1 day*/
671
+        $vCalendar = clone $this->vCalendar1a;
672
+        // construct event reader
673
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
674
+        // test output
675
+        $this->assertEquals(
676
+            'In 1 day on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
677
+            $this->service->generateWhenString($eventReader)
678
+        );
679
+
680
+        /** test entire day event in 1 day*/
681
+        $vCalendar = clone $this->vCalendar2;
682
+        // construct event reader
683
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
684
+        // test output
685
+        $this->assertEquals(
686
+            'In 1 day on July 1, 2024 for the entire day',
687
+            $this->service->generateWhenString($eventReader)
688
+        );
689
+
690
+        /** test patrial day event in 2 days*/
691
+        $vCalendar = clone $this->vCalendar1a;
692
+        // construct event reader
693
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
694
+        // test output
695
+        $this->assertEquals(
696
+            'In 2 days on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
697
+            $this->service->generateWhenString($eventReader)
698
+        );
699
+
700
+        /** test entire day event in 2 days*/
701
+        $vCalendar = clone $this->vCalendar2;
702
+        // construct event reader
703
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
704
+        // test output
705
+        $this->assertEquals(
706
+            'In 2 days on July 1, 2024 for the entire day',
707
+            $this->service->generateWhenString($eventReader)
708
+        );
709
+
710
+        /** test patrial day event in 1 week*/
711
+        $vCalendar = clone $this->vCalendar1a;
712
+        // construct event reader
713
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
714
+        // test output
715
+        $this->assertEquals(
716
+            'In 1 week on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
717
+            $this->service->generateWhenString($eventReader)
718
+        );
719
+
720
+        /** test entire day event in 1 week*/
721
+        $vCalendar = clone $this->vCalendar2;
722
+        // construct event reader
723
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
724
+        // test output
725
+        $this->assertEquals(
726
+            'In 1 week on July 1, 2024 for the entire day',
727
+            $this->service->generateWhenString($eventReader)
728
+        );
729
+
730
+        /** test patrial day event in 2 weeks*/
731
+        $vCalendar = clone $this->vCalendar1a;
732
+        // construct event reader
733
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
734
+        // test output
735
+        $this->assertEquals(
736
+            'In 2 weeks on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
737
+            $this->service->generateWhenString($eventReader)
738
+        );
739
+
740
+        /** test entire day event in 2 weeks*/
741
+        $vCalendar = clone $this->vCalendar2;
742
+        // construct event reader
743
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
744
+        // test output
745
+        $this->assertEquals(
746
+            'In 2 weeks on July 1, 2024 for the entire day',
747
+            $this->service->generateWhenString($eventReader)
748
+        );
749
+
750
+        /** test patrial day event in 1 month*/
751
+        $vCalendar = clone $this->vCalendar1a;
752
+        // construct event reader
753
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
754
+        // test output
755
+        $this->assertEquals(
756
+            'In 1 month on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
757
+            $this->service->generateWhenString($eventReader)
758
+        );
759
+
760
+        /** test entire day event in 1 month*/
761
+        $vCalendar = clone $this->vCalendar2;
762
+        // construct event reader
763
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
764
+        // test output
765
+        $this->assertEquals(
766
+            'In 1 month on July 1, 2024 for the entire day',
767
+            $this->service->generateWhenString($eventReader)
768
+        );
769
+
770
+        /** test patrial day event in 2 months*/
771
+        $vCalendar = clone $this->vCalendar1a;
772
+        // construct event reader
773
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
774
+        // test output
775
+        $this->assertEquals(
776
+            'In 2 months on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
777
+            $this->service->generateWhenString($eventReader)
778
+        );
779
+
780
+        /** test entire day event in 2 months*/
781
+        $vCalendar = clone $this->vCalendar2;
782
+        // construct event reader
783
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
784
+        // test output
785
+        $this->assertEquals(
786
+            'In 2 months on July 1, 2024 for the entire day',
787
+            $this->service->generateWhenString($eventReader)
788
+        );
789
+
790
+        /** test patrial day event in 1 year*/
791
+        $vCalendar = clone $this->vCalendar1a;
792
+        // construct event reader
793
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
794
+        // test output
795
+        $this->assertEquals(
796
+            'In 1 year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
797
+            $this->service->generateWhenString($eventReader)
798
+        );
799
+
800
+        /** test entire day event in 1 year*/
801
+        $vCalendar = clone $this->vCalendar2;
802
+        // construct event reader
803
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
804
+        // test output
805
+        $this->assertEquals(
806
+            'In 1 year on July 1, 2024 for the entire day',
807
+            $this->service->generateWhenString($eventReader)
808
+        );
809
+
810
+        /** test patrial day event in 2 years*/
811
+        $vCalendar = clone $this->vCalendar1a;
812
+        // construct event reader
813
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
814
+        // test output
815
+        $this->assertEquals(
816
+            'In 2 years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)',
817
+            $this->service->generateWhenString($eventReader)
818
+        );
819
+
820
+        /** test entire day event in 2 years*/
821
+        $vCalendar = clone $this->vCalendar2;
822
+        // construct event reader
823
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
824
+        // test output
825
+        $this->assertEquals(
826
+            'In 2 years on July 1, 2024 for the entire day',
827
+            $this->service->generateWhenString($eventReader)
828
+        );
829
+
830
+    }
831
+
832
+    public function testGenerateWhenStringRecurringDaily(): void {
833
+
834
+        // construct l10n return maps
835
+        $this->l10n->method('l')->willReturnCallback(
836
+            function ($v1, $v2, $v3) {
837
+                return match (true) {
838
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
839
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
840
+                    $v1 === 'date' && $v2 == (new \DateTime('20240713T080000', (new \DateTimeZone('UTC')))) && $v3 == ['width' => 'long'] => 'July 13, 2024'
841
+                };
842
+            }
843
+        );
844
+        $this->l10n->method('t')->willReturnMap([
845
+            ['Every Day for the entire day', [], 'Every Day for the entire day'],
846
+            ['Every Day for the entire day until %1$s', ['July 13, 2024'], 'Every Day for the entire day until July 13, 2024'],
847
+            ['Every Day between %1$s - %2$s', ['8:00 AM', '9:00 AM (America/Toronto)'], 'Every Day between 8:00 AM - 9:00 AM (America/Toronto)'],
848
+            ['Every Day between %1$s - %2$s until %3$s', ['8:00 AM', '9:00 AM (America/Toronto)', 'July 13, 2024'], 'Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024'],
849
+            ['Every %1$d Days for the entire day', [3], 'Every 3 Days for the entire day'],
850
+            ['Every %1$d Days for the entire day until %2$s', [3, 'July 13, 2024'], 'Every 3 Days for the entire day until July 13, 2024'],
851
+            ['Every %1$d Days between %2$s - %3$s', [3, '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto)'],
852
+            ['Every %1$d Days between %2$s - %3$s until %4$s', [3, '8:00 AM', '9:00 AM (America/Toronto)', 'July 13, 2024'], 'Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024'],
853
+            ['Could not generate event recurrence statement', [], 'Could not generate event recurrence statement'],
854
+        ]);
855
+
856
+        /** test partial day event with every day interval and no conclusion*/
857
+        $vCalendar = clone $this->vCalendar1a;
858
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=1;');
859
+        // construct event reader
860
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
861
+        // test output
862
+        $this->assertEquals(
863
+            'Every Day between 8:00 AM - 9:00 AM (America/Toronto)',
864
+            $this->service->generateWhenString($eventReader)
865
+        );
866
+
867
+        /** test partial day event with every day interval and conclusion*/
868
+        $vCalendar = clone $this->vCalendar1a;
869
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=1;UNTIL=20240713T080000Z');
870
+        // construct event reader
871
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
872
+        // test output
873
+        $this->assertEquals(
874
+            'Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024',
875
+            $this->service->generateWhenString($eventReader)
876
+        );
877
+
878
+        /** test partial day event every 3rd day interval and no conclusion*/
879
+        $vCalendar = clone $this->vCalendar1a;
880
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=3;');
881
+        // construct event reader
882
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
883
+        // test output
884
+        $this->assertEquals(
885
+            'Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto)',
886
+            $this->service->generateWhenString($eventReader)
887
+        );
888
+
889
+        /** test partial day event with every 3rd day interval and conclusion*/
890
+        $vCalendar = clone $this->vCalendar1a;
891
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=3;UNTIL=20240713T080000Z');
892
+        // construct event reader
893
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
894
+        // test output
895
+        $this->assertEquals(
896
+            'Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024',
897
+            $this->service->generateWhenString($eventReader)
898
+        );
899
+
900
+        /** test entire day event with every day interval and no conclusion*/
901
+        $vCalendar = clone $this->vCalendar2;
902
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=1;');
903
+        // construct event reader
904
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
905
+        // test output
906
+        $this->assertEquals(
907
+            'Every Day for the entire day',
908
+            $this->service->generateWhenString($eventReader)
909
+        );
910
+
911
+        /** test entire day event with every day interval and conclusion*/
912
+        $vCalendar = clone $this->vCalendar2;
913
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=1;UNTIL=20240713T080000Z');
914
+        // construct event reader
915
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
916
+        // test output
917
+        $this->assertEquals(
918
+            'Every Day for the entire day until July 13, 2024',
919
+            $this->service->generateWhenString($eventReader)
920
+        );
921
+
922
+        /** test entire day event with every 3rd day interval and no conclusion*/
923
+        $vCalendar = clone $this->vCalendar2;
924
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=3;');
925
+        // construct event reader
926
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
927
+        // test output
928
+        $this->assertEquals(
929
+            'Every 3 Days for the entire day',
930
+            $this->service->generateWhenString($eventReader)
931
+        );
932
+
933
+        /** test entire day event with every 3rd day interval and conclusion*/
934
+        $vCalendar = clone $this->vCalendar2;
935
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=3;UNTIL=20240713T080000Z');
936
+        // construct event reader
937
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
938
+        // test output
939
+        $this->assertEquals(
940
+            'Every 3 Days for the entire day until July 13, 2024',
941
+            $this->service->generateWhenString($eventReader)
942
+        );
943
+
944
+    }
945
+
946
+    public function testGenerateWhenStringRecurringWeekly(): void {
947
+
948
+        // construct l10n return maps
949
+        $this->l10n->method('l')->willReturnCallback(
950
+            function ($v1, $v2, $v3) {
951
+                return match (true) {
952
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
953
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
954
+                    $v1 === 'date' && $v2 == (new \DateTime('20240722T080000', (new \DateTimeZone('UTC')))) && $v3 == ['width' => 'long'] => 'July 13, 2024'
955
+                };
956
+            }
957
+        );
958
+        $this->l10n->method('t')->willReturnMap([
959
+            ['Every Week on %1$s for the entire day', ['Monday, Wednesday, Friday'], 'Every Week on Monday, Wednesday, Friday for the entire day'],
960
+            ['Every Week on %1$s for the entire day until %2$s', ['Monday, Wednesday, Friday', 'July 13, 2024'], 'Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024'],
961
+            ['Every Week on %1$s between %2$s - %3$s', ['Monday, Wednesday, Friday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)'],
962
+            ['Every Week on %1$s between %2$s - %3$s until %4$s', ['Monday, Wednesday, Friday', '8:00 AM', '9:00 AM (America/Toronto)', 'July 13, 2024'], 'Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024'],
963
+            ['Every %1$d Weeks on %2$s for the entire day', [2, 'Monday, Wednesday, Friday'], 'Every 2 Weeks on Monday, Wednesday, Friday for the entire day'],
964
+            ['Every %1$d Weeks on %2$s for the entire day until %3$s', [2, 'Monday, Wednesday, Friday', 'July 13, 2024'], 'Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024'],
965
+            ['Every %1$d Weeks on %2$s between %3$s - %4$s', [2, 'Monday, Wednesday, Friday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)'],
966
+            ['Every %1$d Weeks on %2$s between %3$s - %4$s until %5$s', [2, 'Monday, Wednesday, Friday', '8:00 AM', '9:00 AM (America/Toronto)', 'July 13, 2024'], 'Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024'],
967
+            ['Could not generate event recurrence statement', [], 'Could not generate event recurrence statement'],
968
+            ['Monday', [], 'Monday'],
969
+            ['Wednesday', [], 'Wednesday'],
970
+            ['Friday', [], 'Friday'],
971
+        ]);
972
+
973
+        /** test partial day event with every week interval on Mon, Wed, Fri and no conclusion*/
974
+        $vCalendar = clone $this->vCalendar1a;
975
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR');
976
+        // construct event reader
977
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
978
+        // test output
979
+        $this->assertEquals(
980
+            'Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)',
981
+            $this->service->generateWhenString($eventReader)
982
+        );
983
+
984
+        /** test partial day event with every week interval on Mon, Wed, Fri and conclusion*/
985
+        $vCalendar = clone $this->vCalendar1a;
986
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20240722T080000Z;');
987
+        // construct event reader
988
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
989
+        // test output
990
+        $this->assertEquals(
991
+            'Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024',
992
+            $this->service->generateWhenString($eventReader)
993
+        );
994
+
995
+        /** test partial day event with every 2nd week interval on Mon, Wed, Fri and no conclusion*/
996
+        $vCalendar = clone $this->vCalendar1a;
997
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;');
998
+        // construct event reader
999
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1000
+        // test output
1001
+        $this->assertEquals(
1002
+            'Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)',
1003
+            $this->service->generateWhenString($eventReader)
1004
+        );
1005
+
1006
+        /** test partial day event with every 2nd week interval on Mon, Wed, Fri and conclusion*/
1007
+        $vCalendar = clone $this->vCalendar1a;
1008
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;UNTIL=20240722T080000Z;');
1009
+        // construct event reader
1010
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1011
+        // test output
1012
+        $this->assertEquals(
1013
+            'Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024',
1014
+            $this->service->generateWhenString($eventReader)
1015
+        );
1016
+
1017
+        /** test entire day event with every week interval on Mon, Wed, Fri and no conclusion*/
1018
+        $vCalendar = clone $this->vCalendar2;
1019
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;');
1020
+        // construct event reader
1021
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1022
+        // test output
1023
+        $this->assertEquals(
1024
+            'Every Week on Monday, Wednesday, Friday for the entire day',
1025
+            $this->service->generateWhenString($eventReader)
1026
+        );
1027
+
1028
+        /** test entire day event with every week interval on Mon, Wed, Fri and conclusion*/
1029
+        $vCalendar = clone $this->vCalendar2;
1030
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20240722T080000Z;');
1031
+        // construct event reader
1032
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1033
+        // test output
1034
+        $this->assertEquals(
1035
+            'Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024',
1036
+            $this->service->generateWhenString($eventReader)
1037
+        );
1038
+
1039
+        /** test entire day event with every 2nd week interval on Mon, Wed, Fri and no conclusion*/
1040
+        $vCalendar = clone $this->vCalendar2;
1041
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;');
1042
+        // construct event reader
1043
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1044
+        // test output
1045
+        $this->assertEquals(
1046
+            'Every 2 Weeks on Monday, Wednesday, Friday for the entire day',
1047
+            $this->service->generateWhenString($eventReader)
1048
+        );
1049
+
1050
+        /** test entire day event with every 2nd week interval on Mon, Wed, Fri and conclusion*/
1051
+        $vCalendar = clone $this->vCalendar2;
1052
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;UNTIL=20240722T080000Z;');
1053
+        // construct event reader
1054
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1055
+        // test output
1056
+        $this->assertEquals(
1057
+            'Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024',
1058
+            $this->service->generateWhenString($eventReader)
1059
+        );
1060
+
1061
+    }
1062
+
1063
+    public function testGenerateWhenStringRecurringMonthly(): void {
1064
+
1065
+        // construct l10n return maps
1066
+        $this->l10n->method('l')->willReturnCallback(
1067
+            function ($v1, $v2, $v3) {
1068
+                return match (true) {
1069
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
1070
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
1071
+                    $v1 === 'date' && $v2 == (new \DateTime('20241231T080000', (new \DateTimeZone('UTC')))) && $v3 == ['width' => 'long'] => 'December 31, 2024'
1072
+                };
1073
+            }
1074
+        );
1075
+        $this->l10n->method('t')->willReturnMap([
1076
+            ['Every Month on the %1$s for the entire day', ['1, 8'], 'Every Month on the 1, 8 for the entire day'],
1077
+            ['Every Month on the %1$s for the entire day until %2$s', ['1, 8', 'December 31, 2024'], 'Every Month on the 1, 8 for the entire day until December 31, 2024'],
1078
+            ['Every Month on the %1$s between %2$s - %3$s', ['1, 8', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)'],
1079
+            ['Every Month on the %1$s between %2$s - %3$s until %4$s', ['1, 8', '8:00 AM', '9:00 AM (America/Toronto)', 'December 31, 2024'], 'Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024'],
1080
+            ['Every %1$d Months on the %2$s for the entire day', [2, '1, 8'], 'Every 2 Months on the 1, 8 for the entire day'],
1081
+            ['Every %1$d Months on the %2$s for the entire day until %3$s', [2, '1, 8', 'December 31, 2024'], 'Every 2 Months on the 1, 8 for the entire day until December 31, 2024'],
1082
+            ['Every %1$d Months on the %2$s between %3$s - %4$s', [2, '1, 8', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)'],
1083
+            ['Every %1$d Months on the %2$s between %3$s - %4$s until %5$s', [2, '1, 8', '8:00 AM', '9:00 AM (America/Toronto)', 'December 31, 2024'], 'Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024'],
1084
+            ['Every Month on the %1$s for the entire day', ['First Sunday, Saturday'], 'Every Month on the First Sunday, Saturday for the entire day'],
1085
+            ['Every Month on the %1$s for the entire day until %2$s', ['First Sunday, Saturday', 'December 31, 2024'], 'Every Month on the First Sunday, Saturday for the entire day until December 31, 2024'],
1086
+            ['Every Month on the %1$s between %2$s - %3$s', ['First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)'],
1087
+            ['Every Month on the %1$s between %2$s - %3$s until %4$s', ['First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)', 'December 31, 2024'], 'Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024'],
1088
+            ['Every %1$d Months on the %2$s for the entire day', [2, 'First Sunday, Saturday'], 'Every 2 Months on the First Sunday, Saturday for the entire day'],
1089
+            ['Every %1$d Months on the %2$s for the entire day until %3$s', [2, 'First Sunday, Saturday', 'December 31, 2024'], 'Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024'],
1090
+            ['Every %1$d Months on the %2$s between %3$s - %4$s', [2, 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)'],
1091
+            ['Every %1$d Months on the %2$s between %3$s - %4$s until %5$s', [2, 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)', 'December 31, 2024'], 'Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024'],
1092
+            ['Could not generate event recurrence statement', [], 'Could not generate event recurrence statement'],
1093
+            ['Saturday', [], 'Saturday'],
1094
+            ['Sunday', [], 'Sunday'],
1095
+            ['First', [], 'First'],
1096
+        ]);
1097
+
1098
+        /** test absolute partial day event with every month interval on 1st, 8th and no conclusion*/
1099
+        $vCalendar = clone $this->vCalendar1a;
1100
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;');
1101
+        // construct event reader
1102
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1103
+        // test output
1104
+        $this->assertEquals(
1105
+            'Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)',
1106
+            $this->service->generateWhenString($eventReader)
1107
+        );
1108
+
1109
+        /** test absolute partial day event with every Month interval on 1st, 8th and conclusion*/
1110
+        $vCalendar = clone $this->vCalendar1a;
1111
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;UNTIL=20241231T080000Z;');
1112
+        // construct event reader
1113
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1114
+        // test output
1115
+        $this->assertEquals(
1116
+            'Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024',
1117
+            $this->service->generateWhenString($eventReader)
1118
+        );
1119
+
1120
+        /** test absolute partial day event with every 2nd Month interval on 1st, 8th and no conclusion*/
1121
+        $vCalendar = clone $this->vCalendar1a;
1122
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;INTERVAL=2;');
1123
+        // construct event reader
1124
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1125
+        // test output
1126
+        $this->assertEquals(
1127
+            'Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)',
1128
+            $this->service->generateWhenString($eventReader)
1129
+        );
1130
+
1131
+        /** test absolute partial day event with every 2nd Month interval on 1st, 8th and conclusion*/
1132
+        $vCalendar = clone $this->vCalendar1a;
1133
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;INTERVAL=2;UNTIL=20241231T080000Z;');
1134
+        // construct event reader
1135
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1136
+        // test output
1137
+        $this->assertEquals(
1138
+            'Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024',
1139
+            $this->service->generateWhenString($eventReader)
1140
+        );
1141
+
1142
+        /** test absolute entire day event with every Month interval on 1st, 8th and no conclusion*/
1143
+        $vCalendar = clone $this->vCalendar2;
1144
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;');
1145
+        // construct event reader
1146
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1147
+        // test output
1148
+        $this->assertEquals(
1149
+            'Every Month on the 1, 8 for the entire day',
1150
+            $this->service->generateWhenString($eventReader)
1151
+        );
1152
+
1153
+        /** test absolute entire day event with every Month interval on 1st, 8th and conclusion*/
1154
+        $vCalendar = clone $this->vCalendar2;
1155
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;UNTIL=20241231T080000Z;');
1156
+        // construct event reader
1157
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1158
+        // test output
1159
+        $this->assertEquals(
1160
+            'Every Month on the 1, 8 for the entire day until December 31, 2024',
1161
+            $this->service->generateWhenString($eventReader)
1162
+        );
1163
+
1164
+        /** test absolute entire day event with every 2nd Month interval on 1st, 8th and no conclusion*/
1165
+        $vCalendar = clone $this->vCalendar2;
1166
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;INTERVAL=2;');
1167
+        // construct event reader
1168
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1169
+        // test output
1170
+        $this->assertEquals(
1171
+            'Every 2 Months on the 1, 8 for the entire day',
1172
+            $this->service->generateWhenString($eventReader)
1173
+        );
1174
+
1175
+        /** test absolute entire day event with every 2nd Month interval on 1st, 8th and conclusion*/
1176
+        $vCalendar = clone $this->vCalendar2;
1177
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYMONTHDAY=1,8;INTERVAL=2;UNTIL=20241231T080000Z;');
1178
+        // construct event reader
1179
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1180
+        // test output
1181
+        $this->assertEquals(
1182
+            'Every 2 Months on the 1, 8 for the entire day until December 31, 2024',
1183
+            $this->service->generateWhenString($eventReader)
1184
+        );
1185
+
1186
+        /** test relative partial day event with every month interval on the 1st Saturday, Sunday and no conclusion*/
1187
+        $vCalendar = clone $this->vCalendar1a;
1188
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;');
1189
+        // construct event reader
1190
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1191
+        // test output
1192
+        $this->assertEquals(
1193
+            'Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)',
1194
+            $this->service->generateWhenString($eventReader)
1195
+        );
1196
+
1197
+        /** test relative partial day event with every Month interval on the 1st Saturday, Sunday and conclusion*/
1198
+        $vCalendar = clone $this->vCalendar1a;
1199
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;UNTIL=20241231T080000Z;');
1200
+        // construct event reader
1201
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1202
+        // test output
1203
+        $this->assertEquals(
1204
+            'Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024',
1205
+            $this->service->generateWhenString($eventReader)
1206
+        );
1207
+
1208
+        /** test relative partial day event with every 2nd Month interval on the 1st Saturday, Sunday and no conclusion*/
1209
+        $vCalendar = clone $this->vCalendar1a;
1210
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;');
1211
+        // construct event reader
1212
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1213
+        // test output
1214
+        $this->assertEquals(
1215
+            'Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)',
1216
+            $this->service->generateWhenString($eventReader)
1217
+        );
1218
+
1219
+        /** test relative partial day event with every 2nd Month interval on the 1st Saturday, Sunday and conclusion*/
1220
+        $vCalendar = clone $this->vCalendar1a;
1221
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;UNTIL=20241231T080000Z;');
1222
+        // construct event reader
1223
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1224
+        // test output
1225
+        $this->assertEquals(
1226
+            'Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024',
1227
+            $this->service->generateWhenString($eventReader)
1228
+        );
1229
+
1230
+        /** test relative entire day event with every Month interval on the 1st Saturday, Sunday and no conclusion*/
1231
+        $vCalendar = clone $this->vCalendar2;
1232
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;');
1233
+        // construct event reader
1234
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1235
+        // test output
1236
+        $this->assertEquals(
1237
+            'Every Month on the First Sunday, Saturday for the entire day',
1238
+            $this->service->generateWhenString($eventReader)
1239
+        );
1240
+
1241
+        /** test relative entire day event with every Month interval on the 1st Saturday, Sunday and conclusion*/
1242
+        $vCalendar = clone $this->vCalendar2;
1243
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;UNTIL=20241231T080000Z;');
1244
+        // construct event reader
1245
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1246
+        // test output
1247
+        $this->assertEquals(
1248
+            'Every Month on the First Sunday, Saturday for the entire day until December 31, 2024',
1249
+            $this->service->generateWhenString($eventReader)
1250
+        );
1251
+
1252
+        /** test relative entire day event with every 2nd Month interval on the 1st Saturday, Sunday and no conclusion*/
1253
+        $vCalendar = clone $this->vCalendar2;
1254
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;');
1255
+        // construct event reader
1256
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1257
+        // test output
1258
+        $this->assertEquals(
1259
+            'Every 2 Months on the First Sunday, Saturday for the entire day',
1260
+            $this->service->generateWhenString($eventReader)
1261
+        );
1262
+
1263
+        /** test relative entire day event with every 2nd Month interval on the 1st Saturday, Sunday and conclusion*/
1264
+        $vCalendar = clone $this->vCalendar2;
1265
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=MONTHLY;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;UNTIL=20241231T080000Z;');
1266
+        // construct event reader
1267
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1268
+        // test output
1269
+        $this->assertEquals(
1270
+            'Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024',
1271
+            $this->service->generateWhenString($eventReader)
1272
+        );
1273
+
1274
+    }
1275
+
1276
+    public function testGenerateWhenStringRecurringYearly(): void {
1277
+
1278
+        // construct l10n return maps
1279
+        $this->l10n->method('l')->willReturnCallback(
1280
+            function ($v1, $v2, $v3) {
1281
+                return match (true) {
1282
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
1283
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
1284
+                    $v1 === 'date' && $v2 == (new \DateTime('20260731T040000', (new \DateTimeZone('UTC')))) && $v3 == ['width' => 'long'] => 'July 31, 2026'
1285
+                };
1286
+            }
1287
+        );
1288
+        $this->l10n->method('t')->willReturnMap([
1289
+            ['Every Year in %1$s on the %2$s for the entire day', ['July', '1st'], 'Every Year in July on the 1st for the entire day'],
1290
+            ['Every Year in %1$s on the %2$s for the entire day until %3$s', ['July', '1st', 'July 31, 2026'], 'Every Year in July on the 1st for the entire day until July 31, 2026'],
1291
+            ['Every Year in %1$s on the %2$s between %3$s - %4$s', ['July', '1st', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)'],
1292
+            ['Every Year in %1$s on the %2$s between %3$s - %4$s until %5$s', ['July', '1st', '8:00 AM', '9:00 AM (America/Toronto)', 'July 31, 2026'], 'Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026'],
1293
+            ['Every %1$d Years in %2$s on the %3$s for the entire day', [2, 'July', '1st'], 'Every 2 Years in July on the 1st for the entire day'],
1294
+            ['Every %1$d Years in %2$s on the %3$s for the entire day until %4$s', [2, 'July', '1st', 'July 31, 2026'], 'Every 2 Years in July on the 1st for the entire day until July 31, 2026'],
1295
+            ['Every %1$d Years in %2$s on the %3$s between %4$s - %5$s', [2, 'July', '1st', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)'],
1296
+            ['Every %1$d Years in %2$s on the %3$s between %4$s - %5$s until %6$s', [2, 'July', '1st', '8:00 AM', '9:00 AM (America/Toronto)', 'July 31, 2026'], 'Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026'],
1297
+            ['Every Year in %1$s on the %2$s for the entire day', ['July', 'First Sunday, Saturday'], 'Every Year in July on the First Sunday, Saturday for the entire day'],
1298
+            ['Every Year in %1$s on the %2$s for the entire day until %3$s', ['July', 'First Sunday, Saturday', 'July 31, 2026'], 'Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026'],
1299
+            ['Every Year in %1$s on the %2$s between %3$s - %4$s', ['July', 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)'],
1300
+            ['Every Year in %1$s on the %2$s between %3$s - %4$s until %5$s', ['July', 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)', 'July 31, 2026'], 'Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026'],
1301
+            ['Every %1$d Years in %2$s on the %3$s for the entire day', [2, 'July', 'First Sunday, Saturday'], 'Every 2 Years in July on the First Sunday, Saturday for the entire day'],
1302
+            ['Every %1$d Years in %2$s on the %3$s for the entire day until %4$s', [2, 'July', 'First Sunday, Saturday', 'July 31, 2026'], 'Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026'],
1303
+            ['Every %1$d Years in %2$s on the %3$s between %4$s - %5$s', [2, 'July', 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)'], 'Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)'],
1304
+            ['Every %1$d Years in %2$s on the %3$s between %4$s - %5$s until %6$s', [2, 'July', 'First Sunday, Saturday', '8:00 AM', '9:00 AM (America/Toronto)', 'July 31, 2026'], 'Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026'],
1305
+            ['Could not generate event recurrence statement', [], 'Could not generate event recurrence statement'],
1306
+            ['July', [], 'July'],
1307
+            ['Saturday', [], 'Saturday'],
1308
+            ['Sunday', [], 'Sunday'],
1309
+            ['First', [], 'First'],
1310
+        ]);
1311
+
1312
+        /** test absolute partial day event with every year interval on July 1 and no conclusion*/
1313
+        $vCalendar = clone $this->vCalendar1a;
1314
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;');
1315
+        // construct event reader
1316
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1317
+        // test output
1318
+        $this->assertEquals(
1319
+            'Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)',
1320
+            $this->service->generateWhenString($eventReader)
1321
+        );
1322
+
1323
+        /** test absolute partial day event with every year interval on July 1 and conclusion*/
1324
+        $vCalendar = clone $this->vCalendar1a;
1325
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;UNTIL=20260731T040000Z');
1326
+        // construct event reader
1327
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1328
+        // test output
1329
+        $this->assertEquals(
1330
+            'Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026',
1331
+            $this->service->generateWhenString($eventReader)
1332
+        );
1333
+
1334
+        /** test absolute partial day event with every 2nd year interval on July 1 and no conclusion*/
1335
+        $vCalendar = clone $this->vCalendar1a;
1336
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;INTERVAL=2;');
1337
+        // construct event reader
1338
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1339
+        // test output
1340
+        $this->assertEquals(
1341
+            'Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)',
1342
+            $this->service->generateWhenString($eventReader)
1343
+        );
1344
+
1345
+        /** test absolute partial day event with every 2nd year interval on July 1 and conclusion*/
1346
+        $vCalendar = clone $this->vCalendar1a;
1347
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;INTERVAL=2;UNTIL=20260731T040000Z;');
1348
+        // construct event reader
1349
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1350
+        // test output
1351
+        $this->assertEquals(
1352
+            'Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026',
1353
+            $this->service->generateWhenString($eventReader)
1354
+        );
1355
+
1356
+        /** test absolute entire day event with every year interval on July 1 and no conclusion*/
1357
+        $vCalendar = clone $this->vCalendar2;
1358
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;');
1359
+        // construct event reader
1360
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1361
+        // test output
1362
+        $this->assertEquals(
1363
+            'Every Year in July on the 1st for the entire day',
1364
+            $this->service->generateWhenString($eventReader)
1365
+        );
1366
+
1367
+        /** test absolute entire day event with every year interval on July 1 and conclusion*/
1368
+        $vCalendar = clone $this->vCalendar2;
1369
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;UNTIL=20260731T040000Z;');
1370
+        // construct event reader
1371
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1372
+        // test output
1373
+        $this->assertEquals(
1374
+            'Every Year in July on the 1st for the entire day until July 31, 2026',
1375
+            $this->service->generateWhenString($eventReader)
1376
+        );
1377
+
1378
+        /** test absolute entire day event with every 2nd year interval on July 1 and no conclusion*/
1379
+        $vCalendar = clone $this->vCalendar2;
1380
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;INTERVAL=2;');
1381
+        // construct event reader
1382
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1383
+        // test output
1384
+        $this->assertEquals(
1385
+            'Every 2 Years in July on the 1st for the entire day',
1386
+            $this->service->generateWhenString($eventReader)
1387
+        );
1388
+
1389
+        /** test absolute entire day event with every 2nd year interval on July 1 and conclusion*/
1390
+        $vCalendar = clone $this->vCalendar2;
1391
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;INTERVAL=2;UNTIL=20260731T040000Z;');
1392
+        // construct event reader
1393
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1394
+        // test output
1395
+        $this->assertEquals(
1396
+            'Every 2 Years in July on the 1st for the entire day until July 31, 2026',
1397
+            $this->service->generateWhenString($eventReader)
1398
+        );
1399
+
1400
+        /** test relative partial day event with every year interval on the 1st Saturday, Sunday in July and no conclusion*/
1401
+        $vCalendar = clone $this->vCalendar1a;
1402
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;');
1403
+        // construct event reader
1404
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1405
+        // test output
1406
+        $this->assertEquals(
1407
+            'Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)',
1408
+            $this->service->generateWhenString($eventReader)
1409
+        );
1410
+
1411
+        /** test relative partial day event with every year interval on the 1st Saturday, Sunday in July and conclusion*/
1412
+        $vCalendar = clone $this->vCalendar1a;
1413
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;UNTIL=20260731T040000Z;');
1414
+        // construct event reader
1415
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1416
+        // test output
1417
+        $this->assertEquals(
1418
+            'Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026',
1419
+            $this->service->generateWhenString($eventReader)
1420
+        );
1421
+
1422
+        /** test relative partial day event with every 2nd year interval on the 1st Saturday, Sunday in July and no conclusion*/
1423
+        $vCalendar = clone $this->vCalendar1a;
1424
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;');
1425
+        // construct event reader
1426
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1427
+        // test output
1428
+        $this->assertEquals(
1429
+            'Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)',
1430
+            $this->service->generateWhenString($eventReader)
1431
+        );
1432
+
1433
+        /** test relative partial day event with every 2nd year interval on the 1st Saturday, Sunday in July and conclusion*/
1434
+        $vCalendar = clone $this->vCalendar1a;
1435
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;UNTIL=20260731T040000Z;');
1436
+        // construct event reader
1437
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1438
+        // test output
1439
+        $this->assertEquals(
1440
+            'Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026',
1441
+            $this->service->generateWhenString($eventReader)
1442
+        );
1443
+
1444
+        /** test relative entire day event with every year interval on the 1st Saturday, Sunday in July and no conclusion*/
1445
+        $vCalendar = clone $this->vCalendar2;
1446
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;');
1447
+        // construct event reader
1448
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1449
+        // test output
1450
+        $this->assertEquals(
1451
+            'Every Year in July on the First Sunday, Saturday for the entire day',
1452
+            $this->service->generateWhenString($eventReader)
1453
+        );
1454
+
1455
+        /** test relative entire day event with every year interval on the 1st Saturday, Sunday in July and conclusion*/
1456
+        $vCalendar = clone $this->vCalendar2;
1457
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;UNTIL=20260731T040000Z;');
1458
+        // construct event reader
1459
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1460
+        // test output
1461
+        $this->assertEquals(
1462
+            'Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026',
1463
+            $this->service->generateWhenString($eventReader)
1464
+        );
1465
+
1466
+        /** test relative entire day event with every 2nd year interval on the 1st Saturday, Sunday in July and no conclusion*/
1467
+        $vCalendar = clone $this->vCalendar2;
1468
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;');
1469
+        // construct event reader
1470
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1471
+        // test output
1472
+        $this->assertEquals(
1473
+            'Every 2 Years in July on the First Sunday, Saturday for the entire day',
1474
+            $this->service->generateWhenString($eventReader)
1475
+        );
1476
+
1477
+        /** test relative entire day event with every 2nd year interval on the 1st Saturday, Sunday in July and conclusion*/
1478
+        $vCalendar = clone $this->vCalendar2;
1479
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=YEARLY;BYMONTH=7;BYDAY=SU,SA;BYSETPOS=1;INTERVAL=2;UNTIL=20260731T040000Z;');
1480
+        // construct event reader
1481
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1482
+        // test output
1483
+        $this->assertEquals(
1484
+            'Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026',
1485
+            $this->service->generateWhenString($eventReader)
1486
+        );
1487
+
1488
+    }
1489
+
1490
+    public function testGenerateWhenStringRecurringFixed(): void {
1491
+
1492
+        // construct l10n return maps
1493
+        $this->l10n->method('l')->willReturnCallback(
1494
+            function ($v1, $v2, $v3) {
1495
+                return match (true) {
1496
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '8:00 AM',
1497
+                    $v1 === 'time' && $v2 == (new \DateTime('20240701T090000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'short'] => '9:00 AM',
1498
+                    $v1 === 'date' && $v2 == (new \DateTime('20240713T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 13, 2024'
1499
+                };
1500
+            }
1501
+        );
1502
+        $this->l10n->method('t')->willReturnMap([
1503
+            ['On specific dates for the entire day until %1$s', ['July 13, 2024'], 'On specific dates for the entire day until July 13, 2024'],
1504
+            ['On specific dates between %1$s - %2$s until %3$s', ['8:00 AM', '9:00 AM (America/Toronto)', 'July 13, 2024'], 'On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024'],
1505
+        ]);
1506
+
1507
+        /** test partial day event with every day interval and conclusion*/
1508
+        $vCalendar = clone $this->vCalendar1a;
1509
+        $vCalendar->VEVENT[0]->add('RDATE', '20240703T080000,20240709T080000,20240713T080000');
1510
+        // construct event reader
1511
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1512
+        // test output
1513
+        $this->assertEquals(
1514
+            'On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024',
1515
+            $this->service->generateWhenString($eventReader)
1516
+        );
1517
+
1518
+        /** test entire day event with every day interval and no conclusion*/
1519
+        $vCalendar = clone $this->vCalendar2;
1520
+        $vCalendar->VEVENT[0]->add('RDATE', '20240703T080000,20240709T080000,20240713T080000');
1521
+        // construct event reader
1522
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1523
+        // test output
1524
+        $this->assertEquals(
1525
+            'On specific dates for the entire day until July 13, 2024',
1526
+            $this->service->generateWhenString($eventReader)
1527
+        );
1528
+
1529
+    }
1530
+
1531
+    public function testGenerateOccurringStringWithRrule(): void {
1532
+
1533
+        // construct l10n return(s)
1534
+        $this->l10n->method('l')->willReturnCallback(
1535
+            function ($v1, $v2, $v3) {
1536
+                return match (true) {
1537
+                    $v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 1, 2024',
1538
+                    $v1 === 'date' && $v2 == (new \DateTime('20240703T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 3, 2024',
1539
+                    $v1 === 'date' && $v2 == (new \DateTime('20240705T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 5, 2024'
1540
+                };
1541
+            }
1542
+        );
1543
+        $this->l10n->method('n')->willReturnMap([
1544
+            // singular
1545
+            [
1546
+                'In %n day on %1$s',
1547
+                'In %n days on %1$s',
1548
+                1,
1549
+                ['July 1, 2024'],
1550
+                'In 1 day on July 1, 2024'
1551
+            ],
1552
+            [
1553
+                'In %n day on %1$s then on %2$s',
1554
+                'In %n days on %1$s then on %2$s',
1555
+                1,
1556
+                ['July 1, 2024', 'July 3, 2024'],
1557
+                'In 1 day on July 1, 2024 then on July 3, 2024'
1558
+            ],
1559
+            [
1560
+                'In %n day on %1$s then on %2$s and %3$s',
1561
+                'In %n days on %1$s then on %2$s and %3$s',
1562
+                1,
1563
+                ['July 1, 2024', 'July 3, 2024', 'July 5, 2024'],
1564
+                'In 1 day on July 1, 2024 then on July 3, 2024 and July 5, 2024'
1565
+            ],
1566
+            // plural
1567
+            [
1568
+                'In %n day on %1$s',
1569
+                'In %n days on %1$s',
1570
+                2,
1571
+                ['July 1, 2024'],
1572
+                'In 2 days on July 1, 2024'
1573
+            ],
1574
+            [
1575
+                'In %n day on %1$s then on %2$s',
1576
+                'In %n days on %1$s then on %2$s',
1577
+                2,
1578
+                ['July 1, 2024', 'July 3, 2024'],
1579
+                'In 2 days on July 1, 2024 then on July 3, 2024'
1580
+            ],
1581
+            [
1582
+                'In %n day on %1$s then on %2$s and %3$s',
1583
+                'In %n days on %1$s then on %2$s and %3$s',
1584
+                2,
1585
+                ['July 1, 2024', 'July 3, 2024', 'July 5, 2024'],
1586
+                'In 2 days on July 1, 2024 then on July 3, 2024 and July 5, 2024'
1587
+            ],
1588
+        ]);
1589
+
1590
+        // construct time factory return(s)
1591
+        $this->timeFactory->method('getDateTime')->willReturnOnConsecutiveCalls(
1592
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1593
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1594
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1595
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1596
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1597
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1598
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1599
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1600
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1601
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1602
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1603
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1604
+        );
1605
+
1606
+        /** test patrial day recurring event in 1 day with single occurrence remaining */
1607
+        $vCalendar = clone $this->vCalendar1a;
1608
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
1609
+        // construct event reader
1610
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1611
+        // test output
1612
+        $this->assertEquals(
1613
+            'In 1 day on July 1, 2024',
1614
+            $this->service->generateOccurringString($eventReader)
1615
+        );
1616
+
1617
+        /** test patrial day recurring event in 1 day with two occurrences remaining */
1618
+        $vCalendar = clone $this->vCalendar1a;
1619
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
1620
+        // construct event reader
1621
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1622
+        // test output
1623
+        $this->assertEquals(
1624
+            'In 1 day on July 1, 2024 then on July 3, 2024',
1625
+            $this->service->generateOccurringString($eventReader)
1626
+        );
1627
+
1628
+        /** test patrial day recurring event in 1 day with three occurrences remaining */
1629
+        $vCalendar = clone $this->vCalendar1a;
1630
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
1631
+        // construct event reader
1632
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1633
+        // test output
1634
+        $this->assertEquals(
1635
+            'In 1 day on July 1, 2024 then on July 3, 2024 and July 5, 2024',
1636
+            $this->service->generateOccurringString($eventReader)
1637
+        );
1638
+
1639
+        /** test patrial day recurring event in 2 days with single occurrence remaining */
1640
+        $vCalendar = clone $this->vCalendar1a;
1641
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
1642
+        // construct event reader
1643
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1644
+        // test output
1645
+        $this->assertEquals(
1646
+            'In 2 days on July 1, 2024',
1647
+            $this->service->generateOccurringString($eventReader)
1648
+        );
1649
+
1650
+        /** test patrial day recurring event in 2 days with two occurrences remaining */
1651
+        $vCalendar = clone $this->vCalendar1a;
1652
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
1653
+        // construct event reader
1654
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1655
+        // test output
1656
+        $this->assertEquals(
1657
+            'In 2 days on July 1, 2024 then on July 3, 2024',
1658
+            $this->service->generateOccurringString($eventReader)
1659
+        );
1660
+
1661
+        /** test patrial day recurring event in 2 days with three occurrences remaining */
1662
+        $vCalendar = clone $this->vCalendar1a;
1663
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
1664
+        // construct event reader
1665
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1666
+        // test output
1667
+        $this->assertEquals(
1668
+            'In 2 days on July 1, 2024 then on July 3, 2024 and July 5, 2024',
1669
+            $this->service->generateOccurringString($eventReader)
1670
+        );
1671
+    }
1672
+
1673
+    public function testGenerateOccurringStringWithRdate(): void {
1674
+
1675
+        // construct l10n return(s)
1676
+        $this->l10n->method('l')->willReturnCallback(
1677
+            function ($v1, $v2, $v3) {
1678
+                return match (true) {
1679
+                    $v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 1, 2024',
1680
+                    $v1 === 'date' && $v2 == (new \DateTime('20240703T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 3, 2024',
1681
+                    $v1 === 'date' && $v2 == (new \DateTime('20240705T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 5, 2024'
1682
+                };
1683
+            }
1684
+        );
1685
+        $this->l10n->method('n')->willReturnMap([
1686
+            // singular
1687
+            [
1688
+                'In %n day on %1$s',
1689
+                'In %n days on %1$s',
1690
+                1,
1691
+                ['July 1, 2024'],
1692
+                'In 1 day on July 1, 2024'
1693
+            ],
1694
+            [
1695
+                'In %n day on %1$s then on %2$s',
1696
+                'In %n days on %1$s then on %2$s',
1697
+                1,
1698
+                ['July 1, 2024', 'July 3, 2024'],
1699
+                'In 1 day on July 1, 2024 then on July 3, 2024'
1700
+            ],
1701
+            [
1702
+                'In %n day on %1$s then on %2$s and %3$s',
1703
+                'In %n days on %1$s then on %2$s and %3$s',
1704
+                1,
1705
+                ['July 1, 2024', 'July 3, 2024', 'July 5, 2024'],
1706
+                'In 1 day on July 1, 2024 then on July 3, 2024 and July 5, 2024'
1707
+            ],
1708
+            // plural
1709
+            [
1710
+                'In %n day on %1$s',
1711
+                'In %n days on %1$s',
1712
+                2,
1713
+                ['July 1, 2024'],
1714
+                'In 2 days on July 1, 2024'
1715
+            ],
1716
+            [
1717
+                'In %n day on %1$s then on %2$s',
1718
+                'In %n days on %1$s then on %2$s',
1719
+                2,
1720
+                ['July 1, 2024', 'July 3, 2024'],
1721
+                'In 2 days on July 1, 2024 then on July 3, 2024'
1722
+            ],
1723
+            [
1724
+                'In %n day on %1$s then on %2$s and %3$s',
1725
+                'In %n days on %1$s then on %2$s and %3$s',
1726
+                2,
1727
+                ['July 1, 2024', 'July 3, 2024', 'July 5, 2024'],
1728
+                'In 2 days on July 1, 2024 then on July 3, 2024 and July 5, 2024'
1729
+            ],
1730
+        ]);
1731
+
1732
+        // construct time factory return(s)
1733
+        $this->timeFactory->method('getDateTime')->willReturnOnConsecutiveCalls(
1734
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1735
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1736
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1737
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1738
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1739
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1740
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1741
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1742
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1743
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1744
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1745
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1746
+        );
1747
+
1748
+        /** test patrial day recurring event in 1 day with single occurrence remaining */
1749
+        $vCalendar = clone $this->vCalendar1a;
1750
+        $vCalendar->VEVENT[0]->add('RDATE', '20240701T080000');
1751
+        // construct event reader
1752
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1753
+        // test output
1754
+        $this->assertEquals(
1755
+            'In 1 day on July 1, 2024',
1756
+            $this->service->generateOccurringString($eventReader),
1757
+            'test patrial day recurring event in 1 day with single occurrence remaining'
1758
+        );
1759
+
1760
+        /** test patrial day recurring event in 1 day with two occurrences remaining */
1761
+        $vCalendar = clone $this->vCalendar1a;
1762
+        $vCalendar->VEVENT[0]->add('RDATE', '20240701T080000,20240703T080000');
1763
+        // construct event reader
1764
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1765
+        // test output
1766
+        $this->assertEquals(
1767
+            'In 1 day on July 1, 2024 then on July 3, 2024',
1768
+            $this->service->generateOccurringString($eventReader),
1769
+            'test patrial day recurring event in 1 day with two occurrences remaining'
1770
+        );
1771
+
1772
+        /** test patrial day recurring event in 1 day with three occurrences remaining */
1773
+        $vCalendar = clone $this->vCalendar1a;
1774
+        $vCalendar->VEVENT[0]->add('RDATE', '20240701T080000,20240703T080000,20240705T080000');
1775
+        // construct event reader
1776
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1777
+        // test output
1778
+        $this->assertEquals(
1779
+            'In 1 day on July 1, 2024 then on July 3, 2024 and July 5, 2024',
1780
+            $this->service->generateOccurringString($eventReader),
1781
+            ''
1782
+        );
1783
+
1784
+        /** test patrial day recurring event in 2 days with single occurrences remaining */
1785
+        $vCalendar = clone $this->vCalendar1a;
1786
+        $vCalendar->VEVENT[0]->add('RDATE', '20240701T080000');
1787
+        // construct event reader
1788
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1789
+        // test output
1790
+        $this->assertEquals(
1791
+            'In 2 days on July 1, 2024',
1792
+            $this->service->generateOccurringString($eventReader),
1793
+            ''
1794
+        );
1795
+
1796
+        /** test patrial day recurring event in 2 days with two occurrences remaining */
1797
+        $vCalendar = clone $this->vCalendar1a;
1798
+        $vCalendar->VEVENT[0]->add('RDATE', '20240701T080000');
1799
+        $vCalendar->VEVENT[0]->add('RDATE', '20240703T080000');
1800
+        // construct event reader
1801
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1802
+        // test output
1803
+        $this->assertEquals(
1804
+            'In 2 days on July 1, 2024 then on July 3, 2024',
1805
+            $this->service->generateOccurringString($eventReader),
1806
+            ''
1807
+        );
1808
+
1809
+        /** test patrial day recurring event in 2 days with three occurrences remaining */
1810
+        $vCalendar = clone $this->vCalendar1a;
1811
+        $vCalendar->VEVENT[0]->add('RDATE', '20240701T080000');
1812
+        $vCalendar->VEVENT[0]->add('RDATE', '20240703T080000');
1813
+        $vCalendar->VEVENT[0]->add('RDATE', '20240705T080000');
1814
+        // construct event reader
1815
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1816
+        // test output
1817
+        $this->assertEquals(
1818
+            'In 2 days on July 1, 2024 then on July 3, 2024 and July 5, 2024',
1819
+            $this->service->generateOccurringString($eventReader),
1820
+            'test patrial day recurring event in 2 days with three occurrences remaining'
1821
+        );
1822
+    }
1823
+
1824
+    public function testGenerateOccurringStringWithOneExdate(): void {
1825
+
1826
+        // construct l10n return(s)
1827
+        $this->l10n->method('l')->willReturnCallback(
1828
+            function ($v1, $v2, $v3) {
1829
+                return match (true) {
1830
+                    $v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 1, 2024',
1831
+                    $v1 === 'date' && $v2 == (new \DateTime('20240705T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 5, 2024',
1832
+                    $v1 === 'date' && $v2 == (new \DateTime('20240707T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 7, 2024'
1833
+                };
1834
+            }
1835
+        );
1836
+        $this->l10n->method('n')->willReturnMap([
1837
+            // singular
1838
+            [
1839
+                'In %n day on %1$s',
1840
+                'In %n days on %1$s',
1841
+                1,
1842
+                ['July 1, 2024'],
1843
+                'In 1 day on July 1, 2024'
1844
+            ],
1845
+            [
1846
+                'In %n day on %1$s then on %2$s',
1847
+                'In %n days on %1$s then on %2$s',
1848
+                1,
1849
+                ['July 1, 2024', 'July 5, 2024'],
1850
+                'In 1 day on July 1, 2024 then on July 5, 2024'
1851
+            ],
1852
+            [
1853
+                'In %n day on %1$s then on %2$s and %3$s',
1854
+                'In %n days on %1$s then on %2$s and %3$s',
1855
+                1,
1856
+                ['July 1, 2024', 'July 5, 2024', 'July 7, 2024'],
1857
+                'In 1 day on July 1, 2024 then on July 5, 2024 and July 7, 2024'
1858
+            ],
1859
+            // plural
1860
+            [
1861
+                'In %n day on %1$s',
1862
+                'In %n days on %1$s',
1863
+                2,
1864
+                ['July 1, 2024'],
1865
+                'In 2 days on July 1, 2024'
1866
+            ],
1867
+            [
1868
+                'In %n day on %1$s then on %2$s',
1869
+                'In %n days on %1$s then on %2$s',
1870
+                2,
1871
+                ['July 1, 2024', 'July 5, 2024'],
1872
+                'In 2 days on July 1, 2024 then on July 5, 2024'
1873
+            ],
1874
+            [
1875
+                'In %n day on %1$s then on %2$s and %3$s',
1876
+                'In %n days on %1$s then on %2$s and %3$s',
1877
+                2,
1878
+                ['July 1, 2024', 'July 5, 2024', 'July 7, 2024'],
1879
+                'In 2 days on July 1, 2024 then on July 5, 2024 and July 7, 2024'
1880
+            ],
1881
+        ]);
1882
+
1883
+        // construct time factory return(s)
1884
+        $this->timeFactory->method('getDateTime')->willReturnOnConsecutiveCalls(
1885
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1886
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1887
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1888
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1889
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1890
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1891
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1892
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
1893
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1894
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1895
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1896
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1897
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1898
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1899
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1900
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
1901
+        );
1902
+
1903
+        /** test patrial day recurring event in 1 day with single occurrence remaining and one exception */
1904
+        $vCalendar = clone $this->vCalendar1a;
1905
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
1906
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1907
+        // construct event reader
1908
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1909
+        // test output
1910
+        $this->assertEquals(
1911
+            'In 1 day on July 1, 2024',
1912
+            $this->service->generateOccurringString($eventReader),
1913
+            'test patrial day recurring event in 1 day with single occurrence remaining and one exception'
1914
+        );
1915
+
1916
+        /** test patrial day recurring event in 1 day with two occurrences remaining and one exception */
1917
+        $vCalendar = clone $this->vCalendar1a;
1918
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
1919
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1920
+        // construct event reader
1921
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1922
+        // test output
1923
+        $this->assertEquals(
1924
+            'In 1 day on July 1, 2024',
1925
+            $this->service->generateOccurringString($eventReader),
1926
+            'test patrial day recurring event in 1 day with two occurrences remaining and one exception'
1927
+        );
1928
+
1929
+        /** test patrial day recurring event in 1 day with three occurrences remaining and one exception */
1930
+        $vCalendar = clone $this->vCalendar1a;
1931
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
1932
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1933
+        // construct event reader
1934
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1935
+        // test output
1936
+        $this->assertEquals(
1937
+            'In 1 day on July 1, 2024 then on July 5, 2024',
1938
+            $this->service->generateOccurringString($eventReader),
1939
+            'test patrial day recurring event in 1 day with three occurrences remaining and one exception'
1940
+        );
1941
+
1942
+        /** test patrial day recurring event in 1 day with four occurrences remaining and one exception */
1943
+        $vCalendar = clone $this->vCalendar1a;
1944
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=4');
1945
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1946
+        // construct event reader
1947
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1948
+        // test output
1949
+        $this->assertEquals(
1950
+            'In 1 day on July 1, 2024 then on July 5, 2024 and July 7, 2024',
1951
+            $this->service->generateOccurringString($eventReader),
1952
+            'test patrial day recurring event in 1 day with four occurrences remaining and one exception'
1953
+        );
1954
+
1955
+        /** test patrial day recurring event in 2 days with single occurrences remaining and one exception */
1956
+        $vCalendar = clone $this->vCalendar1a;
1957
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
1958
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1959
+        // construct event reader
1960
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1961
+        // test output
1962
+        $this->assertEquals(
1963
+            'In 2 days on July 1, 2024',
1964
+            $this->service->generateOccurringString($eventReader),
1965
+            'test patrial day recurring event in 2 days with single occurrences remaining and one exception'
1966
+        );
1967
+
1968
+        /** test patrial day recurring event in 2 days with two occurrences remaining and one exception */
1969
+        $vCalendar = clone $this->vCalendar1a;
1970
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
1971
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1972
+        // construct event reader
1973
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1974
+        // test output
1975
+        $this->assertEquals(
1976
+            'In 2 days on July 1, 2024',
1977
+            $this->service->generateOccurringString($eventReader),
1978
+            'test patrial day recurring event in 2 days with two occurrences remaining and one exception'
1979
+        );
1980
+
1981
+        /** test patrial day recurring event in 2 days with three occurrences remaining and one exception */
1982
+        $vCalendar = clone $this->vCalendar1a;
1983
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
1984
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1985
+        // construct event reader
1986
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
1987
+        // test output
1988
+        $this->assertEquals(
1989
+            'In 2 days on July 1, 2024 then on July 5, 2024',
1990
+            $this->service->generateOccurringString($eventReader),
1991
+            'test patrial day recurring event in 2 days with three occurrences remaining and one exception'
1992
+        );
1993
+
1994
+        /** test patrial day recurring event in 2 days with four occurrences remaining and one exception */
1995
+        $vCalendar = clone $this->vCalendar1a;
1996
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=4');
1997
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
1998
+        // construct event reader
1999
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2000
+        // test output
2001
+        $this->assertEquals(
2002
+            'In 2 days on July 1, 2024 then on July 5, 2024 and July 7, 2024',
2003
+            $this->service->generateOccurringString($eventReader),
2004
+            'test patrial day recurring event in 2 days with four occurrences remaining and one exception'
2005
+        );
2006
+    }
2007
+
2008
+    public function testGenerateOccurringStringWithTwoExdate(): void {
2009
+
2010
+        // construct l10n return(s)
2011
+        $this->l10n->method('l')->willReturnCallback(
2012
+            function ($v1, $v2, $v3) {
2013
+                return match (true) {
2014
+                    $v1 === 'date' && $v2 == (new \DateTime('20240701T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 1, 2024',
2015
+                    $v1 === 'date' && $v2 == (new \DateTime('20240705T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 5, 2024',
2016
+                    $v1 === 'date' && $v2 == (new \DateTime('20240709T080000', (new \DateTimeZone('America/Toronto')))) && $v3 == ['width' => 'long'] => 'July 9, 2024'
2017
+                };
2018
+            }
2019
+        );
2020
+        $this->l10n->method('n')->willReturnMap([
2021
+            // singular
2022
+            [
2023
+                'In %n day on %1$s',
2024
+                'In %n days on %1$s',
2025
+                1,
2026
+                ['July 1, 2024'],
2027
+                'In 1 day on July 1, 2024'
2028
+            ],
2029
+            [
2030
+                'In %n day on %1$s then on %2$s',
2031
+                'In %n days on %1$s then on %2$s',
2032
+                1,
2033
+                ['July 1, 2024', 'July 5, 2024'],
2034
+                'In 1 day on July 1, 2024 then on July 5, 2024'
2035
+            ],
2036
+            [
2037
+                'In %n day on %1$s then on %2$s and %3$s',
2038
+                'In %n days on %1$s then on %2$s and %3$s',
2039
+                1,
2040
+                ['July 1, 2024', 'July 5, 2024', 'July 9, 2024'],
2041
+                'In 1 day on July 1, 2024 then on July 5, 2024 and July 9, 2024'
2042
+            ],
2043
+            // plural
2044
+            [
2045
+                'In %n day on %1$s',
2046
+                'In %n days on %1$s',
2047
+                2,
2048
+                ['July 1, 2024'],
2049
+                'In 2 days on July 1, 2024'
2050
+            ],
2051
+            [
2052
+                'In %n day on %1$s then on %2$s',
2053
+                'In %n days on %1$s then on %2$s',
2054
+                2,
2055
+                ['July 1, 2024', 'July 5, 2024'],
2056
+                'In 2 days on July 1, 2024 then on July 5, 2024'
2057
+            ],
2058
+            [
2059
+                'In %n day on %1$s then on %2$s and %3$s',
2060
+                'In %n days on %1$s then on %2$s and %3$s',
2061
+                2,
2062
+                ['July 1, 2024', 'July 5, 2024', 'July 9, 2024'],
2063
+                'In 2 days on July 1, 2024 then on July 5, 2024 and July 9, 2024'
2064
+            ],
2065
+        ]);
2066
+
2067
+        // construct time factory return(s)
2068
+        $this->timeFactory->method('getDateTime')->willReturnOnConsecutiveCalls(
2069
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2070
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2071
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2072
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2073
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2074
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2075
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2076
+            (new \DateTime('20240629T170000', (new \DateTimeZone('America/Toronto')))),
2077
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2078
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2079
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2080
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2081
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2082
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2083
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2084
+            (new \DateTime('20240628T170000', (new \DateTimeZone('America/Toronto')))),
2085
+        );
2086
+
2087
+        /** test patrial day recurring event in 1 day with single occurrence remaining and two exception */
2088
+        $vCalendar = clone $this->vCalendar1a;
2089
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
2090
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2091
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2092
+        // construct event reader
2093
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2094
+        // test output
2095
+        $this->assertEquals(
2096
+            'In 1 day on July 1, 2024',
2097
+            $this->service->generateOccurringString($eventReader),
2098
+            'test patrial day recurring event in 1 day with single occurrence remaining and two exception'
2099
+        );
2100
+
2101
+        /** test patrial day recurring event in 1 day with two occurrences remaining and two exception */
2102
+        $vCalendar = clone $this->vCalendar1a;
2103
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
2104
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2105
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2106
+        // construct event reader
2107
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2108
+        // test output
2109
+        $this->assertEquals(
2110
+            'In 1 day on July 1, 2024',
2111
+            $this->service->generateOccurringString($eventReader),
2112
+            'test patrial day recurring event in 1 day with two occurrences remaining and two exception'
2113
+        );
2114
+
2115
+        /** test patrial day recurring event in 1 day with three occurrences remaining and two exception */
2116
+        $vCalendar = clone $this->vCalendar1a;
2117
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
2118
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2119
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2120
+        // construct event reader
2121
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2122
+        // test output
2123
+        $this->assertEquals(
2124
+            'In 1 day on July 1, 2024 then on July 5, 2024',
2125
+            $this->service->generateOccurringString($eventReader),
2126
+            'test patrial day recurring event in 1 day with three occurrences remaining and two exception'
2127
+        );
2128
+
2129
+        /** test patrial day recurring event in 1 day with four occurrences remaining and two exception */
2130
+        $vCalendar = clone $this->vCalendar1a;
2131
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=5');
2132
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2133
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2134
+        // construct event reader
2135
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2136
+        // test output
2137
+        $this->assertEquals(
2138
+            'In 1 day on July 1, 2024 then on July 5, 2024 and July 9, 2024',
2139
+            $this->service->generateOccurringString($eventReader),
2140
+            'test patrial day recurring event in 1 day with four occurrences remaining and two exception'
2141
+        );
2142
+
2143
+        /** test patrial day recurring event in 2 days with single occurrences remaining and two exception */
2144
+        $vCalendar = clone $this->vCalendar1a;
2145
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=1');
2146
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2147
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2148
+        // construct event reader
2149
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2150
+        // test output
2151
+        $this->assertEquals(
2152
+            'In 2 days on July 1, 2024',
2153
+            $this->service->generateOccurringString($eventReader),
2154
+            'test patrial day recurring event in 2 days with single occurrences remaining and two exception'
2155
+        );
2156
+
2157
+        /** test patrial day recurring event in 2 days with two occurrences remaining and two exception */
2158
+        $vCalendar = clone $this->vCalendar1a;
2159
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=2');
2160
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2161
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2162
+        // construct event reader
2163
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2164
+        // test output
2165
+        $this->assertEquals(
2166
+            'In 2 days on July 1, 2024',
2167
+            $this->service->generateOccurringString($eventReader),
2168
+            'test patrial day recurring event in 2 days with two occurrences remaining and two exception'
2169
+        );
2170
+
2171
+        /** test patrial day recurring event in 2 days with three occurrences remaining and two exception */
2172
+        $vCalendar = clone $this->vCalendar1a;
2173
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=3');
2174
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2175
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2176
+        // construct event reader
2177
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2178
+        // test output
2179
+        $this->assertEquals(
2180
+            'In 2 days on July 1, 2024 then on July 5, 2024',
2181
+            $this->service->generateOccurringString($eventReader),
2182
+            'test patrial day recurring event in 2 days with three occurrences remaining and two exception'
2183
+        );
2184
+
2185
+        /** test patrial day recurring event in 2 days with five occurrences remaining and two exception */
2186
+        $vCalendar = clone $this->vCalendar1a;
2187
+        $vCalendar->VEVENT[0]->add('RRULE', 'FREQ=DAILY;INTERVAL=2;COUNT=5');
2188
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240703T080000');
2189
+        $vCalendar->VEVENT[0]->add('EXDATE', '20240707T080000');
2190
+        // construct event reader
2191
+        $eventReader = new EventReader($vCalendar, $vCalendar->VEVENT[0]->UID->getValue());
2192
+        // test output
2193
+        $this->assertEquals(
2194
+            'In 2 days on July 1, 2024 then on July 5, 2024 and July 9, 2024',
2195
+            $this->service->generateOccurringString($eventReader),
2196
+            'test patrial day recurring event in 2 days with five occurrences remaining and two exception'
2197
+        );
2198
+    }
2199 2199
 
2200 2200
 }
Please login to merge, or discard this patch.