Structure field to .ics file

I’ve got a series of events ordered in a structure, each of which has a title, a location, a start date and an end date.

The client now asks if it’s possible to generate a .ics file for each event individually.

I immediately thought about using the (awesome) kirby API to initiate a download. Has anyone got any experience as to how to proceed? Thanks!

Roughly, it should be possible to use the iCal file contents as a template. I don’t think Kirby cares what its generating.

You should be able to use a loop in a route to fill in the contents, I have done this to save a json file to disc that Kirby generated, and the process should be pretty similar.

The only gotcha I can think of is that I don’t know if the ical file format is sensitive to white space and line breaks that you might end up with. I can see in an example there are slashes for escaping things and newlines, im not quite sure how to handle that, but im sure someone can help you out there.

See this post.

Roughly something like this in a route should do it:

c::set('routes', array(
  array(
    'pattern' => 'events.ics',
    'action'  => function() {
      
      header('Content-Type: text/calendar; charset=utf-8');
      $events = site()->index()->visible()->eventstructurefield()->toStructure();

      foreach ($events as $event) {
          // your structure field data
      }
  
    }
  )
));

Then when you visit yourdomain.com/events.ics you will get the ics file :slight_smile: I think if you get people to point their calendars at that URL rather doing file downloads, it will be “live” as in when new events get added, they will feed through to the calendars. If you offer a download, the events are fixed at that point in time.

Well I thought about using routes as well. Trouble is that I don’t just need one .ics file for all events, but one for each event individually. Here’s as far as I got:

c::set('routes', [
  [
    'pattern' => 'programme/(:all).ics',
    'action' => function($allPlaceholder) {
      $event = page('programme/' . $allPlaceholder);
      if ($event != '') {

        header::download(['mime'=>'text/calendar', 'name' => $event->uid() . '.ics']);

        $dtstamp = '20180306T143929Z';
        $summary = addslashes($event->title());
        $dtstart = '20181201T093000';
        $dtend = '20181201T103000';
        $created = '20180306T143724Z';

        $output = <<<EOT
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Apple Inc.//Mac OS X 10.13.3//EN
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/Zurich
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
DTSTART:19810329T020000
TZNAME:UTC+2
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
DTSTART:19961027T030000
TZNAME:UTC+1
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20180306T143724Z
UID:B9C96E8A-B031-4785-9F2B-A26060ACBF1C
DTEND;TZID=Europe/Zurich:CCC
SUMMARY:BBB
DTSTART;TZID=Europe/Zurich:AAA
DTSTAMP:YYY
LOCATION:XXX
SEQUENCE:0
END:VEVENT
END:VCALENDAR
EOT;
        $f = fopen('php://output', 'w');
        fwrite($f, $output);
        fclose($f);
      }
    }
  ]
]);

I’ll try to build upon your suggestion. Thanks a lot!

No worries. Might be worth leaning on Kirbies toolkit (these are basically a bunch of helpers that Kirby is built on top of, as I understand it, and you can use it seperatly from Kirby.)

It has stuff in it for dealing with file read/writes and other file operations so I think you can clean up those last three lines that deal with creating the file.

I dont have much experience with the toolkit, so if you get stuck, please just ask and im sure someone can help you.

You can create a snippet similar to this one: https://github.com/mzur/kirby-calendar-plugin/blob/master/snippets/calendar-ical.php (without the foreach loop, because you only want a single event):

Then your route can be pretty straightforward:

c::set('routes', [
  [
    'pattern' => 'programme/(:all).ics',
    'action' => function($allPlaceholder) {
      $event = page('programme/' . $allPlaceholder);
     // don't know what this if-statement is supposed to do? You probably want to check if the page exists using if($event)
      if ($event != '') {

       header::download(['mime'=>'text/calendar', 'name' => $event->uid() . '.ics']);
       return snippet('ical', ['event' => $event]);  // you'd have to adapt the snippet to use your fields
       
      }
    }
  ]
]);

You probably have to force new lines in the snippet as well. They’re missing in the example file but I think are needed.

@Malcolm did you managed that one?
I have the same function to integrate. I have Events in a structure field on the startpage of the site.
There is a slider where all events are shown, i want to give the certain event as a ics file download.

My Question right now is, how do i get the Data for the ICS file, which is stored in the structured field in the startpage.

In a nutshell, you have (at least) two ways to achieve what you’re trying to get: either with the help of PHP (server-side) or JS (client-side). This topic lead me to choose the first option, which worked perfectly (all browsers/OSes). But after second thoughts, I went for the JS version (less compatibility but way cleaner code). Here’s the breakdown:

