Skip to content

Including a URL, description, and possibly other common ICS fields #110

@mikemandolin

Description

@mikemandolin

Would be nice if an ICS file included the absolute URL of the event, and a description, i.e. the "content" CMS field of a given event collection item. I side stepped the download_link tag with a custom route and Claude 3.7 helped me out on a controller for our specific use case. it's been deployed, working well, and clients are pleased:

namespace App\Http\Controllers;

use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Statamic\Facades\Entry;

class EventIcsController extends Controller
{
    public function download($id)
    {
        // Get the event entry
        $event = Entry::find($id);

        if (!$event || $event->collection()->handle() !== 'events') {
            abort(404);
        }

        // Get event details
        $title = $event->get('title');
        $location = $event->get('location') ?? '';
        $url = $event->absoluteUrl();

        // Get description from Bard field and convert to plain text
        $description = '';
        if ($event->get('content')) {
            $description = $this->bardToPlainText($event->get('content'));
        }

        // THIS IS YOUR EXACT CODE - Preserved exactly as you provided it
        if ($event->get('multi_day')) {
            $days = $event->get('days');
            $first = reset($days);
            $last = end($days);
            $startDate = $first['date'];
            $endDate = $last['date'];
            $isAllDay = true; // Multi-day events are all-day events
        } else {
            $startDate = $event->get('start_date');
            $endDate = $event->get('end_date') ?? $startDate;

            // Check if this is an all-day event or has specific times
            $isAllDay = !($event->get('start_time') && $event->get('end_time'));

            // If it has times, append them to the dates
            if (!$isAllDay) {
                $startTime = $event->get('start_time');
                $endTime = $event->get('end_time');

                if ($startTime) {
                    $startDate .= ' ' . $startTime;
                }

                if ($endTime) {
                    $endDate .= ' ' . $endTime;
                }
            }
        }

        // Get timezone from the event if available, otherwise use the app's default timezone
        $timezone = $event->get('timezone') ?? config('app.timezone', 'America/New_York');
        $startDateObj = new \DateTime($startDate, new \DateTimeZone($timezone));
        $endDateObj = new \DateTime($endDate, new \DateTimeZone($timezone));

        // For multi-day events, increment the end date by 1 day (ICS end date is exclusive)
        if ($event->get('multi_day')) {
            $endDateObj->modify('+1 day');
        }

        // Current timestamp for DTSTAMP
        $nowObj = new \DateTime('now', new \DateTimeZone($timezone));
        $now = $nowObj->format('Ymd\THis');

        // Create the ICS content
        $icsContent = "BEGIN:VCALENDAR\r\n";
        $icsContent .= "VERSION:2.0\r\n";
        $icsContent .= "PRODID:-//Your Organization//Statamic Events//EN\r\n";
        $icsContent .= "CALSCALE:GREGORIAN\r\n";
        $icsContent .= "METHOD:PUBLISH\r\n";
        $icsContent .= "BEGIN:VEVENT\r\n";
        $icsContent .= "UID:" . md5($id) . "@" . $_SERVER['HTTP_HOST'] . "\r\n";
        $icsContent .= "DTSTAMP:" . $now . "\r\n";

        // Different formatting for all-day vs. timed events
        if ($isAllDay) {
            // All-day event (just date, no time)
            $icsContent .= "DTSTART;VALUE=DATE:" . $startDateObj->format('Ymd') . "\r\n";
            $icsContent .= "DTEND;VALUE=DATE:" . $endDateObj->format('Ymd') . "\r\n";
        } else {
            // Timed event (includes time with timezone)
            $icsContent .= "DTSTART;TZID=" . $timezone . ":" . $startDateObj->format('Ymd\THis') . "\r\n";
            $icsContent .= "DTEND;TZID=" . $timezone . ":" . $endDateObj->format('Ymd\THis') . "\r\n";

            // Add timezone information
            $icsContent .= "TZID:" . $timezone . "\r\n";
        }

        $icsContent .= "SUMMARY:" . $this->escapeString($title) . "\r\n";

        if (!empty($location)) {
            $icsContent .= "LOCATION:" . $this->escapeString($location) . "\r\n";
        }

        if (!empty($description)) {
            $icsContent .= "DESCRIPTION:" . $this->escapeString($description) . "\r\n";
        }

        // Add URL to the event page
        $icsContent .= "URL:" . $this->escapeString($url) . "\r\n";

        $icsContent .= "END:VEVENT\r\n";
        $icsContent .= "END:VCALENDAR\r\n";

        // Create response with appropriate headers
        $filename = Str::slug($title) . '-event.ics';

        $response = new Response($icsContent);
        $response->header('Content-Type', 'text/calendar; charset=utf-8');
        $response->header('Content-Disposition', 'attachment; filename="' . $filename . '"');

        return $response;
    }

    /**
     * Escape special characters in string for ICS format
     */
    private function escapeString($string)
    {
        $string = str_replace(array("\\", ";", ","), array("\\\\", "\\;", "\\,"), $string);
        $string = preg_replace('/\r\n|\n|\r/', "\\n", $string);
        return $string;
    }

    /**
     * Convert Bard field content to plain text
     */
    private function bardToPlainText($bardData)
    {
        if (!is_array($bardData)) {
            return '';
        }

        $plainText = '';

        foreach ($bardData as $block) {
            if (isset($block['type']) && $block['type'] === 'paragraph' && isset($block['content'])) {
                foreach ($block['content'] as $content) {
                    if (isset($content['text'])) {
                        $plainText .= $content['text'] . ' ';
                    }
                }
                $plainText .= "\n\n";
            } elseif (isset($block['type']) && $block['type'] === 'heading' && isset($block['content'])) {
                foreach ($block['content'] as $content) {
                    if (isset($content['text'])) {
                        $plainText .= $content['text'] . "\n";
                    }
                }
                $plainText .= "\n";
            } elseif (isset($block['type']) && $block['type'] === 'bullet_list' && isset($block['content'])) {
                foreach ($block['content'] as $listItem) {
                    if (isset($listItem['content'])) {
                        foreach ($listItem['content'] as $paragraph) {
                            if (isset($paragraph['content'])) {
                                foreach ($paragraph['content'] as $content) {
                                    if (isset($content['text'])) {
                                        $plainText .= "" . $content['text'] . "\n";
                                    }
                                }
                            }
                        }
                    }
                }
                $plainText .= "\n";
            }
            // Add more block types as needed
        }

        return trim($plainText);
    }
}

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions