kithkin/app/Http/Controllers/IcsController.php

158 lines
5.2 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\CalendarInstance;
use App\Models\EventMeta;
use Illuminate\Support\Facades\Response;
use Carbon\Carbon;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
class IcsController extends Controller
{
public function download(string $calendarUri)
{
$instance = CalendarInstance::where('uri', $calendarUri)
->with('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';
$ical = $this->generateICalendarFeed($calendar->events, $timezone);
return Response::make($ical, 200, [
'Content-Type' => 'text/calendar; charset=utf-8',
'Content-Disposition' => 'inline; filename="' . $calendarUri . '.ics"',
]);
}
protected function generateICalendarFeed($events, string $tz): string
{
$vcalendar = new VCalendar();
$vcalendar->add('VERSION', '2.0');
$vcalendar->add('PRODID', '-//Kithkin Calendar//EN');
$vcalendar->add('CALSCALE', 'GREGORIAN');
$vcalendar->add('METHOD', 'PUBLISH');
foreach ($events as $event) {
$ical = $event->calendardata ?? null;
if ($ical) {
try {
$parsed = Reader::read($ical);
foreach ($parsed->select('VEVENT') as $vevent) {
$cloned = clone $vevent;
$this->applyLocationProperties($cloned, $event->meta);
$vcalendar->add($cloned);
}
continue;
} catch (\Throwable $e) {
// fall through to meta-based output
}
}
$meta = $event->meta;
if (!$meta) {
continue;
}
$vevent = $vcalendar->add('VEVENT', []);
$vevent->add('UID', $event->uid);
$vevent->add('SUMMARY', $meta->title ?? '(Untitled)');
$vevent->add('DESCRIPTION', $meta->description ?? '');
if ($meta->all_day && $meta->start_on && $meta->end_on) {
$vevent->add('DTSTART', Carbon::parse($meta->start_on), ['VALUE' => 'DATE']);
$vevent->add('DTEND', Carbon::parse($meta->end_on), ['VALUE' => 'DATE']);
} elseif ($meta->start_at && $meta->end_at) {
$start = Carbon::parse($meta->start_at)->timezone($tz);
$end = Carbon::parse($meta->end_at)->timezone($tz);
$vevent->add('DTSTART', $start, ['TZID' => $tz]);
$vevent->add('DTEND', $end, ['TZID' => $tz]);
} else {
continue;
}
if ($event->lastmodified) {
$vevent->add('DTSTAMP', Carbon::createFromTimestamp($event->lastmodified)->utc());
}
$this->applyLocationProperties($vevent, $meta);
}
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
{
return str_replace(['\\', ';', ',', "\n"], ['\\\\', '\;', '\,', '\n'], $text ?? '');
}
}