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 || !$meta->start_at || !$meta->end_at) { continue; } $start = Carbon::parse($meta->start_at)->timezone($tz); $end = Carbon::parse($meta->end_at)->timezone($tz); $vevent = $vcalendar->add('VEVENT', []); $vevent->add('UID', $event->uid); $vevent->add('SUMMARY', $meta->title ?? '(Untitled)'); $vevent->add('DESCRIPTION', $meta->description ?? ''); $vevent->add('DTSTART', $start, ['TZID' => $tz]); $vevent->add('DTEND', $end, ['TZID' => $tz]); 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 ?? ''); } }