improve calendar features
This commit is contained in:
@@ -395,6 +395,68 @@ class CalDavClient
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public wrapper for findObjectByUid.
|
||||
*
|
||||
* @param string $caldavUrl
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $uid
|
||||
* @return array{href: string, etag: string, data: string}|null
|
||||
*/
|
||||
public static function findObjectByUidPublic(
|
||||
string $caldavUrl,
|
||||
string $username,
|
||||
string $password,
|
||||
string $uid
|
||||
): ?array {
|
||||
return self::findObjectByUid($caldavUrl, $username, $password, $uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public wrapper for putCalendarObject.
|
||||
*
|
||||
* @param string $href
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $data
|
||||
* @param string $etag
|
||||
* @return string Empty string on success, error on failure
|
||||
*/
|
||||
public static function putCalendarObjectPublic(
|
||||
string $href,
|
||||
string $username,
|
||||
string $password,
|
||||
string $data,
|
||||
string $etag
|
||||
): string {
|
||||
return self::putCalendarObject($href, $username, $password, $data, $etag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a calendar object from the server.
|
||||
*
|
||||
* @param string $href Full URL of the calendar object
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $etag ETag for If-Match header (empty to skip)
|
||||
* @return bool True on success
|
||||
*/
|
||||
public static function deleteCalendarObject(
|
||||
string $href,
|
||||
string $username,
|
||||
string $password,
|
||||
string $etag
|
||||
): bool {
|
||||
$headers = [];
|
||||
if ($etag !== '') {
|
||||
$headers[] = 'If-Match: ' . $etag;
|
||||
}
|
||||
|
||||
$response = self::request('DELETE', $href, $username, $password, '', $headers);
|
||||
return $response !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an HTTP request to a CalDAV server.
|
||||
*
|
||||
|
||||
40
src/CalendarSyncService.php
Normal file
40
src/CalendarSyncService.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
/**
|
||||
* Centralized calendar sync service.
|
||||
*
|
||||
* Extracts sync logic from action.php so it can be reused
|
||||
* by the admin page, the calendar_sync syntax, and future
|
||||
* CLI entry points without duplicating code.
|
||||
*/
|
||||
class CalendarSyncService
|
||||
{
|
||||
/**
|
||||
* Run a full sync for all enabled slots that have a remote source.
|
||||
*
|
||||
* @param CalendarSlot[] $slots Keyed by slot key
|
||||
* @return array{ok: bool, message: string, results: array<string,bool>}
|
||||
*/
|
||||
public static function syncAll(array $slots): array
|
||||
{
|
||||
$results = [];
|
||||
$hasErrors = false;
|
||||
|
||||
foreach ($slots as $slot) {
|
||||
if (!$slot->hasRemoteSource()) continue;
|
||||
|
||||
$ok = CalDavClient::syncSlot($slot);
|
||||
$results[$slot->getKey()] = $ok;
|
||||
if (!$ok) $hasErrors = true;
|
||||
}
|
||||
|
||||
CalendarService::clearCache();
|
||||
|
||||
return [
|
||||
'ok' => !$hasErrors,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,13 @@ class ChronologicalCalendarWidget
|
||||
$classes .= ' luxtools-calendar-day-has-events';
|
||||
}
|
||||
|
||||
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">';
|
||||
// Encode day event data as JSON for the day popup
|
||||
$dayEventsJson = self::encodeDayEventsJson($events);
|
||||
$dayDataAttr = $dayEventsJson !== '[]'
|
||||
? ' data-luxtools-day-events="' . hsc($dayEventsJson) . '"'
|
||||
: '';
|
||||
|
||||
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '" data-luxtools-day="1"' . $dayDataAttr . '>';
|
||||
|
||||
if ($size === 'small') {
|
||||
$dayIndicators = $indicators[$date] ?? [];
|
||||
@@ -277,7 +283,47 @@ class ChronologicalCalendarWidget
|
||||
}
|
||||
$attrs .= ' data-event-allday="' . ($event->allDay ? '1' : '0') . '"';
|
||||
$attrs .= ' data-event-slot="' . hsc($event->slotKey) . '"';
|
||||
if ($event->uid !== '') {
|
||||
$attrs .= ' data-event-uid="' . hsc($event->uid) . '"';
|
||||
}
|
||||
if ($event->recurrenceId !== '') {
|
||||
$attrs .= ' data-event-recurrence="' . hsc($event->recurrenceId) . '"';
|
||||
}
|
||||
if ($event->dateIso !== '') {
|
||||
$attrs .= ' data-event-date="' . hsc($event->dateIso) . '"';
|
||||
}
|
||||
|
||||
return $attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode day events as a JSON array for the day popup.
|
||||
*
|
||||
* @param CalendarEvent[] $events
|
||||
* @return string JSON string
|
||||
*/
|
||||
protected static function encodeDayEventsJson(array $events): string
|
||||
{
|
||||
if ($events === []) return '[]';
|
||||
|
||||
$items = [];
|
||||
foreach ($events as $event) {
|
||||
$item = [
|
||||
'summary' => $event->summary,
|
||||
'start' => $event->startIso,
|
||||
'allDay' => $event->allDay,
|
||||
'slot' => $event->slotKey,
|
||||
];
|
||||
if ($event->endIso !== '') $item['end'] = $event->endIso;
|
||||
if ($event->location !== '') $item['location'] = $event->location;
|
||||
if ($event->description !== '') $item['description'] = $event->description;
|
||||
if ($event->time !== '') $item['time'] = $event->time;
|
||||
if ($event->uid !== '') $item['uid'] = $event->uid;
|
||||
if ($event->recurrenceId !== '') $item['recurrence'] = $event->recurrenceId;
|
||||
if ($event->dateIso !== '') $item['date'] = $event->dateIso;
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
return json_encode($items, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user