Calendar Sync V1

This commit is contained in:
2026-03-11 11:44:32 +01:00
parent 87f6839b0d
commit 2d5e9541c2
17 changed files with 3011 additions and 64 deletions

466
src/CalDavClient.php Normal file
View 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;
}
}

86
src/CalendarEvent.php Normal file
View File

@@ -0,0 +1,86 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Normalized calendar event/task for internal use.
*
* All calendar data (from any slot, any source) is converted into this
* structure before rendering or querying.
*/
class CalendarEvent
{
/** @var string Calendar slot key (e.g. 'general', 'maintenance') */
public $slotKey;
/** @var string Unique source event UID */
public $uid;
/** @var string Recurrence ID (empty for non-recurring or master) */
public $recurrenceId;
/** @var string Event summary/title */
public $summary;
/** @var string ISO 8601 start date/time */
public $startIso;
/** @var string ISO 8601 end date/time (may be empty) */
public $endIso;
/** @var bool Whether this is an all-day event */
public $allDay;
/** @var string Formatted time string (HH:MM) or empty for all-day */
public $time;
/** @var string Location (may be empty) */
public $location;
/** @var string Description (may be empty) */
public $description;
/**
* Status: empty, CONFIRMED, TENTATIVE, CANCELLED, TODO, COMPLETED,
* IN-PROCESS, NEEDS-ACTION.
* @var string
*/
public $status;
/** @var string Component type from source: VEVENT or VTODO */
public $componentType;
/** @var string The date (YYYY-MM-DD) this event applies to */
public $dateIso;
/**
* Build a stable completion key for maintenance task tracking.
*
* @return string
*/
public function completionKey(): string
{
return implode('|', [$this->slotKey, $this->uid, $this->dateIso]);
}
/**
* Whether this event/task is marked as completed.
*
* @return bool
*/
public function isCompleted(): bool
{
$s = strtoupper($this->status);
return $s === 'COMPLETED';
}
/**
* Whether this event/task is open (for maintenance filtering).
*
* @return bool
*/
public function isOpen(): bool
{
return !$this->isCompleted();
}
}

553
src/CalendarService.php Normal file
View File

