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 {