*/ public function parse(string $ical, ?CarbonInterface $from = null, ?CarbonInterface $to = null): array { $ical = (new CalendarOccurrenceExpander)->expand( $ical, $from ?? Carbon::now()->subDay(), $to ?? Carbon::now()->addDays((int) config('relais_sms.caldav.lookahead_days', 90)), ); $lines = $this->unfoldLines($ical); $events = []; $eventLines = null; $alarmLines = null; $alarms = []; foreach ($lines as $line) { $upper = strtoupper($line); if ($upper === 'BEGIN:VEVENT') { $eventLines = []; $alarms = []; continue; } if ($upper === 'END:VEVENT') { if ($eventLines !== null) { $event = $this->parseEvent($eventLines, $alarms); if (! $event->cancelled) { $events[] = $event; } } $eventLines = null; $alarmLines = null; $alarms = []; continue; } if ($eventLines === null) { continue; } if ($upper === 'BEGIN:VALARM') { $alarmLines = []; continue; } if ($upper === 'END:VALARM') { if ($alarmLines !== null) { $alarms[] = $this->parseAlarm($alarmLines); } $alarmLines = null; continue; } if ($alarmLines !== null) { $alarmLines[] = $line; continue; } $eventLines[] = $line; } return array_values(array_filter($events, fn (ParsedCalendarEvent $event): bool => $event->uid !== '')); } /** * @param array $lines * @param array $alarms */ private function parseEvent(array $lines, array $alarms): ParsedCalendarEvent { $uid = ''; $summary = null; $description = null; $location = null; $startsAt = null; $endsAt = null; $attendees = []; $recurrenceId = null; $cancelled = false; foreach ($lines as $line) { $property = $this->parseProperty($line); $name = strtoupper($property['name']); match ($name) { 'UID' => $uid = $this->decodeText($property['value']), 'SUMMARY' => $summary = $this->decodeText($property['value']), 'DESCRIPTION' => $description = $this->decodeText($property['value']), 'LOCATION' => $location = $this->decodeText($property['value']), 'DTSTART' => $startsAt = $this->parseDateTime($property['value'], $property['params']), 'DTEND' => $endsAt = $this->parseDateTime($property['value'], $property['params']), 'RECURRENCE-ID' => $recurrenceId = $this->parseDateTime($property['value'], $property['params']), 'STATUS' => $cancelled = strtoupper($property['value']) === 'CANCELLED', 'ATTENDEE', 'ORGANIZER' => $attendees[] = $this->parseAttendee($property['value'], $property['params']), default => null, }; } return new ParsedCalendarEvent( uid: $uid, summary: $summary, description: $description, location: $location, startsAt: $startsAt, endsAt: $endsAt, attendees: array_values(array_filter( $attendees, fn (array $attendee): bool => $attendee['email'] !== '', )), alarms: $alarms, recurrenceId: $recurrenceId, cancelled: $cancelled, ); } /** * @param array $lines */ private function parseAlarm(array $lines): ParsedCalendarAlarm { $action = ''; $trigger = ''; $triggerMinutes = null; $absoluteRemindAt = null; foreach ($lines as $line) { $property = $this->parseProperty($line); $name = strtoupper($property['name']); if ($name === 'ACTION') { $action = strtoupper($property['value']); } if ($name === 'TRIGGER') { $trigger = $property['value']; if (strtoupper((string) ($property['params']['VALUE'] ?? '')) === 'DATE-TIME') { $absoluteRemindAt = $this->parseDateTime($property['value'], $property['params']); } else { $triggerMinutes = $this->parseDurationMinutes($property['value']); } } } return new ParsedCalendarAlarm( action: $action, trigger: $trigger, triggerMinutes: $triggerMinutes, absoluteRemindAt: $absoluteRemindAt, ); } /** * @return array{name: string, params: array, value: string} */ private function parseProperty(string $line): array { [$left, $value] = $this->splitPropertyValue($line); $parts = $this->splitOutsideQuotes($left, ';'); $name = array_shift($parts) ?: ''; $params = []; foreach ($parts as $part) { [$key, $paramValue] = array_pad(explode('=', $part, 2), 2, ''); if ($key !== '') { $params[strtoupper($key)] = trim($paramValue, '"'); } } return [ 'name' => $name, 'params' => $params, 'value' => $value, ]; } /** * A CalDAV parameter can contain a URI, for example * MEMBER="mailto:circle+id@example.test". The colon in that quoted URI is * not the separator between the property metadata and its value. * * @return array{string, string} */ private function splitPropertyValue(string $line): array { $quoted = false; for ($index = 0, $length = strlen($line); $index < $length; $index++) { if ($line[$index] === '"') { $quoted = ! $quoted; continue; } if ($line[$index] === ':' && ! $quoted) { return [substr($line, 0, $index), substr($line, $index + 1)]; } } return [$line, '']; } /** * @return array */ private function splitOutsideQuotes(string $value, string $delimiter): array { $parts = []; $part = ''; $quoted = false; for ($index = 0, $length = strlen($value); $index < $length; $index++) { $character = $value[$index]; if ($character === '"') { $quoted = ! $quoted; } if ($character === $delimiter && ! $quoted) { $parts[] = $part; $part = ''; continue; } $part .= $character; } $parts[] = $part; return $parts; } /** * @param array $params * @return array{email: string, name: ?string} */ private function parseAttendee(string $value, array $params): array { $email = preg_replace('/^mailto:/i', '', trim($value)) ?? ''; return [ 'email' => strtolower($email), 'name' => isset($params['CN']) ? $this->decodeText($params['CN']) : null, ]; } /** * @param array $params */ private function parseDateTime(string $value, array $params): ?Carbon { $value = trim($value); if ($value === '') { return null; } $timezone = $params['TZID'] ?? config('app.timezone', 'UTC'); if (str_ends_with($value, 'Z')) { return Carbon::createFromFormat('Ymd\THis\Z', $value, 'UTC')?->utc(); } if (str_contains($value, 'T')) { return Carbon::createFromFormat('Ymd\THis', $value, $timezone)?->utc(); } return Carbon::createFromFormat('Ymd', $value, $timezone)?->startOfDay()->utc(); } private function parseDurationMinutes(string $value): ?int { $value = strtoupper(trim($value)); if (! preg_match('/^([+-])?P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/', $value, $matches)) { return null; } $sign = ($matches[1] ?? '') === '-' ? -1 : 1; $days = (int) ($matches[2] ?? 0); $hours = (int) ($matches[3] ?? 0); $minutes = (int) ($matches[4] ?? 0); $seconds = (int) ($matches[5] ?? 0); return $sign * (($days * 24 * 60) + ($hours * 60) + $minutes + (int) ceil($seconds / 60)); } /** * @return array */ private function unfoldLines(string $ical): array { $rawLines = preg_split('/\r\n|\n|\r/', $ical) ?: []; $lines = []; foreach ($rawLines as $line) { if ($line === '') { continue; } if ((str_starts_with($line, ' ') || str_starts_with($line, "\t")) && $lines !== []) { $lines[array_key_last($lines)] .= substr($line, 1); continue; } $lines[] = $line; } return $lines; } private function decodeText(string $value): string { return str_replace( ['\\n', '\\N', '\\,', '\\;', '\\\\'], ["\n", "\n", ',', ';', '\\'], $value, ); } }