improve calendar features

This commit is contained in:
2026-03-18 14:17:38 +01:00
parent 14d4a2895a
commit 975e195ae3
13 changed files with 2274 additions and 138 deletions

View File

@@ -265,6 +265,459 @@ class IcsWriter
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.
*