@@ -0,0 +1,553 @@
<?php
namespace dokuwiki\plugin\luxtools;
use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Component\VTodo;
use Sabre\VObject\Reader;
use Throwable;
/**
* Slot-aware calendar service.
*
* Provides normalized event data grouped by slot for rendering,
* widget indicators, task list queries, and completion tracking.
*/
class CalendarService
{
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|dateIso" */
protected static $dayCache = [];
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|all" for open tasks */
protected static $taskCache = [];
/**
* Get all normalized events for a given date across all enabled slots.
*
* @param CalendarSlot[] $slots Keyed by slot key
* @param string $dateIso YYYY-MM-DD
* @return CalendarEvent[] Sorted: all-day first, then by time, then by title
*/
public static function eventsForDate(array $slots, string $dateIso): array
{
if (!ChronoID::isIsoDate($dateIso)) return [];
$all = [];
foreach ($slots as $slot) {
if (!$slot->isEnabled()) continue;
$events = self::slotEventsForDate($slot, $dateIso);
foreach ($events as $event) {
$all[] = $event;
}
}
usort($all, [self::class, 'compareEvents']);
return $all;
}
/**
* Get events for a specific slot and date.
*
* @param CalendarSlot $slot
* @param string $dateIso
* @return CalendarEvent[]
*/
public static function slotEventsForDate(CalendarSlot $slot, string $dateIso): array
{
if (!ChronoID::isIsoDate($dateIso)) return [];
if (!$slot->isEnabled()) return [];
$cacheKey = $slot->getKey() . '|' . $dateIso;
if (isset(self::$dayCache[$cacheKey])) {
return self::$dayCache[$cacheKey];
}
$events = [];
$file = $slot->getFile();
if ($file !== '' && is_file($file) && is_readable($file)) {
$events = self::parseEventsFromFile($file, $slot->getKey(), $dateIso);
}
self::$dayCache[$cacheKey] = $events;
return $events;
}
/**
* Get events for a specific slot on a date, grouped by slot key.
*
* @param CalendarSlot[] $slots
* @param string $dateIso
* @return array<string,CalendarEvent[]> Keyed by slot key
*/
public static function eventsForDateGrouped(array $slots, string $dateIso): array
{
$grouped = [];
foreach ($slots as $slot) {
if (!$slot->isEnabled()) continue;
$events = self::slotEventsForDate($slot, $dateIso);
if ($events !== []) {
$grouped[$slot->getKey()] = $events;
}
}
return $grouped;
}
/**
* Check whether a slot has any events on a given date.
*
* @param CalendarSlot $slot
* @param string $dateIso
* @return bool
*/
public static function slotHasEventsOnDate(CalendarSlot $slot, string $dateIso): bool
{
return self::slotEventsForDate($slot, $dateIso) !== [];
}
/**
* Get all open maintenance tasks due up to (and including) today.
*
* @param CalendarSlot $maintenanceSlot
* @param string $todayIso YYYY-MM-DD
* @return CalendarEvent[] Sorted: overdue first, then today, then by title
*/
public static function openMaintenanceTasks(CalendarSlot $maintenanceSlot, string $todayIso): array
{
if (!$maintenanceSlot->isEnabled()) return [];
if (!ChronoID::isIsoDate($todayIso)) return [];
$file = $maintenanceSlot->getFile();
if ($file === '' || !is_file($file) || !is_readable($file)) return [];
$cacheKey = $maintenanceSlot->getKey() . '|tasks|' . $todayIso;
if (isset(self::$taskCache[$cacheKey])) {
return self::$taskCache[$cacheKey];
}
$tasks = self::parseAllTasksFromFile($file, $maintenanceSlot->getKey(), $todayIso);
// Filter: only non-completed, due today or earlier
$open = [];
foreach ($tasks as $task) {
if ($task->isCompleted()) continue;
// dateIso is the date the task falls on
if ($task->dateIso > $todayIso) continue;
$open[] = $task;
}
// Sort: overdue first, then today, then by time, then by title
usort($open, static function (CalendarEvent $a, CalendarEvent $b) use ($todayIso): int {
$aOverdue = $a->dateIso < $todayIso;
$bOverdue = $b->dateIso < $todayIso;
if ($aOverdue !== $bOverdue) {
return $aOverdue ? -1 : 1;
}
$dateCmp = strcmp($a->dateIso, $b->dateIso);
if ($dateCmp !== 0) return $dateCmp;
$timeCmp = strcmp($a->time, $b->time);
if ($timeCmp !== 0) return $timeCmp;
return strcmp($a->summary, $b->summary);
});
self::$taskCache[$cacheKey] = $open;
return $open;
}
/**
* Get slot-level day indicator data for a whole month.
*
* @param CalendarSlot[] $slots
* @param int $year
* @param int $month
* @return array<string,string[]> date => [slotKey, ...]
*/
public static function monthIndicators(array $slots, int $year, int $month): array
{
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
$indicators = [];
foreach ($slots as $slot) {
if (!$slot->isEnabled()) continue;
for ($day = 1; $day <= $daysInMonth; $day++) {
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
if (self::slotHasEventsOnDate($slot, $dateIso)) {
$indicators[$dateIso][] = $slot->getKey();
}
}
}
return $indicators;
}
/**
* Parse events from a local ICS file for a specific date.
*
* @param string $file
* @param string $slotKey
* @param string $dateIso
* @return CalendarEvent[]
*/
protected static function parseEventsFromFile(string $file, string $slotKey, string $dateIso): array
{
$raw = @file_get_contents($file);
if (!is_string($raw) || trim($raw) === '') return [];
try {
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($component instanceof VCalendar)) return [];
$utc = new DateTimeZone('UTC');
$rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc);
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
$rangeEnd = $rangeStart->add(new DateInterval('P3D'));
$expanded = $component->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) return [];
return self::collectFromCalendar($expanded, $slotKey, $dateIso);
} catch (Throwable $e) {
return [];
}
}
/**
* Parse all tasks (VEVENT with STATUS or VTODO) from a maintenance file,
* expanding recurrences up to the given date.
*
* @param string $file
* @param string $slotKey
* @param string $todayIso
* @return CalendarEvent[]
*/
protected static function parseAllTasksFromFile(string $file, string $slotKey, string $todayIso): array
{
$raw = @file_get_contents($file);
if (!is_string($raw) || trim($raw) === '') return [];
try {
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($component instanceof VCalendar)) return [];
// Expand from a reasonable lookback to tomorrow
$utc = new DateTimeZone('UTC');
$rangeStart = new DateTimeImmutable('2020-01-01 00:00:00', $utc);
$rangeEnd = new DateTimeImmutable($todayIso . ' 00:00:00', $utc);
$rangeEnd = $rangeEnd->add(new DateInterval('P1D'));
$expanded = $component->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) return [];
$tasks = [];
// Collect VEVENTs
foreach ($expanded->select('VEVENT') as $vevent) {
if (!($vevent instanceof VEvent)) continue;
$event = self::normalizeVEvent($vevent, $slotKey);
if ($event !== null) {
$tasks[] = $event;
}
}
// Collect VTODOs
foreach ($expanded->select('VTODO') as $vtodo) {
if (!($vtodo instanceof VTodo)) continue;
$event = self::normalizeVTodo($vtodo, $slotKey);
if ($event !== null) {
$tasks[] = $event;
}
}
return $tasks;
} catch (Throwable $e) {
return [];
}
}
/**
* Collect normalized events from an expanded VCalendar for a specific date.
*
* @param VCalendar $calendar
* @param string $slotKey
* @param string $dateIso
* @return CalendarEvent[]
*/
protected static function collectFromCalendar(VCalendar $calendar, string $slotKey, string $dateIso): array
{
$result = [];
$seen = [];
// VEVENTs
foreach ($calendar->select('VEVENT') as $vevent) {
if (!($vevent instanceof VEvent)) continue;
$event = self::normalizeVEventForDay($vevent, $slotKey, $dateIso);
if ($event === null) continue;
$dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary;
if (isset($seen[$dedupeKey])) continue;
$seen[$dedupeKey] = true;
$result[] = $event;
}
// VTODOs
foreach ($calendar->select('VTODO') as $vtodo) {
if (!($vtodo instanceof VTodo)) continue;
$event = self::normalizeVTodoForDay($vtodo, $slotKey, $dateIso);
if ($event === null) continue;
$dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary;
if (isset($seen[$dedupeKey])) continue;
$seen[$dedupeKey] = true;
$result[] = $event;
}
usort($result, [self::class, 'compareEvents']);
return $result;
}
/**
* Normalize a VEVENT for a specific day into a CalendarEvent.
*
* @param VEvent $vevent
* @param string $slotKey
* @param string $dateIso
* @return CalendarEvent|null
*/
protected static function normalizeVEventForDay(VEvent $vevent, string $slotKey, string $dateIso): ?CalendarEvent
{
if (!isset($vevent->DTSTART)) return null;
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
$start = self::toImmutable($vevent->DTSTART->getDateTime());
if ($start === null) return null;
$end = self::resolveEnd($vevent, $start, $isAllDay);
if (!self::intersectsDay($start, $end, $isAllDay, $dateIso)) return null;
$event = new CalendarEvent();
$event->slotKey = $slotKey;
$event->uid = trim((string)($vevent->UID ?? ''));
$event->recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? trim((string)$vevent->{'RECURRENCE-ID'}) : '';
$event->summary = trim((string)($vevent->SUMMARY ?? ''));
if ($event->summary === '') $event->summary = '(ohne Titel)';
$event->startIso = $start->format(DateTimeInterface::ATOM);
$event->endIso = $end->format(DateTimeInterface::ATOM);
$event->allDay = $isAllDay;
$event->time = $isAllDay ? '' : $start->format('H:i');
$event->location = trim((string)($vevent->LOCATION ?? ''));
$event->description = trim((string)($vevent->DESCRIPTION ?? ''));
$event->status = strtoupper(trim((string)($vevent->STATUS ?? '')));
$event->componentType = 'VEVENT';
$event->dateIso = $dateIso;
return $event;
}
/**
* Normalize a VEVENT into a CalendarEvent (without day filtering).
*
* @param VEvent $vevent
* @param string $slotKey
* @return CalendarEvent|null
*/
protected static function normalizeVEvent(VEvent $vevent, string $slotKey): ?CalendarEvent
{
if (!isset($vevent->DTSTART)) return null;
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
$start = self::toImmutable($vevent->DTSTART->getDateTime());
if ($start === null) return null;
$end = self::resolveEnd($vevent, $start, $isAllDay);
$event = new CalendarEvent();
$event->slotKey = $slotKey;
$event->uid = trim((string)($vevent->UID ?? ''));
$event->recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? trim((string)$vevent->{'RECURRENCE-ID'}) : '';
$event->summary = trim((string)($vevent->SUMMARY ?? ''));
if ($event->summary === '') $event->summary = '(ohne Titel)';
$event->startIso = $start->format(DateTimeInterface::ATOM);
$event->endIso = $end->format(DateTimeInterface::ATOM);
$event->allDay = $isAllDay;
$event->time = $isAllDay ? '' : $start->format('H:i');
$event->location = trim((string)($vevent->LOCATION ?? ''));
$event->description = trim((string)($vevent->DESCRIPTION ?? ''));
$event->status = strtoupper(trim((string)($vevent->STATUS ?? '')));
$event->componentType = 'VEVENT';
$event->dateIso = $start->format('Y-m-d');
return $event;
}
/**
* Normalize a VTODO for a specific day.
*
* @param VTodo $vtodo
* @param string $slotKey
* @param string $dateIso
* @return CalendarEvent|null
*/
protected static function normalizeVTodoForDay(VTodo $vtodo, string $slotKey, string $dateIso): ?CalendarEvent
{
$event = self::normalizeVTodo($vtodo, $slotKey);
if ($event === null) return null;
// Check if the VTODO's due/start date matches this day
if ($event->dateIso !== $dateIso) return null;
return $event;
}
/**
* Normalize a VTODO into a CalendarEvent.
*
* @param VTodo $vtodo
* @param string $slotKey
* @return CalendarEvent|null
*/
protected static function normalizeVTodo(VTodo $vtodo, string $slotKey): ?CalendarEvent
{
// VTODO uses DUE or DTSTART for date
$dateProperty = $vtodo->DUE ?? $vtodo->DTSTART ?? null;
if ($dateProperty === null) return null;
$isAllDay = strtoupper((string)($dateProperty['VALUE'] ?? '')) === 'DATE';
$dt = self::toImmutable($dateProperty->getDateTime());
if ($dt === null) return null;
$event = new CalendarEvent();
$event->slotKey = $slotKey;
$event->uid = trim((string)($vtodo->UID ?? ''));
$event->recurrenceId = isset($vtodo->{'RECURRENCE-ID'}) ? trim((string)$vtodo->{'RECURRENCE-ID'}) : '';
$event->summary = trim((string)($vtodo->SUMMARY ?? ''));
if ($event->summary === '') $event->summary = '(ohne Titel)';
$event->startIso = $dt->format(DateTimeInterface::ATOM);
$event->endIso = '';
$event->allDay = $isAllDay;
$event->time = $isAllDay ? '' : $dt->format('H:i');
$event->location = trim((string)($vtodo->LOCATION ?? ''));
$event->description = trim((string)($vtodo->DESCRIPTION ?? ''));
$status = strtoupper(trim((string)($vtodo->STATUS ?? '')));
// Map VTODO statuses to our model
if ($status === 'COMPLETED') {
$event->status = 'COMPLETED';
} elseif ($status === 'IN-PROCESS' || $status === 'NEEDS-ACTION' || $status === '') {
$event->status = 'TODO';
} else {
$event->status = $status;
}
$event->componentType = 'VTODO';
$event->dateIso = $dt->format('Y-m-d');
return $event;
}
/**
* Resolve the end date/time for a VEVENT.
*
* @param VEvent $vevent
* @param DateTimeImmutable $start
* @param bool $isAllDay
* @return DateTimeImmutable
*/
protected static function resolveEnd(VEvent $vevent, DateTimeImmutable $start, bool $isAllDay): DateTimeImmutable
{
if (isset($vevent->DTEND)) {
$end = self::toImmutable($vevent->DTEND->getDateTime());
if ($end !== null) return $end;
}
if (isset($vevent->DURATION)) {
try {
$duration = $vevent->DURATION->getDateInterval();
if ($duration instanceof DateInterval) {
return $start->add($duration);
}
} catch (Throwable $e) {
// fall through
}
}
return $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
}
/**
* Check if a date range intersects a given day.
*
* @param DateTimeImmutable $start
* @param DateTimeImmutable $end
* @param bool $isAllDay
* @param string $dateIso
* @return bool
*/
protected static function intersectsDay(
DateTimeImmutable $start,
DateTimeImmutable $end,
bool $isAllDay,
string $dateIso
): bool {
$eventTimezone = $start->getTimezone();
$dayStart = new DateTimeImmutable($dateIso . ' 00:00:00', $eventTimezone);
$dayEnd = $dayStart->add(new DateInterval('P1D'));
if ($end <= $start) {
$end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
}
$intersects = ($start < $dayEnd) && ($end > $dayStart);
if (!$intersects && !$isAllDay && $start >= $dayStart && $start < $dayEnd && $end == $start) {
$intersects = true;
}
return $intersects;
}
/**
* Compare two CalendarEvents for sorting.
*
* @param CalendarEvent $a
* @param CalendarEvent $b
* @return int
*/
protected static function compareEvents(CalendarEvent $a, CalendarEvent $b): int
{
if ($a->allDay !== $b->allDay) {
return $a->allDay ? -1 : 1;
}
$timeCmp = strcmp($a->time, $b->time);
if ($timeCmp !== 0) return $timeCmp;
return strcmp($a->summary, $b->summary);
}
/**
* Convert a DateTimeInterface to DateTimeImmutable.
*
* @param DateTimeInterface $dt
* @return DateTimeImmutable|null
*/
protected static function toImmutable(DateTimeInterface $dt): ?DateTimeImmutable
{
if ($dt instanceof DateTimeImmutable) return $dt;
$immutable = DateTimeImmutable::createFromFormat('U', (string)$dt->getTimestamp());
if (!($immutable instanceof DateTimeImmutable)) return null;
return $immutable->setTimezone($dt->getTimezone());
}
/**
* Clear all runtime caches.
*/
public static function clearCache(): void
{
self::$dayCache = [];
self::$taskCache = [];
}
}

