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; } }