kithkin/app/Services/Event/EventRecurrence.php

186 lines
5.2 KiB
PHP

<?php
namespace App\Services\Event;
use App\Models\Event;
use Carbon\Carbon;
use DateTimeZone;
use Illuminate\Support\Str;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
use Sabre\VObject\Recur\EventIterator;
class EventRecurrence
{
/**
* Build a VCALENDAR string from core fields and optional recurrence.
*/
public function buildCalendar(array $data): string
{
$vcalendar = new VCalendar();
$vcalendar->add('PRODID', '-//Kithkin//Laravel CalDAV//EN');
$vcalendar->add('VERSION', '2.0');
$vcalendar->add('CALSCALE', 'GREGORIAN');
$vevent = $vcalendar->add('VEVENT', []);
$uid = $data['uid'];
$startUtc = $data['start_utc'];
$endUtc = $data['end_utc'];
$tzid = $data['tzid'] ?? null;
$vevent->add('UID', $uid);
$vevent->add('DTSTAMP', $startUtc->copy()->utc());
if ($tzid) {
$startLocal = $startUtc->copy()->tz($tzid);
$endLocal = $endUtc->copy()->tz($tzid);
$vevent->add('DTSTART', $startLocal, ['TZID' => $tzid]);
$vevent->add('DTEND', $endLocal, ['TZID' => $tzid]);
} else {
$vevent->add('DTSTART', $startUtc->copy()->utc());
$vevent->add('DTEND', $endUtc->copy()->utc());
}
if (!empty($data['summary'])) {
$vevent->add('SUMMARY', $data['summary']);
}
if (!empty($data['description'])) {
$vevent->add('DESCRIPTION', $data['description']);
}
if (!empty($data['location'])) {
$vevent->add('LOCATION', $data['location']);
}
$rrule = $data['rrule'] ?? null;
if ($rrule) {
$vevent->add('RRULE', $rrule);
}
$exdates = $data['exdate'] ?? [];
if (!empty($exdates)) {
foreach ($exdates as $ex) {
$dt = Carbon::parse($ex, $tzid ?: 'UTC');
if ($tzid) {
$vevent->add('EXDATE', $dt, ['TZID' => $tzid]);
} else {
$vevent->add('EXDATE', $dt->utc());
}
}
}
$rdates = $data['rdate'] ?? [];
if (!empty($rdates)) {
foreach ($rdates as $r) {
$dt = Carbon::parse($r, $tzid ?: 'UTC');
if ($tzid) {
$vevent->add('RDATE', $dt, ['TZID' => $tzid]);
} else {
$vevent->add('RDATE', $dt->utc());
}
}
}
return $vcalendar->serialize();
}
/**
* Check if a stored event contains recurrence data.
*/
public function isRecurring(Event $event): bool
{
$extra = $event->meta?->extra ?? [];
if (!empty($extra['rrule'])) {
return true;
}
return Str::contains($event->calendardata ?? '', ['RRULE', 'RDATE', 'EXDATE']);
}
/**
* Expand recurring instances within the requested range.
*
* Returns an array of ['start' => Carbon, 'end' => Carbon, 'recurrence_id' => string|null]
*/
public function expand(Event $event, Carbon $rangeStart, Carbon $rangeEnd): array
{
$vcalendar = $this->readCalendar($event->calendardata);
if (!$vcalendar || empty($vcalendar->VEVENT)) {
return [];
}
$vevent = $vcalendar->VEVENT;
$uid = (string) $vevent->UID;
$startTz = $vevent->DTSTART?->getDateTime()?->getTimezone()
?? new DateTimeZone('UTC');
$iter = new EventIterator($vcalendar, $uid);
$iter->fastForward($rangeStart->copy()->setTimezone($startTz)->toDateTime());
$items = [];
while ($iter->valid()) {
$start = Carbon::instance($iter->getDTStart());
$end = Carbon::instance($iter->getDTEnd());
if ($start->gt($rangeEnd)) {
break;
}
$startUtc = $start->copy()->utc();
$endUtc = $end->copy()->utc();
$items[] = [
'start' => $startUtc,
'end' => $endUtc,
'recurrence_id' => $startUtc->format('Ymd\\THis\\Z'),
];
$iter->next();
}
return $items;
}
/**
* Resolve a single occurrence by its DTSTART.
*/
public function resolveOccurrence(Event $event, Carbon $occurrenceStart): ?array
{
$rangeStart = $occurrenceStart->copy()->subDay();
$rangeEnd = $occurrenceStart->copy()->addDay();
foreach ($this->expand($event, $rangeStart, $rangeEnd) as $occ) {
if ($occ['start']->equalTo($occurrenceStart)) {
return $occ;
}
}
return null;
}
public function extractRrule(Event $event): ?string
{
$vcalendar = $this->readCalendar($event->calendardata);
if (!$vcalendar || empty($vcalendar->VEVENT)) {
return null;
}
$vevent = $vcalendar->VEVENT;
return isset($vevent->RRULE) ? (string) $vevent->RRULE : null;
}
private function readCalendar(?string $ical): ?VCalendar
{
if (!$ical) {
return null;
}
try {
return Reader::read($ical);
} catch (\Throwable $e) {
return null;
}
}
}