From 9ebe4b541bb9454e4c732ccfed726d166f989066 Mon Sep 17 00:00:00 2001 From: Nico Donath Date: Mon, 18 May 2026 07:55:12 +0000 Subject: [PATCH] fix(dav): show canceled/moved occurrences in iMIP emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the organizer modifies a single occurrence of a recurring event, the iMIP notification email body did not indicate which occurrence was affected (issue #60451). This adds two new rows to the invitation email when an iTip REQUEST diffs from the previous calendar state: - "Cancelled: " for each EXDATE newly added to the master VEvent that has no corresponding RECURRENCE-ID override. - "Moved: from to " (same-day) or "Moved: from to " (cross-day) for each override whose DTSTART differs from its RECURRENCE-ID (or from its previous DTSTART). The new rows reuse the existing addBodyListItem styling (italic gray label + value), matching the look of "When:", "Title:", etc. Also fixes the heading wording for single-occurrence updates: when a move adds a brand-new override VEvent, EventComparisonService cannot match it to an old VEvent and IMipPlugin previously fell through to the "would like to invite you" wording. The recipient is already on the existing series, so we now check the old VCalendar for the same UID and use the "admin updated the event" wording in that case. German translations are included for the new label and date/time format strings (de.js/de.json/de_DE.js/de_DE.json) so DE users see "Abgesagt:" / "Verschoben:" and natural prepositions ("um", "von … auf …") immediately; other languages will be picked up via Transifex. Implementation notes: - buildBodyData() now accepts optional ?VCalendar parameters so it can diff EXDATE values and sibling RECURRENCE-ID overrides against the previous calendar state. - Sabre's Property\ICalendar\DateTime::getDateTimes() returns \DateTimeImmutable, which is a sibling of \DateTime, not a subclass. Nextcloud's L10N::l() only matches \DateTime via instanceof and casts other input via (int), producing timestamp 1 and rendering as "1970-01-01 00:00:01" for any Immutable. A toMutableDateTime() helper converts before formatting. Signed-off-by: Nico Donath --- apps/dav/l10n/de.js | 6 + apps/dav/l10n/de.json | 6 + apps/dav/l10n/de_DE.js | 6 + apps/dav/l10n/de_DE.json | 6 + apps/dav/lib/CalDAV/Schedule/IMipPlugin.php | 11 +- apps/dav/lib/CalDAV/Schedule/IMipService.php | 190 +++++++++++++++++- .../unit/CalDAV/Schedule/IMipServiceTest.php | 161 +++++++++++++++ 7 files changed, 383 insertions(+), 3 deletions(-) diff --git a/apps/dav/l10n/de.js b/apps/dav/l10n/de.js index 7ff8e705d73c7..f751c5527ca1c 100644 --- a/apps/dav/l10n/de.js +++ b/apps/dav/l10n/de.js @@ -145,6 +145,12 @@ OC.L10N.register( "_In %n year on %1$s then on %2$s and %3$s_::_In %n years on %1$s then on %2$s and %3$s_" : ["In %n Jahr am %1$s danach am %2$s und %3$s","In %n Jahren am %1$s danach am %2$s und %3$s"], "Could not generate next recurrence statement" : "Angabe für nächste Wiederholung konnte nicht erzeugt werden.", "Cancelled: %1$s" : "Abgesagt: %1$s", + "Cancelled:" : "Abgesagt:", + "Moved:" : "Verschoben:", + "%1$s at %2$s" : "%1$s um %2$s", + "%1$s from %2$s to %3$s" : "%1$s von %2$s auf %3$s", + "from %1$s to %2$s" : "von %1$s auf %2$s", + "from %1$s %2$s to %3$s %4$s" : "von %1$s %2$s auf %3$s %4$s", "\"%1$s\" has been canceled" : "\"%1$s\" wurde abgesagt.", "Re: %1$s" : "Re: %1$s", "%1$s has accepted your invitation" : "%1$s hat deine Einladung angenommen.", diff --git a/apps/dav/l10n/de.json b/apps/dav/l10n/de.json index a661934310c7f..be589db693df9 100644 --- a/apps/dav/l10n/de.json +++ b/apps/dav/l10n/de.json @@ -143,6 +143,12 @@ "_In %n year on %1$s then on %2$s and %3$s_::_In %n years on %1$s then on %2$s and %3$s_" : ["In %n Jahr am %1$s danach am %2$s und %3$s","In %n Jahren am %1$s danach am %2$s und %3$s"], "Could not generate next recurrence statement" : "Angabe für nächste Wiederholung konnte nicht erzeugt werden.", "Cancelled: %1$s" : "Abgesagt: %1$s", + "Cancelled:" : "Abgesagt:", + "Moved:" : "Verschoben:", + "%1$s at %2$s" : "%1$s um %2$s", + "%1$s from %2$s to %3$s" : "%1$s von %2$s auf %3$s", + "from %1$s to %2$s" : "von %1$s auf %2$s", + "from %1$s %2$s to %3$s %4$s" : "von %1$s %2$s auf %3$s %4$s", "\"%1$s\" has been canceled" : "\"%1$s\" wurde abgesagt.", "Re: %1$s" : "Re: %1$s", "%1$s has accepted your invitation" : "%1$s hat deine Einladung angenommen.", diff --git a/apps/dav/l10n/de_DE.js b/apps/dav/l10n/de_DE.js index c3477216ef927..0ab592fef04f1 100644 --- a/apps/dav/l10n/de_DE.js +++ b/apps/dav/l10n/de_DE.js @@ -145,6 +145,12 @@ OC.L10N.register( "_In %n year on %1$s then on %2$s and %3$s_::_In %n years on %1$s then on %2$s and %3$s_" : ["In %n Jahr am %1$s danach am %2$s und %3$s","In %n Jahren am %1$s danach am %2$s und %3$s"], "Could not generate next recurrence statement" : "Nächste Wiederholungsangabe konnte nicht erzeugt werden", "Cancelled: %1$s" : "Abgesagt: %1$s", + "Cancelled:" : "Abgesagt:", + "Moved:" : "Verschoben:", + "%1$s at %2$s" : "%1$s um %2$s", + "%1$s from %2$s to %3$s" : "%1$s von %2$s auf %3$s", + "from %1$s to %2$s" : "von %1$s auf %2$s", + "from %1$s %2$s to %3$s %4$s" : "von %1$s %2$s auf %3$s %4$s", "\"%1$s\" has been canceled" : "\"%1$s\" wurde abgesagt.", "Re: %1$s" : "Re: %1$s", "%1$s has accepted your invitation" : "%1$s hat Ihre Einladung angenommen", diff --git a/apps/dav/l10n/de_DE.json b/apps/dav/l10n/de_DE.json index 24ee7575d2f2c..2bc5d24898e03 100644 --- a/apps/dav/l10n/de_DE.json +++ b/apps/dav/l10n/de_DE.json @@ -143,6 +143,12 @@ "_In %n year on %1$s then on %2$s and %3$s_::_In %n years on %1$s then on %2$s and %3$s_" : ["In %n Jahr am %1$s danach am %2$s und %3$s","In %n Jahren am %1$s danach am %2$s und %3$s"], "Could not generate next recurrence statement" : "Nächste Wiederholungsangabe konnte nicht erzeugt werden", "Cancelled: %1$s" : "Abgesagt: %1$s", + "Cancelled:" : "Abgesagt:", + "Moved:" : "Verschoben:", + "%1$s at %2$s" : "%1$s um %2$s", + "%1$s from %2$s to %3$s" : "%1$s von %2$s auf %3$s", + "from %1$s to %2$s" : "von %1$s auf %2$s", + "from %1$s %2$s to %3$s %4$s" : "von %1$s %2$s auf %3$s %4$s", "\"%1$s\" has been canceled" : "\"%1$s\" wurde abgesagt.", "Re: %1$s" : "Re: %1$s", "%1$s has accepted your invitation" : "%1$s hat Ihre Einladung angenommen", diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 01e4eb1f393a1..167c041d40a15 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -150,7 +150,14 @@ public function schedule(Message $iTipMessage) { $vEvent = array_pop($modified['new']); /** @var VEvent $oldVevent */ $oldVevent = !empty($modified['old']) && is_array($modified['old']) ? array_pop($modified['old']) : null; - $isModified = isset($oldVevent); + // Treat as a modification (use "updated" wording) if either: + // - the EventComparisonService matched an old VEvent, or + // - the old VCalendar already contains a VEvent with the same UID (e.g. a + // single-occurrence move adds a brand-new override with no old counterpart, + // but the series itself is not new to the attendee). + $isExistingSeries = $oldEvents !== null && isset($vEvent->UID) + && $this->imipService->findMasterEvent($oldEvents, (string)$vEvent->UID) !== null; + $isModified = isset($oldVevent) || $isExistingSeries; // No changed events after all - this shouldn't happen if there is significant change yet here we are // The scheduling status is debatable @@ -211,7 +218,7 @@ public function schedule(Message $iTipMessage) { break; default: $method = self::METHOD_REQUEST; - $data = $this->imipService->buildBodyData($vEvent, $oldVevent); + $data = $this->imipService->buildBodyData($vEvent, $oldVevent, $newEvents, $oldEvents); break; } diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php index 5fd20a7684352..4b7602b73a599 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipService.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php @@ -152,9 +152,11 @@ private function linkify(?string $url): ?string { /** * @param VEvent $vEvent * @param VEvent|null $oldVEvent + * @param VCalendar|null $newVCalendar full new VCalendar, used to find sibling overrides/EXDATEs + * @param VCalendar|null $oldVCalendar full old VCalendar, used to diff EXDATEs and overrides * @return array */ - public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array { + public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent, ?VCalendar $newVCalendar = null, ?VCalendar $oldVCalendar = null): array { // construct event reader $eventReaderCurrent = new EventReader($vEvent); $eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null; @@ -191,9 +193,181 @@ public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array { if ($eventReaderCurrent->recurs()) { $data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent); } + // detect canceled / moved occurrences using full VCalendar context + if ($newVCalendar !== null && isset($vEvent->UID)) { + $changes = $this->detectRecurrenceChanges((string)$vEvent->UID, $newVCalendar, $oldVCalendar); + if (!empty($changes['canceled'])) { + $data['meeting_canceled_occurrences'] = $changes['canceled']; + } + if (!empty($changes['moved'])) { + $data['meeting_moved_occurrences'] = $changes['moved']; + } + } return $data; } + /** + * Find the master VEvent (the one without RECURRENCE-ID) for the given UID. + */ + public function findMasterEvent(VCalendar $vCalendar, string $uid): ?VEvent { + foreach ($vCalendar->getComponents() as $component) { + if (!$component instanceof VEvent) { + continue; + } + if (!isset($component->UID) || (string)$component->UID !== $uid) { + continue; + } + if (!isset($component->{'RECURRENCE-ID'})) { + return $component; + } + } + return null; + } + + /** + * Find all override VEvents (those with RECURRENCE-ID) for the given UID, + * keyed by the RECURRENCE-ID timestamp. + * + * @return array + */ + private function findOverrideEvents(VCalendar $vCalendar, string $uid): array { + $result = []; + foreach ($vCalendar->getComponents() as $component) { + if (!$component instanceof VEvent) { + continue; + } + if (!isset($component->UID) || (string)$component->UID !== $uid) { + continue; + } + if (!isset($component->{'RECURRENCE-ID'})) { + continue; + } + $ts = $component->{'RECURRENCE-ID'}->getDateTime()->getTimestamp(); + $result[$ts] = $component; + } + return $result; + } + + /** + * Collect EXDATE values from a master VEvent, keyed by timestamp. + * + * @return array + */ + private function collectExdates(VEvent $master): array { + $result = []; + if (!isset($master->EXDATE)) { + return $result; + } + foreach ($master->EXDATE as $property) { + /** @var Property\ICalendar\DateTime $property */ + foreach ($property->getDateTimes() as $dt) { + $result[$dt->getTimestamp()] = $dt; + } + } + return $result; + } + + /** + * Build human-readable strings for newly-canceled and newly-moved occurrences + * by diffing the new VCalendar against the old one. + * + * @return array{canceled: string[], moved: string[]} + */ + private function detectRecurrenceChanges(string $uid, VCalendar $newVCal, ?VCalendar $oldVCal): array { + $newMaster = $this->findMasterEvent($newVCal, $uid); + $oldMaster = $oldVCal !== null ? $this->findMasterEvent($oldVCal, $uid) : null; + $newOverrides = $this->findOverrideEvents($newVCal, $uid); + $oldOverrides = $oldVCal !== null ? $this->findOverrideEvents($oldVCal, $uid) : []; + + // entire-day-ness comes from the master (or, as a fallback, the first new override) + $entireDay = false; + if ($newMaster !== null && isset($newMaster->DTSTART)) { + $entireDay = !$newMaster->DTSTART->hasTime(); + } elseif (!empty($newOverrides)) { + $firstOverride = reset($newOverrides); + if (isset($firstOverride->DTSTART)) { + $entireDay = !$firstOverride->DTSTART->hasTime(); + } + } + + $newExdates = $newMaster !== null ? $this->collectExdates($newMaster) : []; + $oldExdates = $oldMaster !== null ? $this->collectExdates($oldMaster) : []; + + $canceled = []; + foreach ($newExdates as $ts => $dt) { + if (isset($oldExdates[$ts])) { + continue; // already excluded previously + } + if (isset($newOverrides[$ts])) { + continue; // matched by an override -> treated as a move below + } + $canceled[] = $this->formatCanceledOccurrence($dt, $entireDay); + } + + $moved = []; + foreach ($newOverrides as $ts => $override) { + if (!isset($override->DTSTART) || !isset($override->{'RECURRENCE-ID'})) { + continue; + } + $newStart = $override->DTSTART->getDateTime(); + $originalStart = $override->{'RECURRENCE-ID'}->getDateTime(); + $oldStart = isset($oldOverrides[$ts]) && isset($oldOverrides[$ts]->DTSTART) + ? $oldOverrides[$ts]->DTSTART->getDateTime() + : $originalStart; + if ($newStart->getTimestamp() === $oldStart->getTimestamp()) { + continue; // override exists but DTSTART unchanged + } + $moved[] = $this->formatMovedOccurrence($oldStart, $newStart, $entireDay); + } + + return ['canceled' => $canceled, 'moved' => $moved]; + } + + private function formatCanceledOccurrence(\DateTimeInterface $dt, bool $entireDay): string { + $dt = $this->toMutableDateTime($dt); + $date = $this->l10n->l('date', $dt, ['width' => 'full']); + if ($entireDay) { + return $date; + } + $time = $this->l10n->l('time', $dt, ['width' => 'short']); + // TRANSLATORS: Date and time of a canceled occurrence of a recurring event. Example: "Friday, April 24, 2026 at 15:00" + return $this->l10n->t('%1$s at %2$s', [$date, $time]); + } + + private function formatMovedOccurrence(\DateTimeInterface $oldDt, \DateTimeInterface $newDt, bool $entireDay): string { + $oldDt = $this->toMutableDateTime($oldDt); + $newDt = $this->toMutableDateTime($newDt); + $oldDate = $this->l10n->l('date', $oldDt, ['width' => 'full']); + $newDate = $this->l10n->l('date', $newDt, ['width' => 'full']); + if ($entireDay) { + if ($oldDate === $newDate) { + return $newDate; + } + // TRANSLATORS: A moved all-day occurrence of a recurring event. Example: "from Friday, April 24, 2026 to Saturday, April 25, 2026" + return $this->l10n->t('from %1$s to %2$s', [$oldDate, $newDate]); + } + $oldTime = $this->l10n->l('time', $oldDt, ['width' => 'short']); + $newTime = $this->l10n->l('time', $newDt, ['width' => 'short']); + if ($oldDate === $newDate) { + // TRANSLATORS: A moved occurrence of a recurring event, same date. Example: "Saturday, April 25, 2026 from 15:00 to 16:00" + return $this->l10n->t('%1$s from %2$s to %3$s', [$newDate, $oldTime, $newTime]); + } + // TRANSLATORS: A moved occurrence of a recurring event across dates. Example: "from Friday, April 24, 2026 15:00 to Saturday, April 25, 2026 16:00" + return $this->l10n->t('from %1$s %2$s to %3$s %4$s', [$oldDate, $oldTime, $newDate, $newTime]); + } + + /** + * Nextcloud's IL10N::l() only matches \DateTime via instanceof; a \DateTimeImmutable + * (which is what Sabre's getDateTime()/getDateTimes() returns) falls through to the + * numeric branch and gets cast via (int), producing timestamp 1 → epoch 0 dates. + */ + private function toMutableDateTime(\DateTimeInterface $dt): \DateTime { + if ($dt instanceof \DateTime) { + return $dt; + } + return \DateTime::createFromImmutable($dt); + } + /** * @param VEvent $vEvent * @return array @@ -1127,6 +1301,20 @@ public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) { $template->addBodyListItem($data['meeting_occurring_html'] ?? htmlspecialchars($data['meeting_occurring']), $this->l10n->t('Occurring:'), $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_occurring'], '', IMipPlugin::IMIP_INDENT); } + if (!empty($data['meeting_canceled_occurrences'])) { + $values = $data['meeting_canceled_occurrences']; + $html = implode('
', array_map('htmlspecialchars', $values)); + $plain = implode("\n", $values); + $template->addBodyListItem($html, $this->l10n->t('Cancelled:'), + $this->getAbsoluteImagePath('caldav/time.png'), $plain, '', IMipPlugin::IMIP_INDENT); + } + if (!empty($data['meeting_moved_occurrences'])) { + $values = $data['meeting_moved_occurrences']; + $html = implode('
', array_map('htmlspecialchars', $values)); + $plain = implode("\n", $values); + $template->addBodyListItem($html, $this->l10n->t('Moved:'), + $this->getAbsoluteImagePath('caldav/time.png'), $plain, '', IMipPlugin::IMIP_INDENT); + } $this->addAttendees($template, $vevent); diff --git a/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php b/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php index c1fab03b5172d..f5a288d5a8d6e 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php @@ -427,6 +427,167 @@ function ($v1, $v2) { $this->assertEquals($expected, $actual); } + private function stubL10nGeneric(): void { + // l('date'|'time', \DateTime, opts) — the \DateTime hint also guards against + // regressions of the DateTimeImmutable -> epoch-0 bug, since passing an + // Immutable here would raise a TypeError. + $this->l10n->method('l') + ->willReturnCallback(static function (string $type, \DateTime $date, $opts): string { + return $type === 'time' ? $date->format('H:i') : $date->format('Y-m-d'); + }); + $this->l10n->method('t') + ->willReturnCallback(static function (string $tmpl, array $args = []): string { + return vsprintf(preg_replace('/%\d+\$s/', '%s', $tmpl), $args); + }); + $this->l10n->method('n') + ->willReturnCallback(static function (string $singular, string $plural, int $count, array $args = []): string { + $tmpl = $count === 1 ? $singular : $plural; + $tmpl = str_replace('%n', (string)$count, $tmpl); + $tmpl = preg_replace('/%\d+\$s/', '%s', $tmpl); + return vsprintf($tmpl, $args); + }); + } + + private function buildDailyRecurringVCal(string $uid): VCalendar { + $vCal = new VCalendar(); + $vEvent = $vCal->add('VEVENT', []); + $vEvent->UID->setValue($uid); + $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'Europe/Berlin']); + $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'Europe/Berlin']); + $vEvent->add('SUMMARY', 'Daily Recurring Event'); + $vEvent->add('RRULE', 'FREQ=DAILY'); + return $vCal; + } + + public function testBuildBodyDataDetectsCanceledOccurrence(): void { + $this->stubL10nGeneric(); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('20240630T000000')); + + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $newVCal = $this->buildDailyRecurringVCal($uid); + $newVCal->VEVENT[0]->add('EXDATE', '20240703T080000', ['TZID' => 'Europe/Berlin']); + $oldVCal = $this->buildDailyRecurringVCal($uid); + + $data = $this->service->buildBodyData( + $newVCal->VEVENT[0], + $oldVCal->VEVENT[0], + $newVCal, + $oldVCal, + ); + + $this->assertArrayHasKey('meeting_canceled_occurrences', $data); + $this->assertSame(['2024-07-03 at 08:00'], $data['meeting_canceled_occurrences']); + $this->assertArrayNotHasKey('meeting_moved_occurrences', $data); + } + + public function testBuildBodyDataIgnoresPreexistingExdates(): void { + $this->stubL10nGeneric(); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('20240630T000000')); + + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $newVCal = $this->buildDailyRecurringVCal($uid); + $newVCal->VEVENT[0]->add('EXDATE', '20240703T080000', ['TZID' => 'Europe/Berlin']); + $newVCal->VEVENT[0]->add('EXDATE', '20240704T080000', ['TZID' => 'Europe/Berlin']); + $oldVCal = $this->buildDailyRecurringVCal($uid); + $oldVCal->VEVENT[0]->add('EXDATE', '20240703T080000', ['TZID' => 'Europe/Berlin']); + + $data = $this->service->buildBodyData( + $newVCal->VEVENT[0], + $oldVCal->VEVENT[0], + $newVCal, + $oldVCal, + ); + + $this->assertSame(['2024-07-04 at 08:00'], $data['meeting_canceled_occurrences']); + } + + public function testBuildBodyDataDetectsMovedOccurrence(): void { + $this->stubL10nGeneric(); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('20240630T000000')); + + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $newVCal = $this->buildDailyRecurringVCal($uid); + $override = $newVCal->add('VEVENT', []); + $override->UID->setValue($uid); + $override->add('DTSTART', '20240703T093000', ['TZID' => 'Europe/Berlin']); + $override->add('DTEND', '20240703T103000', ['TZID' => 'Europe/Berlin']); + $override->add('RECURRENCE-ID', '20240703T080000', ['TZID' => 'Europe/Berlin']); + $override->add('SUMMARY', 'Daily Recurring Event'); + + $oldVCal = $this->buildDailyRecurringVCal($uid); + + // IMipPlugin pops the changed VEvent — for a new override that's the override itself. + $data = $this->service->buildBodyData( + $newVCal->VEVENT[1], + null, + $newVCal, + $oldVCal, + ); + + $this->assertArrayHasKey('meeting_moved_occurrences', $data); + $this->assertSame(['2024-07-03 from 08:00 to 09:30'], $data['meeting_moved_occurrences']); + $this->assertArrayNotHasKey('meeting_canceled_occurrences', $data); + } + + public function testBuildBodyDataDetectsMovedOccurrenceAcrossDays(): void { + $this->stubL10nGeneric(); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('20240630T000000')); + + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $newVCal = $this->buildDailyRecurringVCal($uid); + // Move the July 3, 08:00 occurrence to July 4, 09:30 (different day + time). + $override = $newVCal->add('VEVENT', []); + $override->UID->setValue($uid); + $override->add('DTSTART', '20240704T093000', ['TZID' => 'Europe/Berlin']); + $override->add('DTEND', '20240704T103000', ['TZID' => 'Europe/Berlin']); + $override->add('RECURRENCE-ID', '20240703T080000', ['TZID' => 'Europe/Berlin']); + $override->add('SUMMARY', 'Daily Recurring Event'); + + $oldVCal = $this->buildDailyRecurringVCal($uid); + + $data = $this->service->buildBodyData( + $newVCal->VEVENT[1], + null, + $newVCal, + $oldVCal, + ); + + // Cross-day pattern: "from to ". + $this->assertSame(['from 2024-07-03 08:00 to 2024-07-04 09:30'], $data['meeting_moved_occurrences']); + } + + public function testBuildBodyDataReturnsNoExtraKeysWhenNothingRecurrenceChanged(): void { + $this->stubL10nGeneric(); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('20240630T000000')); + + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $vCal = $this->buildDailyRecurringVCal($uid); + + $data = $this->service->buildBodyData( + $vCal->VEVENT[0], + $vCal->VEVENT[0], + $vCal, + $vCal, + ); + + $this->assertArrayNotHasKey('meeting_canceled_occurrences', $data); + $this->assertArrayNotHasKey('meeting_moved_occurrences', $data); + } + + public function testFindMasterEventSkipsOverrides(): void { + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $vCal = $this->buildDailyRecurringVCal($uid); + $override = $vCal->add('VEVENT', []); + $override->UID->setValue($uid); + $override->add('DTSTART', '20240703T093000', ['TZID' => 'Europe/Berlin']); + $override->add('RECURRENCE-ID', '20240703T080000', ['TZID' => 'Europe/Berlin']); + + $master = $this->service->findMasterEvent($vCal, $uid); + $this->assertNotNull($master); + $this->assertFalse(isset($master->{'RECURRENCE-ID'})); + $this->assertSame('20240701T080000', $master->DTSTART->getValue()); + } + public function testBuildReplyBodyDataEscapesStrings(): void { $this->l10n->method('l') ->willReturnCallback(static function (string $type, \DateTime $date, $_):string {