ICS controller cleanup and improvements to location metadata in VEVENT

This commit is contained in:
Andrew Gioia 2026-02-09 16:43:38 -05:00
parent 5f4cffd5aa
commit ef658d1c04
Signed by: andrew
GPG Key ID: FC09694A000800C8
6 changed files with 135 additions and 8 deletions

View File

@ -161,12 +161,20 @@ class CalendarSettingsController extends Controller
// if it's remote // if it's remote
$icsUrl = null; $icsUrl = null;
if (($meta?->is_remote ?? false) && $meta?->subscription_id) { $shareUrl = null;
$isRemote = (bool) ($meta?->is_remote ?? false);
$isShared = (bool) ($meta?->is_shared ?? false);
if ($isRemote && $meta?->subscription_id) {
$icsUrl = Subscription::query() $icsUrl = Subscription::query()
->whereKey($meta->subscription_id) ->whereKey($meta->subscription_id)
->value('source'); ->value('source');
} }
if (!$isRemote && $isShared) {
$shareUrl = route('calendar.ics', ['calendarUri' => $instance->uri]);
}
return $this->frame( return $this->frame(
'calendar.settings.calendar', 'calendar.settings.calendar',
[ [
@ -175,6 +183,7 @@ class CalendarSettingsController extends Controller
'instance' => $instance, 'instance' => $instance,
'meta' => $meta, 'meta' => $meta,
'icsUrl' => $icsUrl, 'icsUrl' => $icsUrl,
'shareUrl' => $shareUrl,
'userTz' => $user->timezone, 'userTz' => $user->timezone,
]); ]);
} }
@ -195,6 +204,7 @@ class CalendarSettingsController extends Controller
'description' => ['nullable', 'string', 'max:500'], 'description' => ['nullable', 'string', 'max:500'],
'timezone' => ['nullable', 'string', 'max:64'], 'timezone' => ['nullable', 'string', 'max:64'],
'color' => ['nullable', 'regex:/^#[0-9A-F]{6}$/i'], 'color' => ['nullable', 'regex:/^#[0-9A-F]{6}$/i'],
'is_shared' => ['nullable', 'boolean'],
]); ]);
$timezone = filled($data['timezone'] ?? null) $timezone = filled($data['timezone'] ?? null)
@ -204,6 +214,8 @@ class CalendarSettingsController extends Controller
$color = $data['color'] ?? $instance->resolvedColor(); $color = $data['color'] ?? $instance->resolvedColor();
DB::transaction(function () use ($instance, $data, $timezone, $color) { DB::transaction(function () use ($instance, $data, $timezone, $color) {
$isRemote = (bool) ($instance->meta?->is_remote ?? false);
$isShared = $isRemote ? false : (bool) ($data['is_shared'] ?? false);
// update sabre calendar instance (dav-facing) // update sabre calendar instance (dav-facing)
$instance->update([ $instance->update([
@ -220,6 +232,7 @@ class CalendarSettingsController extends Controller
'title' => $data['displayname'], 'title' => $data['displayname'],
'color' => $color, 'color' => $color,
'color_fg' => contrast_text_color($color), 'color_fg' => contrast_text_color($color),
'is_shared' => $isShared,
] ]
); );
}); });

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\CalendarInstance; use App\Models\CalendarInstance;
use App\Models\EventMeta;
use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Response;
use Carbon\Carbon; use Carbon\Carbon;
use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VCalendar;
@ -12,9 +13,17 @@ class IcsController extends Controller
{ {
public function download(string $calendarUri) public function download(string $calendarUri)
{ {
$instance = CalendarInstance::where('uri', $calendarUri)->firstOrFail(); $instance = CalendarInstance::where('uri', $calendarUri)
->with('meta')
->firstOrFail();
$calendar = $instance->calendar()->with(['events.meta'])->firstOrFail(); $isRemote = (bool) ($instance->meta?->is_remote ?? false);
$isShared = (bool) ($instance->meta?->is_shared ?? false);
if ($isRemote || !$isShared) {
abort(404);
}
$calendar = $instance->calendar()->with(['events.meta.venue'])->firstOrFail();
$timezone = $instance->timezone ?? 'UTC'; $timezone = $instance->timezone ?? 'UTC';
$ical = $this->generateICalendarFeed($calendar->events, $timezone); $ical = $this->generateICalendarFeed($calendar->events, $timezone);
@ -40,7 +49,9 @@ class IcsController extends Controller
try { try {
$parsed = Reader::read($ical); $parsed = Reader::read($ical);
foreach ($parsed->select('VEVENT') as $vevent) { foreach ($parsed->select('VEVENT') as $vevent) {
$vcalendar->add(clone $vevent); $cloned = clone $vevent;
$this->applyLocationProperties($cloned, $event->meta);
$vcalendar->add($cloned);
} }
continue; continue;
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -62,15 +73,76 @@ class IcsController extends Controller
$vevent->add('DESCRIPTION', $meta->description ?? ''); $vevent->add('DESCRIPTION', $meta->description ?? '');
$vevent->add('DTSTART', $start, ['TZID' => $tz]); $vevent->add('DTSTART', $start, ['TZID' => $tz]);
$vevent->add('DTEND', $end, ['TZID' => $tz]); $vevent->add('DTEND', $end, ['TZID' => $tz]);
$vevent->add('DTSTAMP', Carbon::parse($event->lastmodified)->utc()); if ($event->lastmodified) {
if ($meta->location) { $vevent->add('DTSTAMP', Carbon::createFromTimestamp($event->lastmodified)->utc());
$vevent->add('LOCATION', $meta->location);
} }
$this->applyLocationProperties($vevent, $meta);
} }
return $vcalendar->serialize(); return $vcalendar->serialize();
} }
protected function applyLocationProperties($vevent, ?EventMeta $meta): void
{
if (!$meta) {
return;
}
$venue = $meta->venue;
$label = trim((string) ($meta->location_label ?? $meta->location ?? $venue?->display_name ?? ''));
$address = trim((string) ($venue?->raw_address ?? ''));
if ($address === '') {
$address = trim(implode("\n", array_filter([
$venue?->street,
trim(implode(' ', array_filter([$venue?->city, $venue?->state, $venue?->postal]))),
$venue?->country,
])));
}
$locationText = trim(implode("\n", array_filter([
$label !== '' ? $label : null,
$address !== '' ? $address : null,
])));
if (!isset($vevent->LOCATION) && $locationText !== '') {
$vevent->add('LOCATION', $locationText);
}
$lat = $venue?->lat;
$lon = $venue?->lon;
if (!is_numeric($lat) || !is_numeric($lon)) {
return;
}
if (!isset($vevent->GEO)) {
$vevent->add('GEO', sprintf('%.6f;%.6f', $lat, $lon));
}
if (!isset($vevent->{'X-APPLE-STRUCTURED-LOCATION'})) {
$params = [
'VALUE' => 'URI',
];
if ($address !== '') {
$params['X-ADDRESS'] = $address;
}
if ($label !== '') {
$params['X-TITLE'] = $label;
}
if (!empty($params['X-ADDRESS']) || !empty($params['X-TITLE'])) {
$params['X-APPLE-REFERENCEFRAME'] = '1';
$params['X-APPLE-RADIUS'] = '150';
$vevent->add(
'X-APPLE-STRUCTURED-LOCATION',
sprintf('geo:%.6f,%.6f', $lat, $lon),
$params
);
}
}
}
protected function escape(?string $text): string protected function escape(?string $text): string
{ {
return str_replace(['\\', ';', ',', "\n"], ['\\\\', '\;', '\,', '\n'], $text ?? ''); return str_replace(['\\', ';', ',', "\n"], ['\\\\', '\;', '\,', '\n'], $text ?? '');

View File

@ -16,6 +16,10 @@ return [
'create' => 'Create calendar', 'create' => 'Create calendar',
'description' => 'Description', 'description' => 'Description',
'ics' => [ 'ics' => [
'share' => 'Share this calendar publicly',
'share_help' => 'Anyone with this link can subscribe to your calendar.',
'public_url' => 'Public subscription URL',
'public_url_help' => 'Copy this URL into a third-party calendar app to subscribe.',
'url' => 'ICS URL', 'url' => 'ICS URL',
'url_help' => 'You can\'t edit a public calendar URL. If you need to make a change, unsubscribe and add it again.', 'url_help' => 'You can\'t edit a public calendar URL. If you need to make a change, unsubscribe and add it again.',
], ],

View File

@ -16,6 +16,10 @@ return [
'create' => 'Crea calendario', 'create' => 'Crea calendario',
'description' => 'Descrizione', 'description' => 'Descrizione',
'ics' => [ 'ics' => [
'share' => 'Condividi questo calendario pubblicamente',
'share_help' => 'Chiunque abbia questo link puo iscriversi al tuo calendario.',
'public_url' => 'URL pubblico di iscrizione',
'public_url_help' => 'Copia questo URL in un app calendario di terze parti per iscriverti.',
'url' => 'URL ICS', 'url' => 'URL ICS',
'url_help' => 'Non puoi modificare un URL di calendario pubblico. Se devi fare una modifica, annulla l iscrizione e aggiungilo di nuovo.', 'url_help' => 'Non puoi modificare un URL di calendario pubblico. Se devi fare una modifica, annulla l iscrizione e aggiungilo di nuovo.',
], ],

View File

@ -10,6 +10,8 @@
$meta = $data['meta'] ?? null; $meta = $data['meta'] ?? null;
$isRemote = (bool) ($meta?->is_remote ?? false); $isRemote = (bool) ($meta?->is_remote ?? false);
$isShared = (bool) ($meta?->is_shared ?? false);
$shareUrl = $data['shareUrl'] ?? null;
$color = old('color', $instance->resolvedColor()); $color = old('color', $instance->resolvedColor());
@ -75,6 +77,37 @@
</div> </div>
</div> </div>
@if (!$isRemote)
<div class="input-row input-row--1">
<div class="input-cell">
<input type="hidden" name="is_shared" value="0">
<x-input.checkbox-label
name="is_shared"
value="1"
:checked="old('is_shared', $isShared)"
:label="__('calendar.ics.share')"
/>
<p class="text-sm text-gray-500 mt-1">{{ __('calendar.ics.share_help') }}</p>
</div>
</div>
@if ($shareUrl)
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.text-label
:label="__('calendar.ics.public_url')"
id="ics_public_url"
name="ics_public_url"
type="url"
:value="$shareUrl"
disabled="true"
:description="__('calendar.ics.public_url_help')"
/>
</div>
</div>
@endif
@endif
@if ($isRemote) @if ($isRemote)
<div class="input-row input-row--1"> <div class="input-row input-row--1">
<div class="input-cell"> <div class="input-cell">

View File

@ -161,7 +161,8 @@ Route::match(
->withoutMiddleware([VerifyCsrfToken::class]); ->withoutMiddleware([VerifyCsrfToken::class]);
// our subscriptions // our subscriptions
Route::get('/ics/{calendarUri}.ics', [IcsController::class, 'download']); Route::get('/ics/{calendarUri}.ics', [IcsController::class, 'download'])
->name('calendar.ics');
// remote subscriptions // remote subscriptions
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {