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 = ''; $data = compact( 'calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule', ); $data = array_merge($data, $this->buildRecurrenceFormData($request, $start, $tz, $rrule)); $data = array_merge($data, $this->buildAttendeeFormData($request)); $data = array_merge($data, $this->buildCalendarPickerData($request, $calendar, null)); if ($request->header('HX-Request') === 'true') { return view('event.partials.form-modal', $data); } return view('event.form', $data); } /** * 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', 'attendees'); $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) ?? ''; $data = compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule'); $data = array_merge($data, $this->buildRecurrenceFormData($request, $start ?? '', $tz, $rrule)); $data = array_merge($data, $this->buildAttendeeFormData($request, $event)); $data = array_merge($data, $this->buildCalendarPickerData($request, $calendar, $event)); if ($request->header('HX-Request') === 'true') { return view('event.partials.form-modal', $data); } return view('event.form', $data); } /** * 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->loadMissing(['meta', 'meta.venue', 'attendees']); $isHtmx = $request->header('HX-Request') === 'true'; $tz = $this->displayTimezone($calendar, $request); $instance = $calendar->instanceForUser($request->user()); if ($instance) { $instance->loadMissing('meta'); } $calendarColor = calendar_color( ['color' => $instance?->meta?->color], $instance?->calendarcolor ); $calendarColorFg = $instance?->meta?->color_fg ?? contrast_text_color($calendarColor); $calendarName = $instance?->displayname ?? __('common.calendar'); // 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); if ($event->meta?->all_day && $end->gt($start)) { $end = $end->copy()->subDay(); } $map = $this->buildBasemapTiles($event->meta?->venue); $event->setAttribute('color', $calendarColor); $event->setAttribute('color_fg', $calendarColorFg); $data = compact('calendar', 'event', 'start', 'end', 'tz', 'map', 'calendarName'); 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, EventAttendeeSynchronizer $attendeeSync ): RedirectResponse { $this->authorize('update', $calendar); $this->normalizeRecurrenceInputs($request); $rules = [ 'title' => ['required', 'string', 'max:200'], 'description' => ['nullable', 'string'], 'location' => ['nullable', 'string'], 'all_day' => ['sometimes', 'boolean'], 'category' => ['nullable', 'string', 'max:50'], 'calendar_uri' => ['nullable', 'string', 'max:255'], 'repeat_frequency' => [ function (string $attribute, mixed $value, $fail) { if ($value === null || $value === '') { return; } if (!in_array($value, ['daily', 'weekly', 'monthly', 'yearly'], true)) { $fail(__('calendar.event.recurrence.invalid_frequency')); } } ], 'repeat_interval' => ['nullable', 'integer', 'min:1', 'max:365'], 'repeat_weekdays' => ['nullable', 'array'], 'repeat_weekdays.*' => ['in:SU,MO,TU,WE,TH,FR,SA'], 'repeat_monthly_mode' => ['nullable', 'in:days,weekday'], 'repeat_month_days' => ['nullable', 'array'], 'repeat_month_days.*' => ['integer', 'min:1', 'max:31'], 'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'], 'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'], 'attendees' => ['nullable', 'array'], 'attendees.*.attendee_uri' => ['nullable', 'string', 'max:500'], 'attendees.*.email' => ['nullable', 'email', 'max:255'], 'attendees.*.name' => ['nullable', 'string', 'max:200'], 'attendees.*.optional' => ['nullable', 'boolean'], 'attendees.*.role' => ['nullable', 'string', 'max:32'], 'attendees.*.partstat' => ['nullable', 'string', 'max:32'], 'attendees.*.cutype' => ['nullable', 'string', 'max:32'], 'attendees.*.rsvp' => ['nullable', 'boolean'], 'attendees.*.is_organizer' => ['nullable', 'boolean'], 'attendees.*.extra' => ['nullable', 'array'], // 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'], ]; if ($request->boolean('all_day')) { $rules['start_at'] = ['required', 'date']; $rules['end_at'] = ['required', 'date', 'after_or_equal:start_at']; } else { $rules['start_at'] = ['required', 'date_format:Y-m-d\\TH:i']; $rules['end_at'] = ['required', 'date_format:Y-m-d\\TH:i', 'after:start_at']; } $data = $request->validate($rules); $targetCalendar = $this->resolveTargetCalendar($request, $calendar); $this->authorize('update', $targetCalendar); $tz = $this->displayTimezone($calendar, $request); $isAllDay = (bool) ($data['all_day'] ?? false); // parse input in display tz, store in utc if ($isAllDay) { [$startOn, $endOn, $startUtc, $endUtc] = $this->deriveAllDayRange( $data['start_at'], $data['end_at'], $tz ); } else { $startOn = null; $endOn = null; $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); $attendees = $this->resolveAttendeesPayload($request, null, $attendeeSync); $rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay)); $extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request); $ical = $recurrence->buildCalendar([ 'uid' => $uid, 'start_utc' => $startUtc, 'end_utc' => $endUtc, 'all_day' => $isAllDay, 'start_date' => $startOn, 'end_date' => $endOn, 'summary' => $data['title'], 'description' => $data['description'] ?? '', 'location' => $data['location'] ?? '', 'tzid' => $rrule ? $tz : null, 'rrule' => $rrule, 'attendees' => $attendees, ]); $event = Event::create([ 'calendarid' => $targetCalendar->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' => $isAllDay, 'category' => $data['category'] ?? null, 'start_at' => $startUtc, 'end_at' => $endUtc, 'start_on' => $startOn, 'end_on' => $endOn, 'tzid' => $isAllDay ? $tz : null, 'extra' => $extra, ]); $attendeeSync->syncRows($event, $attendees); return redirect()->route('calendar.show', $targetCalendar); } /** * update vevent + meta */ public function update( Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence, EventAttendeeSynchronizer $attendeeSync ): RedirectResponse { $this->authorize('update', $calendar); if ((int) $event->calendarid !== (int) $calendar->id) { abort(Response::HTTP_NOT_FOUND); } $this->normalizeRecurrenceInputs($request); $rules = [ 'title' => ['required', 'string', 'max:200'], 'description' => ['nullable', 'string'], 'location' => ['nullable', 'string'], 'all_day' => ['sometimes', 'boolean'], 'category' => ['nullable', 'string', 'max:50'], 'calendar_uri' => ['nullable', 'string', 'max:255'], 'repeat_frequency' => [ function (string $attribute, mixed $value, $fail) { if ($value === null || $value === '') { return; } if (!in_array($value, ['daily', 'weekly', 'monthly', 'yearly'], true)) { $fail(__('calendar.event.recurrence.invalid_frequency')); } } ], 'repeat_interval' => ['nullable', 'integer', 'min:1', 'max:365'], 'repeat_weekdays' => ['nullable', 'array'], 'repeat_weekdays.*' => ['in:SU,MO,TU,WE,TH,FR,SA'], 'repeat_monthly_mode' => ['nullable', 'in:days,weekday'], 'repeat_month_days' => ['nullable', 'array'], 'repeat_month_days.*' => ['integer', 'min:1', 'max:31'], 'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'], 'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'], 'attendees' => ['nullable', 'array'], 'attendees.*.attendee_uri' => ['nullable', 'string', 'max:500'], 'attendees.*.email' => ['nullable', 'email', 'max:255'], 'attendees.*.name' => ['nullable', 'string', 'max:200'], 'attendees.*.optional' => ['nullable', 'boolean'], 'attendees.*.role' => ['nullable', 'string', 'max:32'], 'attendees.*.partstat' => ['nullable', 'string', 'max:32'], 'attendees.*.cutype' => ['nullable', 'string', 'max:32'], 'attendees.*.rsvp' => ['nullable', 'boolean'], 'attendees.*.is_organizer' => ['nullable', 'boolean'], 'attendees.*.extra' => ['nullable', 'array'], ]; if ($request->boolean('all_day')) { $rules['start_at'] = ['required', 'date']; $rules['end_at'] = ['required', 'date', 'after_or_equal:start_at']; } else { $rules['start_at'] = ['required', 'date_format:Y-m-d\\TH:i']; $rules['end_at'] = ['required', 'date_format:Y-m-d\\TH:i', 'after:start_at']; } $data = $request->validate($rules); $targetCalendar = $this->resolveTargetCalendar($request, $calendar); $this->authorize('update', $targetCalendar); $tz = $this->displayTimezone($calendar, $request); $isAllDay = (bool) ($data['all_day'] ?? false); if ($isAllDay) { [$startOn, $endOn, $startUtc, $endUtc] = $this->deriveAllDayRange( $data['start_at'], $data['end_at'], $tz ); } else { $startOn = null; $endOn = null; $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz); $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz); } $uid = $event->uid; $attendees = $this->resolveAttendeesPayload($request, $event, $attendeeSync); $rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay)); $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, 'all_day' => $isAllDay, 'start_date' => $startOn, 'end_date' => $endOn, 'summary' => $data['title'], 'description' => $data['description'] ?? '', 'location' => $data['location'] ?? '', 'tzid' => $rruleForIcs ? $tz : null, 'rrule' => $rruleForIcs, 'exdate' => $extra['exdate'] ?? [], 'rdate' => $extra['rdate'] ?? [], 'attendees' => $attendees, ]); $event->update([ 'calendarid' => $targetCalendar->id, 'calendardata' => $ical, 'etag' => md5($ical), 'lastmodified' => time(), ]); $event->meta()->updateOrCreate([], [ 'title' => $data['title'], 'description' => $data['description'] ?? null, 'location' => $data['location'] ?? null, 'all_day' => $isAllDay, 'category' => $data['category'] ?? null, 'start_at' => $startUtc, 'end_at' => $endUtc, 'start_on' => $startOn, 'end_on' => $endOn, 'tzid' => $isAllDay ? $tz : null, 'extra' => $extra, ]); $attendeeSync->syncRows($event, $attendees); return redirect()->route('calendar.show', $targetCalendar); } /** * 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(); } /** * derive all-day date range (exclusive end) and utc bounds */ private function deriveAllDayRange(string $start, string $end, string $tz): array { $startLocal = $this->parseAllDayInput($start, $tz); $endLocal = $this->parseAllDayInput($end, $tz); $startOn = $startLocal->toDateString(); $endOn = $endLocal->toDateString(); if (Carbon::parse($endOn, $tz)->lte(Carbon::parse($startOn, $tz))) { $endOn = Carbon::parse($startOn, $tz)->addDay()->toDateString(); } $startUtc = Carbon::parse($startOn, $tz)->startOfDay()->utc(); $endUtc = Carbon::parse($endOn, $tz)->startOfDay()->utc(); return [$startOn, $endOn, $startUtc, $endUtc]; } private function parseAllDayInput(string $value, string $tz): Carbon { if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1) { return Carbon::createFromFormat('Y-m-d', $value, $tz)->startOfDay(); } try { return Carbon::createFromFormat('Y-m-d\\TH:i', $value, $tz)->seconds(0); } catch (\Throwable $e) { return Carbon::parse($value, $tz)->startOfDay(); } } /** * 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 normalizeRecurrenceInputs(Request $request): void { if ($request->has('repeat_interval') && $request->input('repeat_interval') === '') { $request->merge(['repeat_interval' => null]); } } private function buildRruleFromRequest(Request $request, Carbon $startLocal): ?string { if (! $request->has('repeat_frequency')) { return null; } $freq = strtolower((string) $request->input('repeat_frequency', '')); if ($freq === '') { return ''; } $interval = max(1, (int) $request->input('repeat_interval', 1)); $parts = ['FREQ=' . strtoupper($freq)]; if ($interval > 1) { $parts[] = 'INTERVAL=' . $interval; } $weekdayMap = [ 'sun' => 'SU', 'mon' => 'MO', 'tue' => 'TU', 'wed' => 'WE', 'thu' => 'TH', 'fri' => 'FR', 'sat' => 'SA', ]; $defaultWeekday = $weekdayMap[strtolower($startLocal->format('D'))] ?? 'MO'; if ($freq === 'weekly') { $days = (array) $request->input('repeat_weekdays', []); $days = array_values(array_filter($days, fn ($d) => preg_match('/^(SU|MO|TU|WE|TH|FR|SA)$/', $d))); if (empty($days)) { $days = [$defaultWeekday]; } $parts[] = 'BYDAY=' . implode(',', $days); } if ($freq === 'monthly') { $mode = $request->input('repeat_monthly_mode', 'days'); if ($mode === 'weekday') { $week = $request->input('repeat_month_week', 'first'); $weekday = $request->input('repeat_month_weekday', $defaultWeekday); $weekMap = [ 'first' => 1, 'second' => 2, 'third' => 3, 'fourth' => 4, 'last' => -1, ]; $bysetpos = $weekMap[$week] ?? 1; $parts[] = 'BYDAY=' . $weekday; $parts[] = 'BYSETPOS=' . $bysetpos; } else { $days = (array) $request->input('repeat_month_days', []); $days = array_values(array_filter($days, fn ($d) => is_numeric($d) && (int) $d >= 1 && (int) $d <= 31)); if (empty($days)) { $days = [(int) $startLocal->format('j')]; } $parts[] = 'BYMONTHDAY=' . implode(',', $days); } } if ($freq === 'yearly') { $parts[] = 'BYMONTH=' . $startLocal->format('n'); $parts[] = 'BYMONTHDAY=' . $startLocal->format('j'); } return implode(';', $parts); } private function parseLocalInputToTz(string $value, string $tz, bool $isAllDay): Carbon { return $isAllDay ? $this->parseAllDayInput($value, $tz) : Carbon::createFromFormat('Y-m-d\\TH:i', $value, $tz)->seconds(0); } private function buildRecurrenceFormData(Request $request, string $startValue, string $tz, ?string $rrule): array { $rruleValue = trim((string) ($rrule ?? '')); $rruleParts = []; foreach (array_filter(explode(';', $rruleValue)) as $chunk) { if (!str_contains($chunk, '=')) { continue; } [$key, $value] = explode('=', $chunk, 2); $rruleParts[strtoupper($key)] = $value; } $freq = strtolower($rruleParts['FREQ'] ?? ''); $interval = (int) ($rruleParts['INTERVAL'] ?? 1); if ($interval < 1) { $interval = 1; } $byday = array_filter(explode(',', $rruleParts['BYDAY'] ?? '')); $bymonthday = array_filter(explode(',', $rruleParts['BYMONTHDAY'] ?? '')); $bysetpos = $rruleParts['BYSETPOS'] ?? null; $startDate = null; if ($startValue !== '') { try { $startDate = Carbon::parse($startValue, $tz); } catch (\Throwable $e) { $startDate = null; } } $startDate = $startDate ?? Carbon::now($tz); $weekdayMap = [ 'Sun' => 'SU', 'Mon' => 'MO', 'Tue' => 'TU', 'Wed' => 'WE', 'Thu' => 'TH', 'Fri' => 'FR', 'Sat' => 'SA', ]; $defaultWeekday = $weekdayMap[$startDate->format('D')] ?? 'MO'; $defaultMonthDay = (int) $startDate->format('j'); $weekMap = [1 => 'first', 2 => 'second', 3 => 'third', 4 => 'fourth']; $startWeek = $startDate->copy(); $isLastWeek = $startWeek->copy()->addWeek()->month !== $startWeek->month; $defaultMonthWeek = $isLastWeek ? 'last' : ($weekMap[$startDate->weekOfMonth] ?? 'first'); $monthMode = 'days'; if (!empty($bymonthday)) { $monthMode = 'days'; } elseif (!empty($byday) && $bysetpos) { $monthMode = 'weekday'; } $setposMap = ['1' => 'first', '2' => 'second', '3' => 'third', '4' => 'fourth', '-1' => 'last']; $repeatFrequency = $request->old('repeat_frequency', $freq ?: ''); $repeatInterval = $request->old('repeat_interval', $interval); $repeatWeekdays = $request->old('repeat_weekdays', $byday ?: [$defaultWeekday]); $repeatMonthDays = $request->old('repeat_month_days', $bymonthday ?: [$defaultMonthDay]); $repeatMonthMode = $request->old('repeat_monthly_mode', $monthMode); $repeatMonthWeek = $request->old('repeat_month_week', $setposMap[(string) $bysetpos] ?? $defaultMonthWeek); $repeatMonthWeekday = $request->old('repeat_month_weekday', $byday[0] ?? $defaultWeekday); $rruleOptions = [ 'daily' => __('calendar.event.recurrence.daily'), 'weekly' => __('calendar.event.recurrence.weekly'), 'monthly' => __('calendar.event.recurrence.monthly'), 'yearly' => __('calendar.event.recurrence.yearly'), ]; $weekdayOptions = [ 'SU' => __('calendar.event.recurrence.weekdays.sun_short'), 'MO' => __('calendar.event.recurrence.weekdays.mon_short'), 'TU' => __('calendar.event.recurrence.weekdays.tue_short'), 'WE' => __('calendar.event.recurrence.weekdays.wed_short'), 'TH' => __('calendar.event.recurrence.weekdays.thu_short'), 'FR' => __('calendar.event.recurrence.weekdays.fri_short'), 'SA' => __('calendar.event.recurrence.weekdays.sat_short'), ]; $weekdayLong = [ 'SU' => __('calendar.event.recurrence.weekdays.sun'), 'MO' => __('calendar.event.recurrence.weekdays.mon'), 'TU' => __('calendar.event.recurrence.weekdays.tue'), 'WE' => __('calendar.event.recurrence.weekdays.wed'), 'TH' => __('calendar.event.recurrence.weekdays.thu'), 'FR' => __('calendar.event.recurrence.weekdays.fri'), 'SA' => __('calendar.event.recurrence.weekdays.sat'), ]; return compact( 'repeatFrequency', 'repeatInterval', 'repeatWeekdays', 'repeatMonthDays', 'repeatMonthMode', 'repeatMonthWeek', 'repeatMonthWeekday', 'rruleOptions', 'weekdayOptions', 'weekdayLong' ); } private function buildAttendeeFormData(Request $request, ?Event $event = null): array { $attendees = $request->old('attendees'); if (!is_array($attendees)) { $attendees = $event?->attendees ? $event->attendees->map(function ($attendee) { return [ 'attendee_uri' => $attendee->attendee_uri, 'email' => $attendee->email, 'name' => $attendee->name, 'optional' => strtoupper((string) $attendee->role) === 'OPT-PARTICIPANT', 'rsvp' => $attendee->rsvp, 'verified' => !empty($attendee->attendee_user_id), ]; })->values()->all() : []; } $attendees = array_values(array_map(function ($attendee) { $row = is_array($attendee) ? $attendee : []; $optional = array_key_exists('optional', $row) ? (bool) $row['optional'] : strtoupper((string) ($row['role'] ?? '')) === 'OPT-PARTICIPANT'; return [ 'attendee_uri' => $row['attendee_uri'] ?? null, 'email' => $row['email'] ?? null, 'name' => $row['name'] ?? null, 'optional' => $optional, 'rsvp' => array_key_exists('rsvp', $row) ? (bool) $row['rsvp'] : true, 'verified' => (bool) ($row['verified'] ?? false), ]; }, $attendees)); return compact('attendees'); } 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))); } private function resolveAttendeesPayload( Request $request, ?Event $event, EventAttendeeSynchronizer $attendeeSync ): array { if ($request->exists('attendees_present')) { return $this->normalizeRequestAttendees((array) $request->input('attendees', [])); } if (!$event) { return []; } return $attendeeSync->extractFromCalendarData($event->calendardata); } private function normalizeRequestAttendees(array $attendees): array { $rows = []; foreach ($attendees as $attendee) { if (!is_array($attendee)) { continue; } $isOptional = (bool) ($attendee['optional'] ?? false); $rows[] = [ 'attendee_uri' => $attendee['attendee_uri'] ?? null, 'email' => $attendee['email'] ?? null, 'name' => $attendee['name'] ?? null, 'role' => $isOptional ? 'OPT-PARTICIPANT' : 'REQ-PARTICIPANT', 'partstat' => 'NEEDS-ACTION', 'cutype' => 'INDIVIDUAL', 'rsvp' => array_key_exists('rsvp', $attendee) ? (bool) $attendee['rsvp'] : true, 'is_organizer' => false, 'extra' => is_array($attendee['extra'] ?? null) ? $attendee['extra'] : null, ]; } return $rows; } private function buildCalendarPickerData(Request $request, Calendar $calendar, ?Event $event = null): array { $instances = CalendarInstance::query() ->forUser($request->user()) ->withUiMeta() ->ordered() ->get(); $selectedUri = old('calendar_uri'); if (!$selectedUri) { if ($event) { $selectedUri = $instances->firstWhere('calendarid', $event->calendarid)?->uri; } else { $selectedUri = $instances->first()?->uri; } } $selected = $instances->firstWhere('uri', $selectedUri) ?? $instances->firstWhere('calendarid', $calendar->id) ?? $instances->first(); $calendarPickerOptions = $instances->map(function (CalendarInstance $instance) { $color = $instance->resolvedColor($instance->calendarcolor); return [ 'uri' => $instance->uri, 'name' => $instance->displayname ?: __('common.calendar'), 'color' => $color, ]; })->values()->all(); $selectedCalendarUri = $selected?->uri; $selectedCalendarName = $selected?->displayname ?: __('common.calendar'); $selectedCalendarColor = $selected?->resolvedColor($selected?->calendarcolor) ?? '#64748b'; return compact( 'calendarPickerOptions', 'selectedCalendarUri', 'selectedCalendarName', 'selectedCalendarColor', ); } private function resolveTargetCalendar(Request $request, Calendar $fallback): Calendar { $uri = trim((string) $request->input('calendar_uri', '')); if ($uri === '') { return $fallback; } $instance = CalendarInstance::query() ->forUser($request->user()) ->where('uri', $uri) ->first(); if (!$instance) { return $fallback; } return Calendar::query()->find($instance->calendarid) ?? $fallback; } /** * 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; } /** * build static basemap tiles for an event location */ private function buildBasemapTiles(?Location $venue): array { $map = [ 'enabled' => false, 'needs_key' => false, 'url' => null, 'zoom' => (int) config('services.geocoding.arcgis.basemap_zoom', 14), ]; if (!$venue || !is_numeric($venue->lat) || !is_numeric($venue->lon)) { return $map; } $token = config('services.geocoding.arcgis.api_key'); if (!$token) { $map['needs_key'] = true; return $map; } $style = config('services.geocoding.arcgis.basemap_style', 'arcgis/light-gray'); $zoom = max(0, (int) $map['zoom']); $lat = max(min((float) $venue->lat, 85.05112878), -85.05112878); $lon = (float) $venue->lon; $n = 2 ** $zoom; $x = (int) floor(($lon + 180.0) / 360.0 * $n); $latRad = deg2rad($lat); $y = (int) floor((1.0 - log(tan($latRad) + (1 / cos($latRad))) / M_PI) / 2.0 * $n); $base = 'https://static-map-tiles-api.arcgis.com/arcgis/rest/services/static-basemap-tiles-service/v1'; $tx = $x % $n; if ($tx < 0) { $tx += $n; } $ty = max(0, min($n - 1, $y)); $map['url'] = "{$base}/{$style}/static/tile/{$zoom}/{$ty}/{$tx}?token={$token}"; $map['enabled'] = true; $map['zoom'] = $zoom; return $map; } }