171
src/CalendarSlot.php Normal file
View File

@@ -0,0 +1,171 @@
<?php
namespace dokuwiki\plugin\luxtools;
/**
* Represents one calendar slot configuration.
*
* Each slot has a stable key, a human-readable label, local/remote source
* configuration, a display color, and a derived enabled state.
*/
class CalendarSlot
{
/** @var string[] Ordered list of all supported slot keys */
public const SLOT_KEYS = ['general', 'maintenance', 'slot3', 'slot4'];
/** @var array<string,string> Human-readable labels for slot keys */
public const SLOT_LABELS = [
'general' => 'General',
'maintenance' => 'Maintenance',
'slot3' => 'Slot 3',
'slot4' => 'Slot 4',
];
/** @var string */
protected $key;
/** @var string */
protected $label;
/** @var string Local ICS file path */
protected $file;
/** @var string CalDAV URL */
protected $caldavUrl;
/** @var string CalDAV username */
protected $username;
/** @var string CalDAV password */
protected $password;
/** @var string CSS color for widget indicators */
protected $color;
/**
* @param string $key
* @param string $file
* @param string $caldavUrl
* @param string $username
* @param string $password
* @param string $color
*/
public function __construct(
string $key,
string $file = '',
string $caldavUrl = '',
string $username = '',
string $password = '',
string $color = ''
) {
$this->key = $key;
$this->label = self::SLOT_LABELS[$key] ?? $key;
$this->file = trim($file);
$this->caldavUrl = trim($caldavUrl);
$this->username = trim($username);
$this->password = trim($password);
$this->color = trim($color);
}
public function getKey(): string
{
return $this->key;
}
public function getLabel(): string
{
return $this->label;
}
public function getFile(): string
{
return $this->file;
}
public function getCaldavUrl(): string
{
return $this->caldavUrl;
}
public function getUsername(): string
{
return $this->username;
}
public function getPassword(): string
{
return $this->password;
}
public function getColor(): string
{
return $this->color;
}
/**
* A slot is enabled if it has a local file path or a CalDAV URL.
*
* @return bool
*/
public function isEnabled(): bool
{
return $this->file !== '' || $this->caldavUrl !== '';
}
/**
* Whether this slot has a usable local ICS file.
*
* @return bool
*/
public function hasLocalFile(): bool
{
if ($this->file === '') return false;
return is_file($this->file) && is_readable($this->file);
}
/**
* Whether this slot has a configured remote CalDAV source.
*
* @return bool
*/
public function hasRemoteSource(): bool
{
return $this->caldavUrl !== '';
}
/**
* Load all configured calendar slots from plugin configuration.
*
* @param object $plugin Plugin instance with getConf() method
* @return CalendarSlot[] Keyed by slot key
*/
public static function loadAll($plugin): array
{
$slots = [];
foreach (self::SLOT_KEYS as $key) {
$slots[$key] = new self(
$key,
(string)$plugin->getConf('calendar_' . $key . '_file'),
(string)$plugin->getConf('calendar_' . $key . '_caldav_url'),
(string)$plugin->getConf('calendar_' . $key . '_username'),
(string)$plugin->getConf('calendar_' . $key . '_password'),
(string)$plugin->getConf('calendar_' . $key . '_color')
);
}
return $slots;
}
/**
* Load only enabled calendar slots.
*
* @param object $plugin Plugin instance with getConf() method
* @return CalendarSlot[] Keyed by slot key
*/
public static function loadEnabled($plugin): array
{
$all = self::loadAll($plugin);
return array_filter($all, static function (CalendarSlot $slot): bool {
return $slot->isEnabled();
});
}
}

