258 lines
11 KiB
PHP
258 lines
11 KiB
PHP
<?php
|
|
|
|
class CalDavClient
|
|
{
|
|
private string $baseUrl;
|
|
private string $username;
|
|
private string $password;
|
|
|
|
public function __construct(string $baseUrl, string $username, string $password)
|
|
{
|
|
$this->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, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>', ['Depth: 0']);
|
|
return $res['status'] === 207;
|
|
}
|
|
|
|
public function listCalendars(): array
|
|
{
|
|
// PROPFIND depth 1 on the base URL to find calendar collections underneath
|
|
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><d:resourcetype/><cs:getctag/></d:prop></d:propfind>';
|
|
$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 = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/></d:prop></d:propfind>';
|
|
$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 = '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>';
|
|
$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";
|
|
}
|
|
}
|