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; } } // 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; } return false; } /** * Check if a component matches the given UID and recurrence criteria. * * @param VEvent $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; } /** * 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; } } /** * 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); } /** * Create a new event in a local ICS file. * * @param string $filePath * @param array{summary:string,date:string,allDay:bool,startTime:string,endTime:string,location:string,description:string} $eventData * @return string The UID of the created event, or empty string on failure */ public static function createEvent(string $filePath, array $eventData): string { if ($filePath === '') return ''; try { $calendar = null; if (is_file($filePath) && is_readable($filePath)) { $raw = @file_get_contents($filePath); if (is_string($raw) && trim($raw) !== '') { $calendar = Reader::read($raw, Reader::OPTION_FORGIVING); if (!($calendar instanceof VCalendar)) { $calendar = null; } } } if ($calendar === null) { $calendar = new VCalendar(); $calendar->PRODID = '-//LuxTools DokuWiki Plugin//EN'; } $uid = self::generateUid(); $props = [ 'UID' => $uid, 'SUMMARY' => $eventData['summary'] ?? '', 'DTSTAMP' => gmdate('Ymd\THis\Z'), ]; if (!empty($eventData['location'])) { $props['LOCATION'] = $eventData['location']; } if (!empty($eventData['description'])) { $props['DESCRIPTION'] = $eventData['description']; } $dateIso = $eventData['date'] ?? ''; $allDay = !empty($eventData['allDay']); if ($allDay) { $props['DTSTART'] = str_replace('-', '', $dateIso); $vevent = $calendar->add('VEVENT', $props); $vevent->DTSTART['VALUE'] = 'DATE'; } else { $startTime = $eventData['startTime'] ?? '00:00'; $endTime = $eventData['endTime'] ?? ''; $props['DTSTART'] = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $startTime) . '00'; $vevent = $calendar->add('VEVENT', $props); if ($endTime !== '') { $vevent->add('DTEND', str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00'); } } $output = $calendar->serialize(); if (!self::atomicWritePublic($filePath, $output)) { return ''; } return $uid; } catch (Throwable $e) { return ''; } } /** * Edit an existing event in a local ICS file. * * Scope controls how recurring event edits are applied: * - 'all': modify the master event directly * - 'this': create/update an occurrence override * - 'future': truncate the master's RRULE before this date and create a new series * * @param string $filePath * @param string $uid * @param string $recurrenceId * @param array{summary:string,date:string,allDay:bool,startTime:string,endTime:string,location:string,description:string} $eventData * @param string $scope 'all', 'this', or 'future' * @return bool */ public static function editEvent(string $filePath, string $uid, string $recurrenceId, array $eventData, string $scope = 'all'): bool { if ($filePath === '' || $uid === '') 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; $dateIso = $eventData['date'] ?? ''; $allDay = !empty($eventData['allDay']); if ($scope === 'future' && $recurrenceId !== '') { // "This and future": truncate master RRULE, then create a new standalone event $edited = self::editFutureOccurrences($calendar, $uid, $dateIso, $eventData); if (!$edited) return false; } elseif ($scope === 'all') { // "All occurrences": find and edit the master event directly $master = self::findMasterByUid($calendar, $uid); if ($master === null) return false; self::applyEventData($master, $eventData); } else { // "This occurrence" (or non-recurring): find/create occurrence override $target = null; // Find matching component foreach ($calendar->select('VEVENT') as $component) { if (!($component instanceof VEvent)) continue; if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) { $target = $component; break; } } // For recurring events without an existing override, create one if ($target === null && $recurrenceId !== '') { $target = self::createEditOccurrenceOverride($calendar, $uid, $recurrenceId, $dateIso, $eventData); } if ($target === null) return false; self::applyEventData($target, $eventData); } $output = $calendar->serialize(); return self::atomicWrite($filePath, $output); } catch (Throwable $e) { return false; } } /** * Find the master VEVENT (the one with RRULE or without RECURRENCE-ID) by UID. */ private static function findMasterByUid(VCalendar $calendar, string $uid): ?VEvent { foreach ($calendar->select('VEVENT') as $component) { if (!($component instanceof VEvent)) continue; $componentUid = trim((string)($component->UID ?? '')); if ($componentUid !== $uid) continue; // Master = has no RECURRENCE-ID if (!isset($component->{'RECURRENCE-ID'})) { return $component; } } return null; } /** * Create an occurrence override VEVENT for a recurring event. */ private static function createEditOccurrenceOverride(VCalendar $calendar, string $uid, string $recurrenceId, string $dateIso, array $eventData): ?VEvent { foreach ($calendar->select('VEVENT') as $component) { if (!($component instanceof VEvent)) continue; $componentUid = trim((string)($component->UID ?? '')); if ($componentUid !== $uid) continue; if (!isset($component->RRULE)) continue; $overrideProps = [ 'UID' => $uid, 'SUMMARY' => $eventData['summary'] ?? '', ]; $isAllDayMaster = strtoupper((string)($component->DTSTART['VALUE'] ?? '')) === 'DATE'; if ($isAllDayMaster) { $recurrenceValue = str_replace('-', '', $dateIso); $overrideProps['RECURRENCE-ID'] = $recurrenceValue; } else { $masterStart = $component->DTSTART->getDateTime(); $recurrenceValue = $dateIso . 'T' . $masterStart->format('His'); $tz = $masterStart->getTimezone(); if ($tz && $tz->getName() !== 'UTC') { $overrideProps['RECURRENCE-ID'] = str_replace('-', '', $recurrenceValue); } else { $overrideProps['RECURRENCE-ID'] = str_replace('-', '', $recurrenceValue) . 'Z'; } } $target = $calendar->add('VEVENT', $overrideProps); if ($isAllDayMaster) { $target->{'RECURRENCE-ID'}['VALUE'] = 'DATE'; } else { $masterStart = $component->DTSTART->getDateTime(); $tz = $masterStart->getTimezone(); if ($tz && $tz->getName() !== 'UTC') { $target->{'RECURRENCE-ID'}['TZID'] = $tz->getName(); } } return $target; } return null; } /** * Apply event data to a VEVENT component (shared by all edit scopes). */ private static function applyEventData(VEvent $target, array $eventData): void { $dateIso = $eventData['date'] ?? ''; $allDay = !empty($eventData['allDay']); $target->SUMMARY = $eventData['summary'] ?? ''; if ($allDay) { $target->DTSTART = str_replace('-', '', $dateIso); $target->DTSTART['VALUE'] = 'DATE'; unset($target->DTEND); } else { $startTime = $eventData['startTime'] ?? '00:00'; $endTime = $eventData['endTime'] ?? ''; $target->DTSTART = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $startTime) . '00'; if ($endTime !== '') { if (isset($target->DTEND)) { $target->DTEND = str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00'; } else { $target->add('DTEND', str_replace('-', '', $dateIso) . 'T' . str_replace(':', '', $endTime) . '00'); } } } if (!empty($eventData['location'])) { $target->LOCATION = $eventData['location']; } else { unset($target->LOCATION); } if (!empty($eventData['description'])) { $target->DESCRIPTION = $eventData['description']; } else { unset($target->DESCRIPTION); } } /** * Handle "this and future" edits: truncate master RRULE before dateIso, * then create a new standalone event with the edited data. */ private static function editFutureOccurrences(VCalendar $calendar, string $uid, string $dateIso, array $eventData): bool { $master = self::findMasterByUid($calendar, $uid); if ($master === null) return false; // Truncate master RRULE to end before this date self::deleteFutureOccurrences($calendar, $uid, $dateIso); // Create a new standalone event with the edited data and a new UID $newProps = [ 'UID' => self::generateUid(), 'SUMMARY' => $eventData['summary'] ?? '', 'DTSTAMP' => gmdate('Ymd\THis\Z'), ]; $newEvent = $calendar->add('VEVENT', $newProps); self::applyEventData($newEvent, $eventData); return true; } /** * Delete an event from a local ICS file. * * @param string $filePath * @param string $uid * @param string $recurrenceId * @param string $dateIso * @param string $scope 'all', 'this', or 'future' * @return bool */ public static function deleteEvent(string $filePath, string $uid, string $recurrenceId, string $dateIso, string $scope = 'all'): bool { if ($filePath === '' || $uid === '') 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; if ($scope === 'all') { // Remove all components with this UID self::removeComponentsByUid($calendar, $uid); } elseif ($scope === 'this') { // For a single occurrence, add EXDATE to master or remove the override self::deleteOccurrence($calendar, $uid, $recurrenceId, $dateIso); } elseif ($scope === 'future') { // Modify RRULE UNTIL on the master self::deleteFutureOccurrences($calendar, $uid, $dateIso); } else { return false; } $output = $calendar->serialize(); return self::atomicWrite($filePath, $output); } catch (Throwable $e) { return false; } } /** * Remove all VEVENT components with a given UID. * * @param VCalendar $calendar * @param string $uid */ protected static function removeComponentsByUid(VCalendar $calendar, string $uid): void { $toRemove = []; foreach ($calendar->select('VEVENT') as $component) { if (!($component instanceof VEvent)) continue; if (trim((string)($component->UID ?? '')) === $uid) { $toRemove[] = $component; } } foreach ($toRemove as $component) { $calendar->remove($component); } } /** * Delete a single occurrence of a recurring event. * * If the occurrence has an override component, remove it and add EXDATE. * If not, just add EXDATE to the master. * * @param VCalendar $calendar * @param string $uid * @param string $recurrenceId * @param string $dateIso */ protected static function deleteOccurrence(VCalendar $calendar, string $uid, string $recurrenceId, string $dateIso): void { // Remove any existing override for this occurrence $toRemove = []; foreach ($calendar->select('VEVENT') as $component) { if (!($component instanceof VEvent)) continue; if (trim((string)($component->UID ?? '')) !== $uid) continue; if (!isset($component->{'RECURRENCE-ID'})) continue; $rid = $component->{'RECURRENCE-ID'}->getDateTime(); if ($rid !== null && $rid->format('Y-m-d') === $dateIso) { $toRemove[] = $component; } } foreach ($toRemove as $component) { $calendar->remove($component); } // Add EXDATE to the master $master = null; foreach ($calendar->select('VEVENT') as $component) { if (!($component instanceof VEvent)) continue; if (trim((string)($component->UID ?? '')) !== $uid) continue; if (isset($component->{'RECURRENCE-ID'})) continue; $master = $component; break; } if ($master !== null) { $isAllDay = strtoupper((string)($master->DTSTART['VALUE'] ?? '')) === 'DATE'; if ($isAllDay) { $exdateValue = str_replace('-', '', $dateIso); $exdate = $master->add('EXDATE', $exdateValue); $exdate['VALUE'] = 'DATE'; } else { $masterStart = $master->DTSTART->getDateTime(); $exdateValue = str_replace('-', '', $dateIso) . 'T' . $masterStart->format('His'); $tz = $masterStart->getTimezone(); if ($tz && $tz->getName() !== 'UTC') { $exdate = $master->add('EXDATE', $exdateValue); $exdate['TZID'] = $tz->getName(); } else { $master->add('EXDATE', $exdateValue . 'Z'); } } } } /** * Delete this and all future occurrences by setting UNTIL on the master RRULE. * * Also removes any override components on or after the given date. * * @param VCalendar $calendar * @param string $uid * @param string $dateIso */ protected static function deleteFutureOccurrences(VCalendar $calendar, string $uid, string $dateIso): void { // Remove overrides on or after dateIso $toRemove = []; foreach ($calendar->select('VEVENT') as $component) { if (!($component instanceof VEvent)) continue; if (trim((string)($component->UID ?? '')) !== $uid) continue; if (!isset($component->{'RECURRENCE-ID'})) continue; $rid = $component->{'RECURRENCE-ID'}->getDateTime(); if ($rid !== null && $rid->format('Y-m-d') >= $dateIso) { $toRemove[] = $component; } } foreach ($toRemove as $component) { $calendar->remove($component); } // Set UNTIL on the master RRULE to the day before $master = null; foreach ($calendar->select('VEVENT') as $component) { if (!($component instanceof VEvent)) continue; if (trim((string)($component->UID ?? '')) !== $uid) continue; if (isset($component->{'RECURRENCE-ID'})) continue; $master = $component; break; } if ($master !== null && isset($master->RRULE)) { $rrule = (string)$master->RRULE; // Remove existing UNTIL or COUNT $rrule = preg_replace('/;?(UNTIL|COUNT)=[^;]*/i', '', $rrule); // Calculate the day before try { $until = new \DateTimeImmutable($dateIso . ' 00:00:00', new \DateTimeZone('UTC')); $until = $until->sub(new \DateInterval('P1D')); $rrule .= ';UNTIL=' . $until->format('Ymd') . 'T235959Z'; } catch (Throwable $e) { return; } $master->RRULE = $rrule; } } /** * Generate a unique UID for a new calendar event. * * @return string */ protected static function generateUid(): string { return sprintf( '%s-%s@luxtools', gmdate('Ymd-His'), bin2hex(random_bytes(8)) ); } /** * 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; } }