serialize(); // PUT the updated object back with If-Match for conflict detection $putError = self::putCalendarObject($objectHref, $username, $password, $newData, $etag); if ($putError !== '') { dbglog($putError); return $putError; } return ''; } catch (Throwable $e) { $msg = 'CalDAV: Exception during updateEventStatus: ' . $e->getMessage(); dbglog($msg); return $msg; } } /** * Sync a remote CalDAV calendar collection into the slot's local ICS file. * * Downloads all calendar objects from the collection and merges them * into a single ICS file at the slot's configured file path. * * @param CalendarSlot $slot * @return bool True if sync succeeded */ public static function syncSlot(CalendarSlot $slot): bool { if (!$slot->hasRemoteSource()) return false; $caldavUrl = $slot->getCaldavUrl(); $username = $slot->getUsername(); $password = $slot->getPassword(); $localFile = $slot->getFile(); if ($localFile === '') { // No local file configured - nothing to sync into return false; } try { $objects = self::fetchAllCalendarObjects($caldavUrl, $username, $password); if ($objects === null) return false; $merged = self::mergeCalendarObjects($objects); if ($merged === '') return false; return IcsWriter::atomicWritePublic($localFile, $merged); } catch (Throwable $e) { return false; } } /** * Find a specific calendar object by UID using a REPORT request. * * @param string $caldavUrl * @param string $username * @param string $password * @param string $uid * @return array{href: string, etag: string, data: string}|null */ protected static function findObjectByUid( string $caldavUrl, string $username, string $password, string $uid ): ?array { $body = '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . htmlspecialchars($uid, ENT_XML1, 'UTF-8') . '' . '' . '' . '' . '' . ''; $response = self::request('REPORT', $caldavUrl, $username, $password, $body, [ 'Content-Type: application/xml; charset=utf-8', 'Depth: 1', ]); if ($response === null) { return null; } return self::parseReportResponse($response, $caldavUrl); } /** * Fetch all calendar objects from a CalDAV collection. * * @param string $caldavUrl * @param string $username * @param string $password * @return string[]|null Array of ICS data strings, or null on failure */ protected static function fetchAllCalendarObjects( string $caldavUrl, string $username, string $password ): ?array { $body = '' . '' . '' . '' . '' . '' . '' . '' . ''; $response = self::request('REPORT', $caldavUrl, $username, $password, $body, [ 'Content-Type: application/xml; charset=utf-8', 'Depth: 1', ]); if ($response === null) return null; return self::parseCalendarDataFromMultistatus($response); } /** * Merge multiple ICS calendar objects into a single calendar string. * * @param string[] $objects Array of ICS data strings * @return string Merged ICS content */ protected static function mergeCalendarObjects(array $objects): string { if ($objects === []) return ''; $merged = new VCalendar(); $merged->PRODID = '-//LuxTools DokuWiki Plugin//CalDAV Sync//EN'; $merged->VERSION = '2.0'; foreach ($objects as $icsData) { if (trim($icsData) === '') continue; try { $cal = Reader::read($icsData, Reader::OPTION_FORGIVING); if (!($cal instanceof VCalendar)) continue; // Copy VTIMEZONE components first foreach ($cal->select('VTIMEZONE') as $tz) { // Check if this timezone already exists in merged $tzid = (string)($tz->TZID ?? ''); $exists = false; foreach ($merged->select('VTIMEZONE') as $existingTz) { if ((string)($existingTz->TZID ?? '') === $tzid) { $exists = true; break; } } if (!$exists) { $merged->add(clone $tz); } } // Copy VEVENT components foreach ($cal->select('VEVENT') as $component) { $merged->add(clone $component); } } catch (Throwable $e) { // Skip malformed objects continue; } } return $merged->serialize(); } /** * PUT a calendar object back to the server. * * @param string $href Full URL of the calendar object * @param string $username * @param string $password * @param string $data ICS data to write * @param string $etag ETag for If-Match header (empty to skip) * @return string Empty string on success, error message on failure */ protected static function putCalendarObject( string $href, string $username, string $password, string $data, string $etag ): string { $headers = [ 'Content-Type: text/calendar; charset=utf-8', ]; if ($etag !== '') { $headers[] = 'If-Match: ' . $etag; } $response = self::request('PUT', $href, $username, $password, $data, $headers); if ($response === null) { return self::$lastRequestError ?: 'CalDAV PUT failed (unknown error)'; } return ''; } /** * Parse a REPORT multistatus response to extract href, etag, and calendar data * for the first matching object. * * @param string $xml * @param string $baseUrl * @return array{href: string, etag: string, data: string}|null */ protected static function parseReportResponse(string $xml, string $baseUrl): ?array { $doc = self::parseXml($xml); if ($doc === null) return null; $doc->registerXPathNamespace('d', 'DAV:'); $doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav'); $responses = $doc->xpath('//d:response'); if (!$responses || count($responses) === 0) return null; foreach ($responses as $resp) { $resp->registerXPathNamespace('d', 'DAV:'); $resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav'); $hrefs = $resp->xpath('d:href'); $href = ($hrefs && count($hrefs) > 0) ? trim((string)$hrefs[0]) : ''; $etags = $resp->xpath('.//d:getetag'); $etag = ($etags && count($etags) > 0) ? trim((string)$etags[0]) : ''; $caldata = $resp->xpath('.//cal:calendar-data'); $data = ($caldata && count($caldata) > 0) ? trim((string)$caldata[0]) : ''; if ($href === '' || $data === '') continue; // Resolve relative href to absolute URL if (strpos($href, 'http') !== 0) { $parsed = parse_url($baseUrl); $scheme = ($parsed['scheme'] ?? 'https'); $host = ($parsed['host'] ?? ''); $port = isset($parsed['port']) ? (':' . $parsed['port']) : ''; $href = $scheme . '://' . $host . $port . $href; } return [ 'href' => $href, 'etag' => $etag, 'data' => $data, ]; } return null; } /** * Parse calendar-data elements from a CalDAV multistatus response. * * @param string $xml * @return string[] */ protected static function parseCalendarDataFromMultistatus(string $xml): array { $doc = self::parseXml($xml); if ($doc === null) return []; $doc->registerXPathNamespace('d', 'DAV:'); $doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav'); $results = []; $responses = $doc->xpath('//d:response'); if (!$responses) return []; foreach ($responses as $resp) { $resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav'); $caldata = $resp->xpath('.//cal:calendar-data'); if ($caldata && count($caldata) > 0) { $data = trim((string)$caldata[0]); if ($data !== '') { $results[] = $data; } } } return $results; } /** * Parse an XML string safely. * * @param string $xml * @return \SimpleXMLElement|null */ protected static function parseXml(string $xml): ?\SimpleXMLElement { if (trim($xml) === '') return null; // Disable external entity loading for security $prev = libxml_use_internal_errors(true); try { $doc = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT | LIBXML_NONET); libxml_clear_errors(); return ($doc !== false) ? $doc : null; } finally { libxml_use_internal_errors($prev); } } /** * Public wrapper for findObjectByUid. * * @param string $caldavUrl * @param string $username * @param string $password * @param string $uid * @return array{href: string, etag: string, data: string}|null */ public static function findObjectByUidPublic( string $caldavUrl, string $username, string $password, string $uid ): ?array { return self::findObjectByUid($caldavUrl, $username, $password, $uid); } /** * Public wrapper for putCalendarObject. * * @param string $href * @param string $username * @param string $password * @param string $data * @param string $etag * @return string Empty string on success, error on failure */ public static function putCalendarObjectPublic( string $href, string $username, string $password, string $data, string $etag ): string { return self::putCalendarObject($href, $username, $password, $data, $etag); } /** * Delete a calendar object from the server. * * @param string $href Full URL of the calendar object * @param string $username * @param string $password * @param string $etag ETag for If-Match header (empty to skip) * @return bool True on success */ public static function deleteCalendarObject( string $href, string $username, string $password, string $etag ): bool { $headers = []; if ($etag !== '') { $headers[] = 'If-Match: ' . $etag; } $response = self::request('DELETE', $href, $username, $password, '', $headers); return $response !== null; } /** * Perform an HTTP request to a CalDAV server. * * @param string $method HTTP method (GET, PUT, REPORT, PROPFIND, etc.) * @param string $url Full URL * @param string $username * @param string $password * @param string $body Request body (empty for GET) * @param string[] $headers Additional HTTP headers * @return string|null Response body, or null on failure */ protected static function request( string $method, string $url, string $username, string $password, string $body = '', array $headers = [] ): ?string { self::$lastRequestError = ''; if (!function_exists('curl_init')) { self::$lastRequestError = 'CalDAV: curl extension not available'; return null; } $ch = curl_init(); if ($ch === false) { self::$lastRequestError = 'CalDAV: curl_init() failed'; return null; } curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_MAXREDIRS, 5); // Authentication if ($username !== '') { curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); curl_setopt($ch, CURLOPT_USERPWD, $username . ':' . $password); } // Request body if ($body !== '') { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } // HTTP headers if ($headers !== []) { curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); } // Capture HTTP status code $responseBody = curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if (!is_string($responseBody)) { self::$lastRequestError = "CalDAV $method failed: curl error: $curlError"; return null; } // Accept 2xx and 207 (multistatus) responses if ($httpCode >= 200 && $httpCode < 300) { return $responseBody; } if ($httpCode === 207) { return $responseBody; } self::$lastRequestError = "CalDAV $method failed: HTTP $httpCode"; return null; } }