186 lines
5.2 KiB
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;
|
|
}
|
|
}
|
|
}
|