kithkin/app/Http/Controllers/EventController.php

804 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 = '';
$data = compact(
'calendar',
'instance',
'event',
'start',
'end',
'tz',
'rrule',
);
$data = array_merge($data, $this->buildRecurrenceFormData($request, $start, $tz, $rrule));
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');
$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));
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']);
$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 sabres calendarobjects + meta row
*/
public function store(Request $request, Calendar $calendar, Geocoder $geocoder, EventRecurrence $recurrence): 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'],
'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'],
// 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);
$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);
$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,
]);
$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' => $isAllDay,
'category' => $data['category'] ?? null,
'start_at' => $startUtc,
'end_at' => $endUtc,
'start_on' => $startOn,
'end_on' => $endOn,
'tzid' => $isAllDay ? $tz : null,
'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);
}
$this->normalizeRecurrenceInputs($request);
$rules = [
'title' => ['required', 'string', 'max:200'],
'description' => ['nullable', 'string'],
'location' => ['nullable', 'string'],
'all_day' => ['sometimes', 'boolean'],
'category' => ['nullable', 'string', 'max:50'],
'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'],
];
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);
$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;
$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'] ?? [],
]);
$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' => $isAllDay,
'category' => $data['category'] ?? null,
'start_at' => $startUtc,
'end_at' => $endUtc,
'start_on' => $startOn,
'end_on' => $endOn,
'tzid' => $isAllDay ? $tz : null,
'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();
}
/**
* 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 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;
}
/**
* 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;
}
}