View File

@@ -13,10 +13,17 @@ class ChronologicalCalendarWidget
* @param int $year
* @param int $month
* @param string $baseNs
* @param array<string,string[]> $indicators date => [slotKey, ...] from CalendarService::monthIndicators()
* @param array<string,string> $slotColors slotKey => CSS color
* @return string
*/
public static function render(int $year, int $month, string $baseNs = 'chronological'): string
{
public static function render(
int $year,
int $month,
string $baseNs = 'chronological',
array $indicators = [],
array $slotColors = []
): string {
if (!self::isValidMonth($year, $month)) return '';
$firstDayTs = mktime(0, 0, 0, $month, 1, $year);
@@ -109,6 +116,19 @@ class ChronologicalCalendarWidget
$classes = 'luxtools-calendar-day';
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">';
// Render slot indicators if any
$dayIndicators = $indicators[$date] ?? [];
if ($dayIndicators !== []) {
$html .= '<div class="luxtools-calendar-indicators">';
foreach ($dayIndicators as $slotKey) {
$color = $slotColors[$slotKey] ?? '';
$style = ($color !== '') ? ' style="background-color:' . hsc($color) . '"' : '';
$html .= '<span class="luxtools-calendar-indicator luxtools-indicator-' . hsc($slotKey) . '"' . $style . '></span>';
}
$html .= '</div>';
}
if ($dayId !== null && function_exists('html_wikilink')) {
$html .= (string)html_wikilink($dayId, (string)$dayNumber);
} else {

434
src/IcsWriter.php Normal file
View File

@@ -0,0 +1,434 @@
<?php
namespace dokuwiki\plugin\luxtools;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Component\VTodo;
use Sabre\VObject\Reader;
use Throwable;
/**
* Write-back support for local ICS files.
*
* Handles updating event/task status (completion, reopening) in local
* ICS files while preserving the original component type and other properties.
*/
class IcsWriter
{
/**
* Update the STATUS of an event or task occurrence in a local ICS file.
*
* For VEVENT: sets STATUS to the given value (TODO or COMPLETED).
* For VTODO: sets STATUS and, when completing, sets COMPLETED timestamp;
* when reopening, removes the COMPLETED property.
*
* For recurring events, this writes an override/exception for the specific
* occurrence rather than modifying the master event.
*
* @param string $filePath Absolute path to the local ICS file
* @param string $uid Event 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 (for recurring event identification)
* @return bool True if the file was updated successfully
*/
public static function updateEventStatus(
string $filePath,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
if ($uid === '' || $filePath === '') return false;
if (!is_file($filePath) || !is_writable($filePath)) return false;
$raw = @file_get_contents($filePath);
if (!is_string($raw) || trim($raw) === '') return false;
try {
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($calendar instanceof VCalendar)) return false;
$updated = self::applyStatusUpdate($calendar, $uid, $recurrenceId, $newStatus, $dateIso);
if (!$updated) return false;
$output = $calendar->serialize();
return self::atomicWrite($filePath, $output);
} catch (Throwable $e) {
return false;
}
}
/**
* Apply a status update to the matching component in the calendar.
*
* Public alias for use by CalDavClient when modifying remote calendar data.
*
* @param VCalendar $calendar
* @param string $uid
* @param string $recurrenceId
* @param string $newStatus
* @param string $dateIso
* @return bool True if a component was updated
*/
public static function applyStatusUpdateToCalendar(
VCalendar $calendar,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
return self::applyStatusUpdate($calendar, $uid, $recurrenceId, $newStatus, $dateIso);
}
/**
* Apply a status update to the matching component in the calendar.
*
* @param VCalendar $calendar
* @param string $uid
* @param string $recurrenceId
* @param string $newStatus
* @param string $dateIso
* @return bool True if a component was updated
*/
protected static function applyStatusUpdate(
VCalendar $calendar,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
// Try VEVENT first
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
self::setVEventStatus($component, $newStatus);
return true;
}
}
// Try VTODO
foreach ($calendar->select('VTODO') as $component) {
if (!($component instanceof VTodo)) continue;
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
self::setVTodoStatus($component, $newStatus);
return true;
}
}
// For recurring events without a matching override, create one
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) continue;
// This is the master event; check if it has RRULE (recurring)
if (!isset($component->RRULE)) continue;
// Create an occurrence override
$override = self::createOccurrenceOverride($calendar, $component, $newStatus, $dateIso);
if ($override !== null) return true;
}
// Same for VTODO with RRULE
foreach ($calendar->select('VTODO') as $component) {
if (!($component instanceof VTodo)) continue;
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) continue;
if (!isset($component->RRULE)) continue;
$override = self::createVTodoOccurrenceOverride($calendar, $component, $newStatus, $dateIso);
if ($override !== null) return true;
}
return false;
}
/**
* Check if a component matches the given UID and recurrence criteria.
*
* @param VEvent|VTodo $component
* @param string $uid
* @param string $recurrenceId
* @param string $dateIso
* @return bool
*/
protected static function matchesComponent($component, string $uid, string $recurrenceId, string $dateIso): bool
{
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) return false;
// If we have a specific recurrence ID, match it
if ($recurrenceId !== '') {
$componentRid = isset($component->{'RECURRENCE-ID'}) ? trim((string)$component->{'RECURRENCE-ID'}) : '';
return $componentRid === $recurrenceId;
}
// For non-recurring events (no RRULE, no RECURRENCE-ID), match by UID alone
if (!isset($component->RRULE) && !isset($component->{'RECURRENCE-ID'})) {
return true;
}
// For a specific occurrence override already in the file
if (isset($component->{'RECURRENCE-ID'})) {
$ridDt = $component->{'RECURRENCE-ID'}->getDateTime();
if ($ridDt !== null && $ridDt->format('Y-m-d') === $dateIso) {
return true;
}
}
return false;
}
/**
* Set the STATUS property on a VEVENT.
*
* @param VEvent $vevent
* @param string $newStatus
*/
protected static function setVEventStatus(VEvent $vevent, string $newStatus): void
{
$vevent->STATUS = $newStatus;
}
/**
* Set the STATUS property on a VTODO with native completion semantics.
*
* @param VTodo $vtodo
* @param string $newStatus
*/
protected static function setVTodoStatus(VTodo $vtodo, string $newStatus): void
{
if ($newStatus === 'COMPLETED') {
$vtodo->STATUS = 'COMPLETED';
// Set COMPLETED timestamp per RFC 5545
$vtodo->COMPLETED = gmdate('Ymd\THis\Z');
// Set PERCENT-COMPLETE to 100
$vtodo->{'PERCENT-COMPLETE'} = '100';
} else {
// Reopening
$vtodo->STATUS = 'NEEDS-ACTION';
// Remove COMPLETED timestamp
if (isset($vtodo->COMPLETED)) {
unset($vtodo->COMPLETED);
}
if (isset($vtodo->{'PERCENT-COMPLETE'})) {
unset($vtodo->{'PERCENT-COMPLETE'});
}
}
}
/**
* Create an occurrence override for a recurring VEVENT.
*
* @param VCalendar $calendar
* @param VEvent $master
* @param string $newStatus
* @param string $dateIso
* @return VEvent|null
*/
protected static function createOccurrenceOverride(
VCalendar $calendar,
VEvent $master,
string $newStatus,
string $dateIso
): ?VEvent {
try {
$isAllDay = strtoupper((string)($master->DTSTART['VALUE'] ?? '')) === 'DATE';
$props = [
'UID' => (string)$master->UID,
'SUMMARY' => (string)($master->SUMMARY ?? ''),
'STATUS' => $newStatus,
];
if ($isAllDay) {
$recurrenceValue = str_replace('-', '', $dateIso);
$props['DTSTART'] = $recurrenceValue;
$props['RECURRENCE-ID'] = $recurrenceValue;
// Set VALUE=DATE on the properties
$override = $calendar->add('VEVENT', $props);
$override->DTSTART['VALUE'] = 'DATE';
$override->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
} else {
// Use the master's time for the occurrence
$masterStart = $master->DTSTART->getDateTime();
$recurrenceValue = $dateIso . 'T' . $masterStart->format('His');
$tz = $masterStart->getTimezone();
if ($tz && $tz->getName() !== 'UTC') {
$props['DTSTART'] = $recurrenceValue;
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VEVENT', $props);
$override->DTSTART['TZID'] = $tz->getName();
$override->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
} else {
$recurrenceValue .= 'Z';
$props['DTSTART'] = $recurrenceValue;
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VEVENT', $props);
}
}
// Copy duration or DTEND if present
if (isset($master->DTEND)) {
$duration = $master->DTSTART->getDateTime()->diff($master->DTEND->getDateTime());
$startDt = $override->DTSTART->getDateTime();
$endDt = $startDt->add($duration);
if ($isAllDay) {
$override->add('DTEND', $endDt->format('Ymd'));
$override->DTEND['VALUE'] = 'DATE';
} else {
$override->add('DTEND', $endDt);
}
} elseif (isset($master->DURATION)) {
$override->DURATION = (string)$master->DURATION;
}
// Copy LOCATION and DESCRIPTION if present
if (isset($master->LOCATION)) {
$override->LOCATION = (string)$master->LOCATION;
}
if (isset($master->DESCRIPTION)) {
$override->DESCRIPTION = (string)$master->DESCRIPTION;
}
return $override;
} catch (Throwable $e) {
return null;
}
}
/**
* Create an occurrence override for a recurring VTODO.
*
* @param VCalendar $calendar
* @param VTodo $master
* @param string $newStatus
* @param string $dateIso
* @return VTodo|null
*/
protected static function createVTodoOccurrenceOverride(
VCalendar $calendar,
VTodo $master,
string $newStatus,
string $dateIso
): ?VTodo {
try {
$dateProperty = $master->DUE ?? $master->DTSTART ?? null;
$isAllDay = $dateProperty !== null && strtoupper((string)($dateProperty['VALUE'] ?? '')) === 'DATE';
$props = [
'UID' => (string)$master->UID,
'SUMMARY' => (string)($master->SUMMARY ?? ''),
];
if ($isAllDay) {
$recurrenceValue = str_replace('-', '', $dateIso);
if (isset($master->DTSTART)) {
$props['DTSTART'] = $recurrenceValue;
}
if (isset($master->DUE)) {
$props['DUE'] = $recurrenceValue;
}
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VTODO', $props);
if (isset($override->DTSTART)) {
$override->DTSTART['VALUE'] = 'DATE';
}
if (isset($override->DUE)) {
$override->DUE['VALUE'] = 'DATE';
}
$override->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
} else {
$dt = $dateProperty->getDateTime();
$recurrenceValue = $dateIso . 'T' . $dt->format('His');
$tz = $dt->getTimezone();
if ($tz && $tz->getName() !== 'UTC') {
if (isset($master->DTSTART)) {
$props['DTSTART'] = $recurrenceValue;
}
if (isset($master->DUE)) {
$props['DUE'] = $recurrenceValue;
}
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VTODO', $props);
if (isset($override->DTSTART)) {
$override->DTSTART['TZID'] = $tz->getName();
}
if (isset($override->DUE)) {
$override->DUE['TZID'] = $tz->getName();
}
$override->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
} else {
$recurrenceValue .= 'Z';
if (isset($master->DTSTART)) {
$props['DTSTART'] = $recurrenceValue;
}
if (isset($master->DUE)) {
$props['DUE'] = $recurrenceValue;
}
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VTODO', $props);
}
}
self::setVTodoStatus($override, $newStatus);
if (isset($master->LOCATION)) {
$override->LOCATION = (string)$master->LOCATION;
}
if (isset($master->DESCRIPTION)) {
$override->DESCRIPTION = (string)$master->DESCRIPTION;
}
return $override;
} catch (Throwable $e) {
return null;
}
}
/**
* Public atomic file write for use by CalDavClient sync.
*
* @param string $filePath
* @param string $content
* @return bool
*/
public static function atomicWritePublic(string $filePath, string $content): bool
{
$dir = dirname($filePath);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return self::atomicWrite($filePath, $content);
}
/**
* Atomic file write using a temp file and rename.
*
* @param string $filePath
* @param string $content
* @return bool
*/
protected static function atomicWrite(string $filePath, string $content): bool
{
$dir = dirname($filePath);
$tmpFile = $dir . '/.luxtools_tmp_' . getmypid() . '_' . mt_rand();
if (@file_put_contents($tmpFile, $content, LOCK_EX) === false) {
@unlink($tmpFile);
return false;
}
if (!@rename($tmpFile, $filePath)) {
@unlink($tmpFile);
return false;
}
return true;
}
}