kithkin/app/Http/Controllers/EventController.php

352 lines
12 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\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;
use Sabre\VObject\Reader;
class EventController extends Controller
{
/**
* create a new event page
*/
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');
return view('event.form', compact(
'calendar',
'instance',
'event',
'start',
'end',
'tz',
));
}
/**
* edit event page
*/
public function edit(Calendar $calendar, Event $event, Request $request)
{
$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;
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz'));
}
/**
* single event view handling
*/
public function show(Request $request, Calendar $calendar, Event $event)
{
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 meta utc timestamps, fall back to sabre columns
$startUtc = $event->meta?->start_at
? Carbon::parse($event->meta->start_at)->utc()
: Carbon::createFromTimestamp($event->firstoccurence, 'UTC');
$endUtc = $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 sabres calendarobjects + meta row
*/
public function store(Request $request, Calendar $calendar, Geocoder $geocoder): 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'],
// 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);
$description = $this->escapeIcsText($data['description'] ?? '');
$locationStr = $this->escapeIcsText($data['location'] ?? '');
// write dtstart/dtend as utc with "Z" so we have one canonical representation
$ical = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Kithkin//Laravel CalDAV//EN
BEGIN:VEVENT
UID:$uid
DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')}
DTSTART:{$startUtc->format('Ymd\\THis\\Z')}
DTEND:{$endUtc->format('Ymd\\THis\\Z')}
SUMMARY:{$this->escapeIcsText($data['title'])}
DESCRIPTION:$description
LOCATION:$locationStr
END:VEVENT
END:VCALENDAR
ICS;
$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,
]);
return redirect()->route('calendar.show', $calendar);
}
/**
* update vevent + meta
*/
public function update(Request $request, Calendar $calendar, Event $event): 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'],
]);
$tz = $this->displayTimezone($calendar, $request);
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
$uid = $event->uid;
$description = $this->escapeIcsText($data['description'] ?? '');
$locationStr = $this->escapeIcsText($data['location'] ?? '');
$summary = $this->escapeIcsText($data['title']);
$ical = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Kithkin//Laravel CalDAV//EN
BEGIN:VEVENT
UID:$uid
DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')}
DTSTART:{$startUtc->format('Ymd\\THis\\Z')}
DTEND:{$endUtc->format('Ymd\\THis\\Z')}
SUMMARY:$summary
DESCRIPTION:$description
LOCATION:$locationStr
END:VEVENT
END:VCALENDAR
ICS;
$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,
]);
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;
}
/**
* 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;
}
}