PHP-version

Once your structured field is set up as a collection, put something like this in your template (or better: controller):

<?php // Build ics export
$icsTitle = 't="' . $event->event_title()->html() . '"';
$icsLocation = 'l="' . $event->event_location()->html() . '"';
$icsStart = 's="' . $event->date('Ymd\THis', 'event_dtstart') . '"';
$icsEnd = 'e="' . $event->date('Ymd\THis', 'event_dtend') . '"';
$icsQuery = '/ics/' . rawurlencode($icsTitle . '&' . $icsLocation . '&' . $icsStart . '&' . $icsEnd);
?>

The resulting $icsQuery is a concatenated string containing all relevant information regarding your event, that you can use like this:

<a href="<?= url() . $icsQuery ?>">my awesome event</a>

Thus, this link will essentially lead to some specific URL, which your router (see config.php) will interpret. This was how it was set up in my case:

// Generate ics file
c::set('routes',array(
  array(
    'pattern' => 'ics/(:all)',
    'action'  => function($allPlaceholder) {

      // some regex magic
      preg_match('/t%3D%22(.+?)%22%26l%3D%22(.+?)%22%26s%3D%22(.+?)%22%26e%3D%22(.+?)%22/', $allPlaceholder, $m);

      header::download(['mime'=>'text/calendar', 'name' => html_entity_decode(rawurldecode($m[1])) . '.ics']);
      return snippet('components/ics', ['event' => $m]);

      }
    )
  ));

You’ll see in this piece of code that it links to a snippet located in ‘components/ics’. Here is the relevant code:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//whatever you want
METHOD:PUBLISH
BEGIN:VEVENT
DTSTART;TZID=Europe/Zurich:<?= rawurldecode($event[3]) . "\n"; ?>
DTEND;TZID=Europe/Zurich:<?= rawurldecode($event[4]) . "\n"; ?>
SUMMARY:arbitrary text : <?= html_entity_decode(rawurldecode($event[1])) . "\n"; ?>
LOCATION:<?= html_entity_decode(rawurldecode($event[2])) . "\n"; ?>
END:VEVENT
END:VCALENDAR

Should you need more info regarding the formatting of an ICS file, head over to here: https://www.kanzaki.com/docs/ical/

JS-version

With this approach you’ll add all relevant info regarding your event by using data-attributes.

Add this to your template file:

<article class="event"
data-title="<?= $event->event_title()->html() ?>"
data-description="<?= $event->event_description()->escape() ?>"
data-location="<?= $event->event_location()->html() ?>"
data-start="<?= $event->date('Ymd\THis', 'event_dtstart') ?>"
data-end="<?= $event->date('Ymd\THis', 'event_dtend') ?>"
data-uid="<?= $uid = uniqid() ?>">

Then, create the link to download your ics file (I’ve used span, but it can be anything else):

<span class="link" onclick="ics(this)"><?= event_time($event, 'H\hi') ?></span>

Notice this bit: onclick="ics(this)" ? This calls the javascript function “ics” and adds this specific span node as parameter.

This “ics” function is then defined thanks to the following piece of code, which you’ll have to load in a .js file, or enclosed in a <script></script> tag:

