Calendar Sync V1
This commit is contained in:
466
src/CalDavClient.php
Normal file
466
src/CalDavClient.php
Normal file
@@ -0,0 +1,466 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* 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 bool True if the remote update succeeded
|
||||
*/
|
||||
public static function updateEventStatus(
|
||||
string $caldavUrl,
|
||||
string $username,
|
||||
string $password,
|
||||
string $uid,
|
||||
string $recurrenceId,
|
||||
string $newStatus,
|
||||
string $dateIso
|
||||
): bool {
|
||||
if ($caldavUrl === '' || $uid === '') return false;
|
||||
|
||||
try {
|
||||
// Find the calendar object href for this UID via REPORT
|
||||
$objectInfo = self::findObjectByUid($caldavUrl, $username, $password, $uid);
|
||||
if ($objectInfo === null) return false;
|
||||
|
||||
$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)) return false;
|
||||
|
||||
$updated = IcsWriter::applyStatusUpdateToCalendar(
|
||||
$calendar, $uid, $recurrenceId, $newStatus, $dateIso
|
||||
);
|
||||
if (!$updated) return false;
|
||||
|
||||
$newData = $calendar->serialize();
|
||||
|
||||
// PUT the updated object back with If-Match for conflict detection
|
||||
return self::putCalendarObject($objectHref, $username, $password, $newData, $etag);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// Try VTODO filter as well
|
||||
$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="VTODO">' .
|
||||
'<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 and VTODO components
|
||||
foreach ($cal->select('VEVENT') as $component) {
|
||||
$merged->add(clone $component);
|
||||
}
|
||||
foreach ($cal->select('VTODO') 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 bool
|
||||
*/
|
||||
protected static function putCalendarObject(
|
||||
string $href,
|
||||
string $username,
|
||||
string $password,
|
||||
string $data,
|
||||
string $etag
|
||||
): bool {
|
||||
$headers = [
|
||||
'Content-Type: text/calendar; charset=utf-8',
|
||||
];
|
||||
if ($etag !== '') {
|
||||
$headers[] = 'If-Match: ' . $etag;
|
||||
}
|
||||
|
||||
$response = self::request('PUT', $href, $username, $password, $data, $headers);
|
||||
// PUT returns null body but we check by HTTP status via the request method
|
||||
// A successful PUT returns 2xx
|
||||
return $response !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]) : '';
|
||||
// Strip surrounding quotes from etag if present
|
||||
$etag = trim($etag, '"');
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (!function_exists('curl_init')) return null;
|
||||
|
||||
$ch = curl_init();
|
||||
if ($ch === false) 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);
|
||||
curl_close($ch);
|
||||
|
||||
if (!is_string($responseBody)) return null;
|
||||
|
||||
// Accept 2xx and 207 (multistatus) responses
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
return $responseBody;
|
||||
}
|
||||
if ($httpCode === 207) {
|
||||
return $responseBody;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user