158 lines
5.2 KiB
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 ?? '');
|
|
}
|
|
}
|