Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/dav/l10n/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 6 additions & 0 deletions apps/dav/l10n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 6 additions & 0 deletions apps/dav/l10n/de_DE.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions apps/dav/l10n/de_DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions apps/dav/lib/CalDAV/Schedule/IMipPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
190 changes: 189 additions & 1 deletion apps/dav/lib/CalDAV/Schedule/IMipService.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,11 @@
/**
* @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;
Expand Down Expand Up @@ -191,9 +193,181 @@
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<int, VEvent>
*/
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();

Check failure on line 245 in apps/dav/lib/CalDAV/Schedule/IMipService.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedMethod

apps/dav/lib/CalDAV/Schedule/IMipService.php:245:41: UndefinedMethod: Method Sabre\VObject\Property::getDateTime does not exist (see https://psalm.dev/022)
$result[$ts] = $component;
}
return $result;
}

/**
* Collect EXDATE values from a master VEvent, keyed by timestamp.
*
* @return array<int, \DateTimeInterface>
*/
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();

Check failure on line 285 in apps/dav/lib/CalDAV/Schedule/IMipService.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedMethod

apps/dav/lib/CalDAV/Schedule/IMipService.php:285:39: UndefinedMethod: Method Sabre\VObject\Property::hasTime does not exist (see https://psalm.dev/022)
} elseif (!empty($newOverrides)) {
$firstOverride = reset($newOverrides);
if (isset($firstOverride->DTSTART)) {
$entireDay = !$firstOverride->DTSTART->hasTime();

Check failure on line 289 in apps/dav/lib/CalDAV/Schedule/IMipService.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedMethod

apps/dav/lib/CalDAV/Schedule/IMipService.php:289:44: UndefinedMethod: Method Sabre\VObject\Property::hasTime does not exist (see https://psalm.dev/022)
}
}

$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()

Check failure on line 315 in apps/dav/lib/CalDAV/Schedule/IMipService.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedMethod

apps/dav/lib/CalDAV/Schedule/IMipService.php:315:36: UndefinedMethod: Method Sabre\VObject\Property::getDateTime does not exist (see https://psalm.dev/022)
: $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 {

Check failure on line 326 in apps/dav/lib/CalDAV/Schedule/IMipService.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidReturnType

apps/dav/lib/CalDAV/Schedule/IMipService.php:326:86: InvalidReturnType: The declared return type 'string' for OCA\DAV\CalDAV\Schedule\IMipService::formatCanceledOccurrence is incorrect, got 'false|int|string' (see https://psalm.dev/011)
$dt = $this->toMutableDateTime($dt);
$date = $this->l10n->l('date', $dt, ['width' => 'full']);
if ($entireDay) {
return $date;

Check failure on line 330 in apps/dav/lib/CalDAV/Schedule/IMipService.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

FalsableReturnStatement

apps/dav/lib/CalDAV/Schedule/IMipService.php:330:11: FalsableReturnStatement: The declared return type 'string' for OCA\DAV\CalDAV\Schedule\IMipService::formatCanceledOccurrence does not allow false, but the function returns 'false|int|string' (see https://psalm.dev/137)

Check failure on line 330 in apps/dav/lib/CalDAV/Schedule/IMipService.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidReturnStatement

apps/dav/lib/CalDAV/Schedule/IMipService.php:330:11: InvalidReturnStatement: The inferred type 'false|int|string' does not match the declared return type 'string' for OCA\DAV\CalDAV\Schedule\IMipService::formatCanceledOccurrence (see https://psalm.dev/128)
}
$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 {

Check failure on line 337 in apps/dav/lib/CalDAV/Schedule/IMipService.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidReturnType

apps/dav/lib/CalDAV/Schedule/IMipService.php:337:113: InvalidReturnType: The declared return type 'string' for OCA\DAV\CalDAV\Schedule\IMipService::formatMovedOccurrence is incorrect, got 'false|int|string' (see https://psalm.dev/011)
$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;

Check failure on line 344 in apps/dav/lib/CalDAV/Schedule/IMipService.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

FalsableReturnStatement

apps/dav/lib/CalDAV/Schedule/IMipService.php:344:12: FalsableReturnStatement: The declared return type 'string' for OCA\DAV\CalDAV\Schedule\IMipService::formatMovedOccurrence does not allow false, but the function returns 'false|int|string' (see https://psalm.dev/137)

Check failure on line 344 in apps/dav/lib/CalDAV/Schedule/IMipService.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidReturnStatement

apps/dav/lib/CalDAV/Schedule/IMipService.php:344:12: InvalidReturnStatement: The inferred type 'false|int|string' does not match the declared return type 'string' for OCA\DAV\CalDAV\Schedule\IMipService::formatMovedOccurrence (see https://psalm.dev/128)
}
// 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
Expand Down Expand Up @@ -1127,6 +1301,20 @@
$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('<br />', 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('<br />', 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);

Expand Down
Loading
Loading