diff --git a/Entities/Calendar.php b/Entities/Calendar.php index 4d5d8d1..39a0537 100644 --- a/Entities/Calendar.php +++ b/Entities/Calendar.php @@ -131,7 +131,8 @@ public function getExternalContent( bool $force = false ): ?string { $remainingUrl = substr( $fullUrl, strpos( $fullUrl, '/', 8 ) ); $caldavClient = new CalDAV( $baseUrl, $this->custom_fields['username'], $this->custom_fields['password'] ); - $data = implode( '', $caldavClient->getEvents( $remainingUrl ) ); + // Keep event payloads separated to preserve valid ICS line boundaries. + $data = implode( "\r\n", $caldavClient->getEvents( $remainingUrl ) ); } catch ( Exception $e ) { Log::error( $e->getMessage(), [ 'exception' => $e ] ); diff --git a/Http/Helpers/CalDAV.php b/Http/Helpers/CalDAV.php index 23ace07..ea76aa5 100644 --- a/Http/Helpers/CalDAV.php +++ b/Http/Helpers/CalDAV.php @@ -19,17 +19,51 @@ public function __construct( $baseUri, $userName, $password ) { } public function getEvents( $calendarUrl ) { - $response = $this->client->propFind( $calendarUrl, [ - '{DAV:}displayname', - '{urn:ietf:params:xml:ns:caldav}calendar-description', - '{urn:ietf:params:xml:ns:caldav}calendar-data', - ], 2 ); + \Log::debug( 'CalDAV getEvents called' ); + + $xmlBody = '' . "\n" . + '' . "\n" . + ' ' . "\n" . + ' ' . "\n" . + ' ' . "\n" . + ' ' . "\n" . + ' ' . "\n" . + ' ' . "\n" . + ' ' . "\n" . + ' ' . "\n" . + ' ' . "\n" . + ' ' . "\n" . + ''; + + try { + $response = $this->client->request( 'REPORT', $calendarUrl, $xmlBody, [ + 'Content-Type' => 'application/xml; charset=utf-8', + 'Depth' => '1', + ] ); + } catch ( \Exception $e ) { + \Log::debug( 'CalDAV REPORT query failed, returning empty events list', [ + 'error' => $e->getMessage(), + ] ); + return []; + } + + \Log::debug( 'CalDAV REPORT response', [ + 'status' => $response['statusCode'] ?? 'no status', + 'body_length' => strlen( $response['body'] ?? '' ), + ] ); $events = []; - foreach ( $response as $eventData ) { - if ( isset( $eventData['{urn:ietf:params:xml:ns:caldav}calendar-data'] ) ) { - $events[] = $eventData['{urn:ietf:params:xml:ns:caldav}calendar-data']; + if ( isset( $response['statusCode'] ) && $response['statusCode'] >= 200 && $response['statusCode'] < 300 ) { + if ( isset( $response['body'] ) && ! empty( $response['body'] ) ) { + $events = $this->extractCalendarDataFromMultistatus( $response['body'] ); + if ( count( $events ) === 0 ) { + $hrefs = $this->extractEventHrefsFromMultistatus( $response['body'], $calendarUrl ); + $events = $this->fetchEventsByHref( $hrefs ); + } + \Log::debug( 'CalDAV parsed events count', [ 'count' => count( $events ) ] ); } + } else { + \Log::debug( 'CalDAV REPORT failed', [ 'status' => $response['statusCode'] ?? 'unknown' ] ); } return $events; @@ -100,7 +134,7 @@ public function deleteEvent( $calendarUrl, $uid ) { /** * Get a single event by UID using CalDAV REPORT query * This is much more efficient than fetching all events - * + * * @param string $calendarUrl The calendar URL * @param string $uid The event UID to fetch * @return array|null The event data or null if not found @@ -157,25 +191,25 @@ public function getEventByUid( $calendarUrl, $uid ) { /** * Check if the CalDAV server supports REPORT queries - * + * * @param string $calendarUrl * @return bool */ public function supportsReportQuery( $calendarUrl ) { try { $response = $this->client->options( $calendarUrl ); - + if ( isset( $response['headers']['allow'] ) ) { - $allow = is_array( $response['headers']['allow'] ) - ? implode( ',', $response['headers']['allow'] ) + $allow = is_array( $response['headers']['allow'] ) + ? implode( ',', $response['headers']['allow'] ) : $response['headers']['allow']; return stripos( $allow, 'REPORT' ) !== false; } - + // Also check DAV header for calendar-access if ( isset( $response['headers']['dav'] ) ) { - $dav = is_array( $response['headers']['dav'] ) - ? implode( ',', $response['headers']['dav'] ) + $dav = is_array( $response['headers']['dav'] ) + ? implode( ',', $response['headers']['dav'] ) : $response['headers']['dav']; return stripos( $dav, 'calendar-access' ) !== false; } @@ -185,8 +219,115 @@ public function supportsReportQuery( $calendarUrl ) { 'calendar_url' => $calendarUrl ] ); } - + return false; } + /** + * Extract calendar-data nodes from a CalDAV multistatus XML response. + * Uses XML parsing for namespace-agnostic extraction and regex as fallback. + * + * @param string $responseBody + * @return string[] + */ + private function extractCalendarDataFromMultistatus( $responseBody ) { + $events = []; + + libxml_use_internal_errors( true ); + $document = new \DOMDocument(); + if ( $document->loadXML( $responseBody ) ) { + $xpath = new \DOMXPath( $document ); + $nodes = $xpath->query( '//*[local-name()="calendar-data"]' ); + if ( $nodes !== false ) { + foreach ( $nodes as $node ) { + $data = trim( $node->nodeValue ); + if ( $data !== '' ) { + $events[] = html_entity_decode( $data, ENT_XML1, 'UTF-8' ); + } + } + } + } + libxml_clear_errors(); + + if ( count( $events ) > 0 ) { + return $events; + } + + $matches = []; + preg_match_all( '/<(?:[A-Za-z0-9_-]+:)?calendar-data[^>]*>(.*?)<\/(?:[A-Za-z0-9_-]+:)?calendar-data>/s', $responseBody, $matches ); + if ( isset( $matches[1] ) ) { + foreach ( $matches[1] as $data ) { + $decoded = html_entity_decode( trim( $data ), ENT_XML1, 'UTF-8' ); + if ( $decoded !== '' ) { + $events[] = $decoded; + } + } + } + + return $events; + } + + /** + * Extract event hrefs from a CalDAV multistatus XML response. + * + * @param string $responseBody + * @param string $calendarUrl + * @return string[] + */ + private function extractEventHrefsFromMultistatus( $responseBody, $calendarUrl ) { + $hrefs = []; + + libxml_use_internal_errors( true ); + $document = new \DOMDocument(); + if ( $document->loadXML( $responseBody ) ) { + $xpath = new \DOMXPath( $document ); + $nodes = $xpath->query( '//*[local-name()="href"]' ); + if ( $nodes !== false ) { + foreach ( $nodes as $node ) { + $href = trim( $node->nodeValue ); + if ( $href === '' ) { + continue; + } + if ( substr( $href, -1 ) === '/' ) { + continue; + } + if ( stripos( $href, '.ics' ) === false ) { + continue; + } + $hrefs[] = $href; + } + } + } + libxml_clear_errors(); + + return array_values( array_unique( $hrefs ) ); + } + + /** + * Fetch event bodies by href list. + * + * @param string[] $hrefs + * @return string[] + */ + private function fetchEventsByHref( $hrefs ) { + $events = []; + foreach ( $hrefs as $href ) { + try { + $response = $this->client->request( 'GET', $href, null, [ + 'Accept' => 'text/calendar, */*;q=0.1', + ] ); + if ( isset( $response['statusCode'] ) && $response['statusCode'] >= 200 && $response['statusCode'] < 300 ) { + $body = trim( $response['body'] ?? '' ); + if ( $body !== '' ) { + $events[] = $body; + } + } + } catch ( \Exception $e ) { + \Log::debug( 'CalDAV event GET failed', [ 'error' => $e->getMessage() ] ); + } + } + + return $events; + } + }