add('PRODID', '-//Kithkin//Laravel CalDAV//EN'); $vcalendar->add('VERSION', '2.0'); $vcalendar->add('CALSCALE', 'GREGORIAN'); $vevent = $vcalendar->add('VEVENT', []); $uid = $data['uid']; $startUtc = $data['start_utc']; $endUtc = $data['end_utc']; $tzid = $data['tzid'] ?? null; $allDay = (bool) ($data['all_day'] ?? false); $startDate = $data['start_date'] ?? null; $endDate = $data['end_date'] ?? null; $vevent->add('UID', $uid); $vevent->add('DTSTAMP', $startUtc->copy()->utc()); if ($allDay && $startDate && $endDate) { $vevent->add('DTSTART', Carbon::parse($startDate), ['VALUE' => 'DATE']); $vevent->add('DTEND', Carbon::parse($endDate), ['VALUE' => 'DATE']); } elseif ($tzid) { $startLocal = $startUtc->copy()->tz($tzid); $endLocal = $endUtc->copy()->tz($tzid); $vevent->add('DTSTART', $startLocal, ['TZID' => $tzid]); $vevent->add('DTEND', $endLocal, ['TZID' => $tzid]); } else { $vevent->add('DTSTART', $startUtc->copy()->utc()); $vevent->add('DTEND', $endUtc->copy()->utc()); } if (!empty($data['summary'])) { $vevent->add('SUMMARY', $data['summary']); } if (!empty($data['description'])) { $vevent->add('DESCRIPTION', $data['description']); } if (!empty($data['location'])) { $vevent->add('LOCATION', $data['location']); } $attendees = $data['attendees'] ?? []; if (is_array($attendees)) { $organizerAdded = false; foreach ($attendees as $attendee) { if (!is_array($attendee)) { continue; } $uri = $this->normalizeAttendeeUri( $attendee['attendee_uri'] ?? ($attendee['email'] ?? null) ); if (!$uri) { continue; } $params = []; if (!empty($attendee['name'])) { $params['CN'] = (string) $attendee['name']; } if (!empty($attendee['role'])) { $params['ROLE'] = strtoupper((string) $attendee['role']); } if (!empty($attendee['partstat'])) { $params['PARTSTAT'] = strtoupper((string) $attendee['partstat']); } if (!empty($attendee['cutype'])) { $params['CUTYPE'] = strtoupper((string) $attendee['cutype']); } if (array_key_exists('rsvp', $attendee) && $attendee['rsvp'] !== null) { $params['RSVP'] = (bool) $attendee['rsvp'] ? 'TRUE' : 'FALSE'; } if (!empty($attendee['extra']) && is_array($attendee['extra'])) { foreach ($attendee['extra'] as $key => $value) { if (!is_scalar($value)) { continue; } $paramName = strtoupper((string) $key); if ($paramName === '' || isset($params[$paramName])) { continue; } $params[$paramName] = (string) $value; } } if ((bool) ($attendee['is_organizer'] ?? false) && !$organizerAdded) { $organizerParams = $params; unset($organizerParams['ROLE'], $organizerParams['PARTSTAT'], $organizerParams['RSVP']); $vevent->add('ORGANIZER', $uri, $organizerParams); $organizerAdded = true; continue; } $vevent->add('ATTENDEE', $uri, $params); } } $rrule = $data['rrule'] ?? null; if ($rrule) { $vevent->add('RRULE', $rrule); } $exdates = $data['exdate'] ?? []; if (!empty($exdates)) { foreach ($exdates as $ex) { $dt = Carbon::parse($ex, $tzid ?: 'UTC'); if ($allDay) { $vevent->add('EXDATE', Carbon::parse($dt->toDateString()), ['VALUE' => 'DATE']); } elseif ($tzid) { $vevent->add('EXDATE', $dt, ['TZID' => $tzid]); } else { $vevent->add('EXDATE', $dt->utc()); } } } $rdates = $data['rdate'] ?? []; if (!empty($rdates)) { foreach ($rdates as $r) { $dt = Carbon::parse($r, $tzid ?: 'UTC'); if ($allDay) { $vevent->add('RDATE', Carbon::parse($dt->toDateString()), ['VALUE' => 'DATE']); } elseif ($tzid) { $vevent->add('RDATE', $dt, ['TZID' => $tzid]); } else { $vevent->add('RDATE', $dt->utc()); } } } return $vcalendar->serialize(); } /** * Check if a stored event contains recurrence data. */ public function isRecurring(Event $event): bool { $extra = $event->meta?->extra ?? []; if (!empty($extra['rrule'])) { return true; } return Str::contains($event->calendardata ?? '', ['RRULE', 'RDATE', 'EXDATE']); } /** * Expand recurring instances within the requested range. * * Returns an array of ['start' => Carbon, 'end' => Carbon, 'recurrence_id' => string|null] */ public function expand(Event $event, Carbon $rangeStart, Carbon $rangeEnd): array { $vcalendar = $this->readCalendar($event->calendardata); if (!$vcalendar || empty($vcalendar->VEVENT)) { return []; } $vevent = $vcalendar->VEVENT; $uid = (string) $vevent->UID; $startTz = $vevent->DTSTART?->getDateTime()?->getTimezone() ?? new DateTimeZone('UTC'); $iter = new EventIterator($vcalendar, $uid); $baseStart = $vevent->DTSTART?->getDateTime(); $baseEnd = $vevent->DTEND?->getDateTime(); $durationSeconds = 0; if ($baseStart && $baseEnd) { $durationSeconds = max(0, $baseEnd->getTimestamp() - $baseStart->getTimestamp()); } $searchStart = $rangeStart->copy()->subSeconds($durationSeconds)->setTimezone($startTz); $iter->fastForward($searchStart->toDateTime()); $items = []; while ($iter->valid()) { $start = Carbon::instance($iter->getDTStart()); $end = Carbon::instance($iter->getDTEnd()); $allDayTz = null; if ($event->meta?->all_day) { $allDayTz = $event->meta?->tzid ?? ($event->meta?->extra['tzid'] ?? null); } if ($allDayTz) { $start = Carbon::parse($start->toDateString(), $allDayTz); $end = Carbon::parse($end->toDateString(), $allDayTz); } $startUtc = $start->copy()->utc(); $endUtc = $end->copy()->utc(); if ($startUtc->gt($rangeEnd)) { break; } if ($endUtc->lte($rangeStart)) { $iter->next(); continue; } $items[] = [ 'start' => $startUtc, 'end' => $endUtc, 'recurrence_id' => $startUtc->format('Ymd\\THis\\Z'), ]; $iter->next(); } return $items; } /** * Resolve a single occurrence by its DTSTART. */ public function resolveOccurrence(Event $event, Carbon $occurrenceStart): ?array { $rangeStart = $occurrenceStart->copy()->subDay(); $rangeEnd = $occurrenceStart->copy()->addDay(); foreach ($this->expand($event, $rangeStart, $rangeEnd) as $occ) { if ($occ['start']->equalTo($occurrenceStart)) { return $occ; } } return null; } public function extractRrule(Event $event): ?string { $vcalendar = $this->readCalendar($event->calendardata); if (!$vcalendar || empty($vcalendar->VEVENT)) { return null; } $vevent = $vcalendar->VEVENT; return isset($vevent->RRULE) ? (string) $vevent->RRULE : null; } private function readCalendar(?string $ical): ?VCalendar { if (!$ical) { return null; } try { return Reader::read($ical); } catch (\Throwable $e) { return null; } } private function normalizeAttendeeUri(mixed $value): ?string { $uri = trim((string) $value); if ($uri === '') { return null; } if (str_contains($uri, '@') && !str_contains($uri, ':')) { return 'mailto:' . Str::lower($uri); } if (str_starts_with(Str::lower($uri), 'mailto:')) { return 'mailto:' . Str::lower(substr($uri, 7)); } return $uri; } }