calender stuff
This commit is contained in:
1 parent
8e78468743
commit
e0fca064ae
15 files changed
+1716
-67
No files matched your search
@@ -0,0 +1,256 @@
|
||||
<?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) {
|
||||
if (preg_match('/^' . preg_quote($prop, '/') . '[^:]*:(.+)$/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";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user