416 lines
14 KiB
PHP
416 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Models\Calendar;
|
||
use App\Models\Event;
|
||
use App\Models\Location;
|
||
use App\Services\Event\EventRecurrence;
|
||
use App\Services\Location\Geocoder;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
|
||
class EventController extends Controller
|
||
{
|
||
/**
|
||
* create a new event
|
||
*/
|
||
public function create(Calendar $calendar, Request $request)
|
||
{
|
||
$this->authorize('update', $calendar);
|
||
|
||
$instance = $calendar->instanceForUser($request->user());
|
||
$tz = $this->displayTimezone($calendar, $request);
|
||
|
||
// build a fresh event "shell" with meta defaults
|
||
$event = new Event;
|
||
$event->meta = (object) [
|
||
'title' => '',
|
||
'description' => '',
|
||
'location' => '',
|
||
'start_at' => null,
|
||
'end_at' => null,
|
||
'all_day' => false,
|
||
'category' => '',
|
||
];
|
||
|
||
// if ?date=YYYY-MM-DD is present, start that day at 9am; otherwise "now"
|
||
$anchor = $request->query('date')
|
||
? Carbon::parse($request->query('date'), $tz)->startOfDay()->addHours(9)
|
||
: Carbon::now($tz);
|
||
|
||
$anchor->second(0);
|
||
|
||
$start = $anchor->copy()->format('Y-m-d\TH:i');
|
||
$end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
|
||
$rrule = '';
|
||
|
||
return view('event.form', compact(
|
||
'calendar',
|
||
'instance',
|
||
'event',
|
||
'start',
|
||
'end',
|
||
'tz',
|
||
'rrule',
|
||
));
|
||
}
|
||
|
||
/**
|
||
* edit event
|
||
*/
|
||
public function edit(Calendar $calendar, Event $event, Request $request, EventRecurrence $recurrence)
|
||
{
|
||
$this->authorize('update', $calendar);
|
||
|
||
// ensure the event belongs to the parent calendar
|
||
if ((int) $event->calendarid !== (int) $calendar->id) {
|
||
abort(Response::HTTP_NOT_FOUND);
|
||
}
|
||
|
||
$instance = $calendar->instanceForUser($request->user());
|
||
$tz = $this->displayTimezone($calendar, $request);
|
||
|
||
$event->load('meta');
|
||
|
||
$start = $event->meta?->start_at
|
||
? Carbon::parse($event->meta->start_at)->timezone($tz)->format('Y-m-d\TH:i')
|
||
: null;
|
||
|
||
$end = $event->meta?->end_at
|
||
? Carbon::parse($event->meta->end_at)->timezone($tz)->format('Y-m-d\TH:i')
|
||
: null;
|
||
|
||
$rrule = $event->meta?->extra['rrule']
|
||
?? $recurrence->extractRrule($event)
|
||
?? '';
|
||
|
||
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule'));
|
||
}
|
||
|
||
/**
|
||
* single event view handling
|
||
*/
|
||
public function show(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence)
|
||
{
|
||
if ((int) $event->calendarid !== (int) $calendar->id) {
|
||
abort(Response::HTTP_NOT_FOUND);
|
||
}
|
||
|
||
$this->authorize('view', $event);
|
||
|
||
$event->load(['meta', 'meta.venue']);
|
||
|
||
$isHtmx = $request->header('HX-Request') === 'true';
|
||
$tz = $this->displayTimezone($calendar, $request);
|
||
|
||
// prefer occurrence when supplied (recurring events), fall back to meta, then sabre columns
|
||
$occurrenceParam = $request->query('occurrence');
|
||
$occurrenceStart = null;
|
||
if ($occurrenceParam) {
|
||
try {
|
||
$occurrenceStart = Carbon::parse($occurrenceParam)->utc();
|
||
} catch (\Throwable $e) {
|
||
$occurrenceStart = null;
|
||
}
|
||
}
|
||
$occurrence = $occurrenceStart
|
||
? $recurrence->resolveOccurrence($event, $occurrenceStart)
|
||
: null;
|
||
|
||
$startUtc = $occurrence['start'] ?? ($event->meta?->start_at
|
||
? Carbon::parse($event->meta->start_at)->utc()
|
||
: Carbon::createFromTimestamp($event->firstoccurence, 'UTC'));
|
||
|
||
$endUtc = $occurrence['end'] ?? ($event->meta?->end_at
|
||
? Carbon::parse($event->meta->end_at)->utc()
|
||
: ($event->lastoccurence
|
||
? Carbon::createFromTimestamp($event->lastoccurence, 'UTC')
|
||
: $startUtc->copy()));
|
||
|
||
// convert for display
|
||
$start = $startUtc->copy()->timezone($tz);
|
||
$end = $endUtc->copy()->timezone($tz);
|
||
|
||
$data = compact('calendar', 'event', 'start', 'end', 'tz');
|
||
|
||
return $isHtmx
|
||
? view('event.partials.details', $data)
|
||
: view('event.show', $data);
|
||
}
|
||
|
||
/**
|
||
* insert vevent into sabre’s calendarobjects + meta row
|
||
*/
|
||
public function store(Request $request, Calendar $calendar, Geocoder $geocoder, EventRecurrence $recurrence): RedirectResponse
|
||
{
|
||
$this->authorize('update', $calendar);
|
||
|
||
$data = $request->validate([
|
||
'title' => ['required', 'string', 'max:200'],
|
||
'start_at' => ['required', 'date_format:Y-m-d\TH:i'],
|
||
'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'],
|
||
'description' => ['nullable', 'string'],
|
||
'location' => ['nullable', 'string'],
|
||
'all_day' => ['sometimes', 'boolean'],
|
||
'category' => ['nullable', 'string', 'max:50'],
|
||
'rrule' => ['nullable', 'string', 'max:255'],
|
||
|
||
// normalized location hints (optional)
|
||
'loc_display_name' => ['nullable', 'string'],
|
||
'loc_place_name' => ['nullable', 'string'],
|
||
'loc_street' => ['nullable', 'string'],
|
||
'loc_city' => ['nullable', 'string'],
|
||
'loc_state' => ['nullable', 'string'],
|
||
'loc_postal' => ['nullable', 'string'],
|
||
'loc_country' => ['nullable', 'string'],
|
||
'loc_lat' => ['nullable'],
|
||
'loc_lon' => ['nullable'],
|
||
]);
|
||
|
||
$tz = $this->displayTimezone($calendar, $request);
|
||
|
||
// parse input in display tz, store in utc
|
||
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
|
||
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
|
||
|
||
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
|
||
|
||
$rrule = $this->normalizeRrule($request);
|
||
$extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request);
|
||
|
||
$ical = $recurrence->buildCalendar([
|
||
'uid' => $uid,
|
||
'start_utc' => $startUtc,
|
||
'end_utc' => $endUtc,
|
||
'summary' => $data['title'],
|
||
'description' => $data['description'] ?? '',
|
||
'location' => $data['location'] ?? '',
|
||
'tzid' => $rrule ? $tz : null,
|
||
'rrule' => $rrule,
|
||
]);
|
||
|
||
$event = Event::create([
|
||
'calendarid' => $calendar->id,
|
||
'uri' => Str::uuid() . '.ics',
|
||
'lastmodified' => time(),
|
||
'etag' => md5($ical),
|
||
'size' => strlen($ical),
|
||
'componenttype' => 'VEVENT',
|
||
'uid' => $uid,
|
||
'calendardata' => $ical,
|
||
]);
|
||
|
||
$locationId = $this->resolveLocationId($request, $geocoder, $data);
|
||
|
||
$event->meta()->create([
|
||
'title' => $data['title'],
|
||
'description' => $data['description'] ?? null,
|
||
'location' => $data['location'] ?? null,
|
||
'location_id' => $locationId,
|
||
'all_day' => (bool) ($data['all_day'] ?? false),
|
||
'category' => $data['category'] ?? null,
|
||
'start_at' => $startUtc,
|
||
'end_at' => $endUtc,
|
||
'extra' => $extra,
|
||
]);
|
||
|
||
return redirect()->route('calendar.show', $calendar);
|
||
}
|
||
|
||
/**
|
||
* update vevent + meta
|
||
*/
|
||
public function update(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence): RedirectResponse
|
||
{
|
||
$this->authorize('update', $calendar);
|
||
|
||
if ((int) $event->calendarid !== (int) $calendar->id) {
|
||
abort(Response::HTTP_NOT_FOUND);
|
||
}
|
||
|
||
$data = $request->validate([
|
||
'title' => ['required', 'string', 'max:200'],
|
||
'start_at' => ['required', 'date_format:Y-m-d\TH:i'],
|
||
'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'],
|
||
'description' => ['nullable', 'string'],
|
||
'location' => ['nullable', 'string'],
|
||
'all_day' => ['sometimes', 'boolean'],
|
||
'category' => ['nullable', 'string', 'max:50'],
|
||
'rrule' => ['nullable', 'string', 'max:255'],
|
||
]);
|
||
|
||
$tz = $this->displayTimezone($calendar, $request);
|
||
|
||
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
|
||
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
|
||
|
||
$uid = $event->uid;
|
||
|
||
$rrule = $this->normalizeRrule($request);
|
||
$extra = $event->meta?->extra ?? [];
|
||
$extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request);
|
||
$rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event));
|
||
|
||
$ical = $recurrence->buildCalendar([
|
||
'uid' => $uid,
|
||
'start_utc' => $startUtc,
|
||
'end_utc' => $endUtc,
|
||
'summary' => $data['title'],
|
||
'description' => $data['description'] ?? '',
|
||
'location' => $data['location'] ?? '',
|
||
'tzid' => $rruleForIcs ? $tz : null,
|
||
'rrule' => $rruleForIcs,
|
||
'exdate' => $extra['exdate'] ?? [],
|
||
'rdate' => $extra['rdate'] ?? [],
|
||
]);
|
||
|
||
$event->update([
|
||
'calendardata' => $ical,
|
||
'etag' => md5($ical),
|
||
'lastmodified' => time(),
|
||
]);
|
||
|
||
$event->meta()->updateOrCreate([], [
|
||
'title' => $data['title'],
|
||
'description' => $data['description'] ?? null,
|
||
'location' => $data['location'] ?? null,
|
||
'all_day' => (bool) ($data['all_day'] ?? false),
|
||
'category' => $data['category'] ?? null,
|
||
'start_at' => $startUtc,
|
||
'end_at' => $endUtc,
|
||
'extra' => $extra,
|
||
]);
|
||
|
||
return redirect()->route('calendar.show', $calendar);
|
||
}
|
||
|
||
/**
|
||
* pick display timezone: calendar instance -> user -> utc
|
||
*/
|
||
private function displayTimezone(Calendar $calendar, Request $request): string
|
||
{
|
||
$instanceTz = $calendar->instanceForUser($request->user())?->timezone;
|
||
$userTz = $request->user()?->timezone;
|
||
|
||
return $instanceTz ?: ($userTz ?: 'UTC');
|
||
}
|
||
|
||
/**
|
||
* parse datetime-local in tz and return utc carbon
|
||
*/
|
||
private function parseLocalDatetimeToUtc(string $value, string $tz): Carbon
|
||
{
|
||
// datetime-local: 2026-01-21T09:00
|
||
$local = Carbon::createFromFormat('Y-m-d\TH:i', $value, $tz)->seconds(0);
|
||
return $local->utc();
|
||
}
|
||
|
||
/**
|
||
* minimal ics escaping for text properties
|
||
*/
|
||
private function escapeIcsText(string $text): string
|
||
{
|
||
$text = str_replace(["\r\n", "\r", "\n"], "\\n", $text);
|
||
$text = str_replace(["\\", ";", ","], ["\\\\", "\;", "\,"], $text);
|
||
return $text;
|
||
}
|
||
|
||
private function normalizeRrule(Request $request): ?string
|
||
{
|
||
if (! $request->has('rrule')) {
|
||
return null;
|
||
}
|
||
|
||
$rrule = trim((string) $request->input('rrule'));
|
||
return $rrule === '' ? '' : $rrule;
|
||
}
|
||
|
||
private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array
|
||
{
|
||
if ($rrule === null) {
|
||
return $extra; // no change requested
|
||
}
|
||
|
||
if ($rrule === '') {
|
||
unset($extra['rrule'], $extra['exdate'], $extra['rdate'], $extra['tzid']);
|
||
return $extra;
|
||
}
|
||
|
||
$extra['rrule'] = $rrule;
|
||
$extra['tzid'] = $tz;
|
||
|
||
$extra['exdate'] = $this->normalizeDateList($request->input('exdate', $extra['exdate'] ?? []), $tz);
|
||
$extra['rdate'] = $this->normalizeDateList($request->input('rdate', $extra['rdate'] ?? []), $tz);
|
||
|
||
return $extra;
|
||
}
|
||
|
||
private function normalizeDateList(mixed $value, string $tz): array
|
||
{
|
||
if (is_string($value)) {
|
||
$value = array_filter(array_map('trim', explode(',', $value)));
|
||
}
|
||
|
||
if (! is_array($value)) {
|
||
return [];
|
||
}
|
||
|
||
return array_values(array_filter(array_map(function ($item) use ($tz) {
|
||
if (! $item) {
|
||
return null;
|
||
}
|
||
|
||
return Carbon::parse($item, $tz)->utc()->toIso8601String();
|
||
}, $value)));
|
||
}
|
||
|
||
/**
|
||
* resolve location_id from hints or geocoding
|
||
*/
|
||
private function resolveLocationId(Request $request, Geocoder $geocoder, array $data): ?int
|
||
{
|
||
$raw = $data['location'] ?? null;
|
||
if (!$raw) return null;
|
||
|
||
$hasNormHints = $request->filled('loc_display_name') ||
|
||
$request->filled('loc_place_name') ||
|
||
$request->filled('loc_street') ||
|
||
$request->filled('loc_city') ||
|
||
$request->filled('loc_state') ||
|
||
$request->filled('loc_postal') ||
|
||
$request->filled('loc_country') ||
|
||
$request->filled('loc_lat') ||
|
||
$request->filled('loc_lon');
|
||
|
||
if ($hasNormHints) {
|
||
$norm = [
|
||
'display_name' => $request->input('loc_display_name') ?: $raw,
|
||
'place_name' => $request->input('loc_place_name'),
|
||
'raw_address' => $raw,
|
||
'street' => $request->input('loc_street'),
|
||
'city' => $request->input('loc_city'),
|
||
'state' => $request->input('loc_state'),
|
||
'postal' => $request->input('loc_postal'),
|
||
'country' => $request->input('loc_country'),
|
||
'lat' => $request->filled('loc_lat') ? (float) $request->input('loc_lat') : null,
|
||
'lon' => $request->filled('loc_lon') ? (float) $request->input('loc_lon') : null,
|
||
];
|
||
|
||
return Location::findOrCreateNormalized($norm, $raw)->id;
|
||
}
|
||
|
||
$norm = $geocoder->forward($raw);
|
||
|
||
if ($norm) {
|
||
return Location::findOrCreateNormalized($norm, $raw)->id;
|
||
}
|
||
|
||
return Location::labelOnly($raw)->id;
|
||
}
|
||
}
|