function ics(e){

  // find parent node that matches the "event" class, since the span node doesn't contain any info https://stackoverflow.com/questions/22119673/find-the-closest-ancestor-element-that-has-a-specific-class
  while ((e = e.parentElement) && !e.classList.contains('event'));

  var e = {
    title: e.dataset.title.replace(/<(.|\n)*?>/g, ''),
    description: e.dataset.description.replace(/<(.|\n)*?>/g, ''),
    location: e.dataset.location.replace(/<(.|\n)*?>/g, ''),
    start: e.dataset.start,
    end: e.dataset.end,
    uid: e.dataset.uid
  };

  // helper functions

  // iso date for ical formats
  e._isofix = function(){
    var
    n = new Date(),
    y = n.getFullYear(),
    m = e._zp(n.getMonth()+1),
    f = e._zp(n.getDate()),
    h = e._zp(n.getHours()),
    o = e._zp(n.getMinutes()),
    s = e._zp(n.getSeconds());
    return y+m+f+'T'+h+o+s;
  }

  //zero padding for data fixes
  e._zp = function(s){return ("0"+s).slice(-2);}
  e._save = function(fileURL){

    // Blob
    var blob = new Blob([fileURL], {type : 'text/calendar'});
    var objectUrl = window.URL.createObjectURL(blob);

    if (navigator.msSaveBlob) { // IE11+ : (has Blob, but not a[download])
      navigator.msSaveBlob(blob, 'CIB2018 – '+e.title+'.ics');
    } else if (navigator.msSaveOrOpenBlob) { // IE10+ : (has Blob, but not a[download])
      navigator.msSaveOrOpenBlob(blob, 'CIB2018 – '+e.title+'.ics');
    } else {

      // A-download
      var anchor = document.createElement('a');
      anchor.setAttribute('href', objectUrl);
      anchor.setAttribute('download', 'CIB2018 – '+e.title+'.ics');

      // Firefox requires the link to be added to the DOM before it can be clicked.
      document.body.appendChild(anchor);
      anchor.click();
      document.body.removeChild(anchor);
    }
    setTimeout(function() { // Firefox needs a timeout
      window.URL.revokeObjectURL(objectUrl);
    }, 0);
  }

  var now = new Date();
  var ics_lines = [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "PRODID:-//whatever,
    "METHOD:PUBLISH",
    "BEGIN:VEVENT",
    "UID:"+e.uid,
    "DTSTAMP:"+e._isofix(),
    "DTSTART;TZID=Europe/Zurich:"+e.start,
    "DTEND;TZID=Europe/Zurich:"+e.end,
    "SUMMARY;LANGUAGE=fr-ch:"+e.title.replace(/[^ -ù]+/g, ''),
    "DESCRIPTION;LANGUAGE=fr-ch:"+e.description.replace(/[^ -ù]+/g, ''),
    "URL;VALUE=URI://yoururl,
    "LAST-MODIFIED:"+e._isofix(),
    "SEQUENCE:0",
    "END:VEVENT",
    "END:VCALENDAR"
  ];

  var dlurl = ics_lines.join('\r\n');

  try {
    e._save(dlurl);
  }catch(e){
    console.log(e);
  }
}

You’ll see the result here: https://cib2018.ch/#programme

Caveat: I still haven’t gotten the JS version to work under android…

Good luck!

1 Like

What’s the purpose of that query string instead of just using the event uri? Kirby routes ignore search strings anyway.

What do you mean by event uri? Remember the events come from a structured field and don’t have their own specific page/url

@Malcolm Yes, you are right, I totally forgot that. In. that case I’d use a unique ID.

I just tested out the PHP Solution @Malcolm, that worked so far.
But did you notice a “arbitrary text :” in your event when you import the ics file?

@texnixe the idea’s good, but then what? An UID would have to be stored automatically in the structred YAML file (dunno how to do this). And since we’re passing it as an url, the router would then have to query again to YAML file in order to get the date, time, description, etc in order to build the ics file. That I also don’t know how to do. It seemed easier to just pass all the parameters straight through the link.

@phoenixh yes, that bit is actually just a comment to your attention. Replace it with anything you want.

My proposed solution is just the one I used. But since I’m by no means an expert, feel free to improve it.

Ok, I see your point.

I created a plugin that can automatically add an AutoID field to a structure item. This can be useful in various use cases.

True. But that’s no problem if you have the unique ID and shouldn’t be a problem performance-wise.

The advantage of the suggested approach would be a cleaner URL, especially if you include a description. With your approach you would end up with a very long URL.

Oh! Very elegant indeed! I’ll see what I can do in the following days/weeks and post my solution (if any) for future reference. I’ll also think about writing a little plugin for this very purpose and cite you if you don’t mind.

@texnixe
Do i understand it right, your Plugin can add a unqiue id to a structre field that i am using on the startpage, so i can grab all data related to that unique id and display it elsewhere in the site?

@Malcolm my fault with the “arbitrary text :”, dont know why i didnt read this one.

Exactly, it uses a hook so that when the page is saved, a unique ID is added to an ID field (you can set that field in your config and you can use a hidden field if you prefer). It works like the AutoID field, only for structure fields instead of pages.

You can then find the structure item in the collection using findBy().

1 Like

Aaaaah… cool, thats sweet and could be very interessting for us. Thank you!

The only place where the structure ID plugin currently doesn’t work is the site options. I’m planning to add that as soon as I have some spare time.

By default, the hash is generated like this:

$hashID = md5(microtime().session_id());

You can, however, use your own hash generator if you want.

1 Like

Here’s my take.

Thanks @texnixe for your ideas and guidance!

2 Likes