baseUrl = rtrim($baseUrl, '/') . '/'; $this->username = $username; $this->password = $password; } public function getBaseUrl(): string { return $this->baseUrl; } public function request(string $method, string $url, string $body = '', array $headers = []): array { $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_USERPWD => $this->username . ':' . $this->password, CURLOPT_HTTPAUTH => CURLAUTH_BASIC, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, ]); if ($body !== '') { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } // Use caller's Content-Type if provided, otherwise default to XML $hasContentType = false; foreach ($headers as $h) { if (stripos($h, 'Content-Type:') === 0) { $hasContentType = true; break; } } $allHeaders = $hasContentType ? $headers : array_merge(['Content-Type: application/xml; charset=utf-8'], $headers); curl_setopt($ch, CURLOPT_HTTPHEADER, $allHeaders); $response = curl_exec($ch); $error = curl_error($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); if ($response === false) { return ['status' => 0, 'headers' => '', 'body' => '', 'error' => $error]; } $respHeaders = substr($response, 0, $headerSize); $respBody = substr($response, $headerSize); // Extract ETag from headers $etag = null; if (preg_match('/^ETag:\s*(.+)$/mi', $respHeaders, $m)) { $etag = trim($m[1]); } return ['status' => $status, 'headers' => $respHeaders, 'body' => $respBody, 'etag' => $etag, 'error' => null]; } public function testConnection(): bool { $res = $this->request('PROPFIND', $this->baseUrl, '', ['Depth: 0']); return $res['status'] === 207; } public function listCalendars(): array { // PROPFIND depth 1 on the base URL to find calendar collections underneath $body = ''; $res = $this->request('PROPFIND', $this->baseUrl, $body, ['Depth: 1']); if ($res['status'] !== 207) return []; $baseHref = rtrim(parse_url($this->baseUrl, PHP_URL_PATH), '/'); $calendars = []; try { $xml = new SimpleXMLElement($res['body']); $xml->registerXPathNamespace('d', 'DAV:'); $xml->registerXPathNamespace('cs', 'http://calendarserver.org/ns/'); $xml->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav'); foreach ($xml->xpath('//d:response') as $resp) { $resp->registerXPathNamespace('d', 'DAV:'); $resp->registerXPathNamespace('cs', 'http://calendarserver.org/ns/'); $resp->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav'); $href = (string) ($resp->xpath('.//d:href')[0] ?? ''); // Skip the collection itself (same path as base) if (rtrim($href, '/') === $baseHref) continue; // Check for calendar resource type via namespace-aware xpath $isCal = $resp->xpath('.//d:resourcetype/c:calendar'); if (!$isCal) { // Fallback: check raw XML for 'calendar' string $rtXml = $resp->xpath('.//d:resourcetype'); if (!$rtXml || strpos($rtXml[0]->asXML(), 'calendar') === false) continue; } $name = (string) ($resp->xpath('.//d:displayname')[0] ?? basename(rtrim($href, '/'))); $ctag = (string) ($resp->xpath('.//cs:getctag')[0] ?? ''); if ($href) $calendars[] = ['name' => $name, 'href' => $href, 'ctag' => $ctag]; } } catch (Exception $e) {} return $calendars; } public function getCalendarCtag(): ?string { $body = ''; $res = $this->request('PROPFIND', $this->baseUrl, $body, ['Depth: 0']); if ($res['status'] !== 207) return null; try { $xml = new SimpleXMLElement($res['body']); $xml->registerXPathNamespace('d', 'DAV:'); $xml->registerXPathNamespace('cs', 'http://calendarserver.org/ns/'); $ctag = $xml->xpath('//cs:getctag'); return $ctag ? (string) $ctag[0] : null; } catch (Exception $e) { return null; } } public function listEvents(): array { $body = ''; $res = $this->request('REPORT', $this->baseUrl, $body, ['Depth: 1']); if ($res['status'] !== 207) return []; $events = []; try { $xml = new SimpleXMLElement($res['body']); $xml->registerXPathNamespace('d', 'DAV:'); $xml->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav'); foreach ($xml->xpath('//d:response') as $resp) { $resp->registerXPathNamespace('d', 'DAV:'); $resp->registerXPathNamespace('c', 'urn:ietf:params:xml:ns:caldav'); $href = (string) ($resp->xpath('.//d:href')[0] ?? ''); $etag = (string) ($resp->xpath('.//d:getetag')[0] ?? ''); $ical = (string) ($resp->xpath('.//c:calendar-data')[0] ?? ''); if ($href && $ical) { $events[] = ['href' => $href, 'etag' => $etag, 'ical' => $ical]; } } } catch (Exception $e) {} return $events; } public function createEvent(string $uid, string $icalData): array { // Use URL-safe filename (replace @ and other special chars) $filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $uid); $url = $this->baseUrl . $filename . '.ics'; return $this->request('PUT', $url, $icalData, [ 'Content-Type: text/calendar; charset=utf-8', 'If-None-Match: *', ]); } public function updateEvent(string $href, string $icalData, string $etag): array { $url = $this->resolveHref($href); return $this->request('PUT', $url, $icalData, [ 'Content-Type: text/calendar; charset=utf-8', 'If-Match: ' . $etag, ]); } public function deleteEvent(string $href, string $etag): array { $url = $this->resolveHref($href); return $this->request('DELETE', $url, '', ['If-Match: ' . $etag]); } private function resolveHref(string $href): string { if (str_starts_with($href, 'http')) return $href; $parsed = parse_url($this->baseUrl); return $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '') . $href; } // ── iCalendar parsing ── public static function parseVEvent(string $ical): ?array { // Unfold RFC 5545 line continuations $ical = preg_replace('/\r\n[ \t]/', '', $ical); $ical = str_replace("\r\n", "\n", $ical); if (!preg_match('/BEGIN:VEVENT(.+?)END:VEVENT/s', $ical, $m)) return null; $block = $m[1]; $get = function ($prop) use ($block) { // Match property value: everything after "PROP...:" until the next property or end if (preg_match('/^' . preg_quote($prop, '/') . '[^:]*:([\s\S]*?)(?=\n[A-Z][-A-Z]*[;:]|\z)/m', $block, $m)) return trim($m[1]); return null; }; return [ 'uid' => $get('UID'), 'summary' => $get('SUMMARY'), 'dtstart' => $get('DTSTART'), 'dtend' => $get('DTEND'), 'location' => $get('LOCATION'), 'description' => str_replace('\\n', "\n", $get('DESCRIPTION') ?? ''), 'last_modified' => $get('LAST-MODIFIED'), 'sequence' => (int) ($get('SEQUENCE') ?? 0), 'rrule' => $get('RRULE'), ]; } public static function buildVEvent(array $props): string { $fold = function ($line) { $out = ''; while (strlen($line) > 75) { $out .= substr($line, 0, 75) . "\r\n "; $line = substr($line, 75); } return $out . $line; }; $escape = function ($s) { return str_replace(["\n", ",", ";", "\\"], ["\\n", "\\,", "\\;", "\\\\"], $s ?? ''); }; $lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//hyprcrd//CalDAV Client//EN', 'CALSCALE:GREGORIAN', 'BEGIN:VEVENT', 'UID:' . $props['uid'], ]; // DTSTART / DTEND if (strlen($props['dtstart'] ?? '') === 8) { $lines[] = 'DTSTART;VALUE=DATE:' . $props['dtstart']; $lines[] = 'DTEND;VALUE=DATE:' . ($props['dtend'] ?? $props['dtstart']); } else { $lines[] = 'DTSTART:' . $props['dtstart']; if (!empty($props['dtend'])) $lines[] = 'DTEND:' . $props['dtend']; } $lines[] = $fold('SUMMARY:' . $escape($props['summary'] ?? '')); if (!empty($props['rrule'])) $lines[] = 'RRULE:' . $props['rrule']; if (!empty($props['location'])) $lines[] = $fold('LOCATION:' . $escape($props['location'])); if (!empty($props['description'])) $lines[] = $fold('DESCRIPTION:' . $escape($props['description'])); $lines[] = 'SEQUENCE:' . ($props['sequence'] ?? 0); $lines[] = 'LAST-MODIFIED:' . gmdate('Ymd\THis\Z'); $lines[] = 'DTSTAMP:' . gmdate('Ymd\THis\Z'); if (!empty($props['color'])) $lines[] = 'X-HYPRCRD-COLOR:' . $props['color']; $lines[] = 'END:VEVENT'; $lines[] = 'END:VCALENDAR'; return implode("\r\n", $lines) . "\r\n"; } }