538 lines
17 KiB
PHP
538 lines
17 KiB
PHP
<?php
|
|
|
|
namespace dokuwiki\plugin\luxtools;
|
|
|
|
use Sabre\VObject\Component\VCalendar;
|
|
use Sabre\VObject\Reader;
|
|
use Throwable;
|
|
|
|
/**
|
|
* CalDAV client for remote calendar operations.
|
|
*
|
|
* Supports:
|
|
* - Downloading a full calendar collection into a local ICS file (sync)
|
|
* - Updating the STATUS of a single event/task occurrence on the remote server
|
|
*
|
|
* Uses plain PHP curl for HTTP. No additional dependencies required.
|
|
*/
|
|
class CalDavClient
|
|
{
|
|
/** @var int HTTP timeout in seconds */
|
|
protected const TIMEOUT = 30;
|
|
|
|
/** @var string Last request error message for diagnostics */
|
|
protected static string $lastRequestError = '';
|
|
|
|
/**
|
|
* Update the STATUS of a specific event or task on the remote CalDAV server.
|
|
*
|
|
* Fetches the calendar object containing the UID, modifies its status,
|
|
* and PUTs it back using the ETag for conflict detection.
|
|
*
|
|
* @param string $caldavUrl CalDAV collection URL
|
|
* @param string $username HTTP Basic auth username
|
|
* @param string $password HTTP Basic auth password
|
|
* @param string $uid Event/task UID
|
|
* @param string $recurrenceId Recurrence ID (empty for non-recurring)
|
|
* @param string $newStatus New status value (e.g. COMPLETED, TODO)
|
|
* @param string $dateIso Occurrence date YYYY-MM-DD
|
|
* @return string Empty string on success, error message on failure
|
|
*/
|
|
public static function updateEventStatus(
|
|
string $caldavUrl,
|
|
string $username,
|
|
string $password,
|
|
string $uid,
|
|
string $recurrenceId,
|
|
string $newStatus,
|
|
string $dateIso
|
|
): string {
|
|
if ($caldavUrl === '' || $uid === '') return 'Missing CalDAV URL or UID';
|
|
|
|
try {
|
|
// Find the calendar object href for this UID via REPORT
|
|
$objectInfo = self::findObjectByUid($caldavUrl, $username, $password, $uid);
|
|
if ($objectInfo === null) {
|
|
$msg = "CalDAV: Could not find object with UID '$uid' on server";
|
|
dbglog($msg);
|
|
return $msg;
|
|
}
|
|
|
|
$objectHref = $objectInfo['href'];
|
|
$etag = $objectInfo['etag'];
|
|
$calendarData = $objectInfo['data'];
|
|
|
|
// Parse and update the status
|
|
$calendar = Reader::read($calendarData, Reader::OPTION_FORGIVING);
|
|
if (!($calendar instanceof VCalendar)) {
|
|
$msg = "CalDAV: Failed to parse calendar data for UID '$uid'";
|
|
dbglog($msg);
|
|
return $msg;
|
|
}
|
|
|
|
$updated = IcsWriter::applyStatusUpdateToCalendar(
|
|
$calendar, $uid, $recurrenceId, $newStatus, $dateIso
|
|
);
|
|
if (!$updated) {
|
|
$msg = "CalDAV: applyStatusUpdateToCalendar failed for UID '$uid'";
|
|
dbglog($msg);
|
|
return $msg;
|
|
}
|
|
|
|
$newData = $calendar->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 = '<?xml version="1.0" encoding="utf-8" ?>' .
|
|
'<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:prop-filter name="UID">' .
|
|
'<C:text-match collation="i;octet">' . htmlspecialchars($uid, ENT_XML1, 'UTF-8') . '</C:text-match>' .
|
|
'</C:prop-filter>' .
|
|
'</C:comp-filter>' .
|
|
'</C:comp-filter>' .
|
|
'</C:filter>' .
|
|
'</C:calendar-query>';
|
|
|
|
$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 = '<?xml version="1.0" encoding="utf-8" ?>' .
|
|
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
|
|
'<D:prop>' .
|
|
'<C:calendar-data/>' .
|
|
'</D:prop>' .
|
|
'<C:filter>' .
|
|
'<C:comp-filter name="VCALENDAR"/>' .
|
|
'</C:filter>' .
|
|
'</C:calendar-query>';
|
|
|
|
$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;
|
|
}
|
|
}
|