select('VEVENT') as $event) { // A cancelled detached occurrence may contain only UID and RECURRENCE-ID. // It must still suppress the original occurrence during expansion. if (! isset($event->DTSTART) && isset($event->{'RECURRENCE-ID'})) { $start = clone $event->{'RECURRENCE-ID'}; $start->name = 'DTSTART'; $event->add($start); } // VObject chooses RDATE instead of RRULE when both are present. // Expand their union separately, then deduplicate by occurrence identity. if (isset($event->RRULE, $event->RDATE)) { $additionalUids[] = (string) $event->UID; unset($event->RDATE); } } $timezone = new DateTimeZone((string) config('app.timezone', 'UTC')); $expanded = $calendar->expand($from, $to, $timezone); if ($additionalUids !== []) { foreach ($additionalDates->select('VEVENT') as $event) { if (! in_array((string) $event->UID, $additionalUids, true)) { $additionalDates->remove($event); } else { unset($event->RRULE); if (! isset($event->DTSTART) && isset($event->{'RECURRENCE-ID'})) { $start = clone $event->{'RECURRENCE-ID'}; $start->name = 'DTSTART'; $event->add($start); } } } $seen = []; foreach ($expanded->select('VEVENT') as $event) { $seen[$this->identity($event)] = true; } foreach ($additionalDates->expand($from, $to, $timezone)->select('VEVENT') as $event) { $key = $this->identity($event); if (! isset($seen[$key])) { $expanded->add(clone $event); $seen[$key] = true; } } } return $expanded->serialize(); } private function identity(VEvent $event): string { return (string) $event->UID.'|'.(string) $event->{'RECURRENCE-ID'}; } }