746 lines
27 KiB
PHP
746 lines
27 KiB
PHP
<?php
|
|
|
|
namespace dokuwiki\plugin\luxtools;
|
|
|
|
use Sabre\VObject\Component\VCalendar;
|
|
use Sabre\VObject\Component\VEvent;
|
|
use Sabre\VObject\Reader;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Write-back support for local ICS files.
|
|
*
|
|
* Handles updating event status (completion, reopening) in local
|
|
* ICS files while preserving other properties.
|
|
*/
|
|
class IcsWriter
|
|
{
|
|
/**
|
|
* Update the STATUS of an event occurrence in a local ICS file.
|
|
*
|
|
* Sets STATUS to the given value (TODO or COMPLETED).
|
|
*
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|