Skip to content

Instantly share code, notes, and snippets.

@tomjaimz
Last active June 4, 2024 10:23
Show Gist options
  • Save tomjaimz/93bb639ba2bb61f99cf5cb942edc361b to your computer and use it in GitHub Desktop.
Save tomjaimz/93bb639ba2bb61f99cf5cb942edc361b to your computer and use it in GitHub Desktop.
Google App Script code to import events from a Calendar (.ics) file into Google Sheets.
function onOpen() {
SpreadsheetApp.getUi().createAddonMenu().addItem('Upload', 'upload').addToUi();
}
function upload() {
SpreadsheetApp.getUi().showModalDialog(HtmlService.createHtmlOutput(
'<form><input type="file" name="file" onchange="var reader = new FileReader(); reader.onloadend = (e) => google.script.run.withSuccessHandler(google.script.host.close).importICS(e.target.result); reader.readAsText(this.parentNode.file.files[0]);"></form>'
), 'Select .ics file');
}
function importICS(form) {
var inputFields = ['DTSTART', 'DTEND', 'SUMMARY'], // add to this to add more fields
data = [['Start', 'End', 'Summary']], // should correspond to inputFields
reDateForDateFunction = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/g,
lines = form.split(/\r\n|\n|\r/);
for (i in lines) {
var lineData = lines[i].split(':'), col = inputFields.indexOf(lineData[0])
if(lines[i] === "BEGIN:VEVENT") {
data[data.length] = [];
}
else if(col !== -1) { // exists in inputFields
data[data.length - 1][col] = (lineData[0].substr(0, 2) === 'DT') ? new Date(lineData[1].replace(reDateForDateFunction, "$1-$2-$3T$4:$5:$6Z")) : lineData[1]
}
}
SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getRange(1, 1, data.length, data[0].length).setValues(data)
}
@tomjaimz
Copy link
Author

tomjaimz commented Oct 3, 2018

Instructions.

You will need: an .ics file, sourced elsewhere.

  1. Open a blank spreadsheet (https://docs.google.com/spreadsheets/ and select Blank).
  2. From the Tools menu select Script Editor.
  3. Paste the above code into the Code.gs, replacing the existing function myFunction() { } code.
  4. Save the script - give it a good name like ICS Importer.
  5. Go back to your spreadsheet and reload the page.
  6. Under the Add-ons menu there should now be an option corresponding to the name you gave your script in step 4. Under that option there should be an Upload item. Select it.
  7. If it's your first time you'll be prompted to accept some permissions.
  8. Select an .ics file using the file dialog.
  9. After you have chosen a file it will process in the background. When it's done, the dialog will disappear.

Notes.

  • The import will always overwrite whatever's already in your spreadsheet, so use a blank one.
  • You can add fields to import by changing lines 12 and 13 in the code. Some options might be DESCRIPTION and LOCATION.
  • There's no real error handling going on here. You could upload a non-ics file or do many other things. It's a minimum viable hack.

@jprobst24
Copy link

Thanks for your work on this.
I obtain the following error when following the directions as specified:
SyntaxError: Unexpected token import (line 11, file "Code.gs")

@tomjaimz
Copy link
Author

Hi @jprobst24,

Google have updated the JavaScript engine for Google App Script from "Rhino", which used ES5, to V8, which uses ES6 (and more). Included in this update was a new "import" function, which is why my own custom import function failed.

I updated my gist to accommodate that. In testing I also discovered that passing "this.parentNode" didn't seem to work anymore so I rewrote that part to use a FileReader.

@ElinaKra
Copy link

ElinaKra commented Sep 1, 2020

Hello Tom,
This is great. I was wondering if there is an option to upload several calendars at the same time just placing them in different sheets?

@tomjaimz
Copy link
Author

tomjaimz commented Sep 1, 2020

Hey Elina,

You could likely change the code to do something like that. In its current form it'll upload an .ics to the current sheet, so you could just make a new sheet and upload a new ics to that, although of course that takes a little longer than what you want.

@ElinaKra
Copy link

ElinaKra commented Sep 1, 2020

Hello Tom,
Yes, that's what I was thinking to do, since I am not sure how to change the code.
Another thing I noticed is that all recurrect events come with blank dates. Any idea if this is due to the code or? The events each have a line, but the date is not appearing.

@bonyaroslav
Copy link

Wow. Thank you so much! I can now analyze my events to do some retro!

@error9900
Copy link

Change Line 21 from:
else if(col !== -1) { // exists in inputFields
to:
else if(col !== -1 && data.length > 1) { // exists in inputFields, and BEGIN:VEVENT found
to avoid problems caused by DTSTART showing up in the .ics file before BEGIN:VEVENT.

@error9900
Copy link

@ElinaKra I noticed a few different formats for dates/times, so this may resolve the issue you were seeing with recurring events:

Follow the instructions here to create a Library for Moment.js, to be able to easily handle time zones:
https://stackoverflow.com/a/55027287

Add this function above the importICS function:

// https://stackoverflow.com/a/55027287
function toUtc(dateTime, timeZone) {  
  var DT_FORMAT = 'YYYY-MM-DD HH:mm:ss';
  var from = m.moment.tz(dateTime, DT_FORMAT, timeZone); // https://momentjs.com/timezone/docs/#/using-timezones/parsing-in-zone/
  return from.utc().format(DT_FORMAT);
}

then I made a few changes to the importICS function to speed up handling of the SUMMARY field, as well as handle the various date/time formats I had in my .ics file:

function importICS(form) {
  var inputFields = ['DTSTART', 'DTEND', 'SUMMARY'], // add to this to add more fields
      data = [['Start', 'End', 'Summary']], // should correspond to inputFields
      reDateForDateFunction = /^(\d{4})(\d{2})(\d{2})$/g,
      reDateTimeUTCForDateFunction = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/g,
      lines = form.split(/\r\n|\n|\r/);
  for (i in lines) {
    var lineData = lines[i].split(/;|:/), col = inputFields.indexOf(lineData[0])
    if(lines[i] === "BEGIN:VEVENT") {
      data[data.length] = [];
    }
    else if(col == 2) {
      data[data.length - 1][2] = lineData[1]
    }
    else if((col == 0 || col == 1) && data.length > 1) { // exists in inputFields, and BEGIN:VEVENT found
      if(lineData.length == 2) {
        if(lineData[1].length == 16) {
          // DTSTART:19970714T173000Z
          data[data.length - 1][col] = new Date(lineData[1].replace(reDateTimeUTCForDateFunction, "$1-$2-$3T$4:$5:$6Z"))
        }
      }
      else if(lineData.length == 3) {
        if(lineData[1] == "VALUE=DATE") {
          // DTSTART;VALUE=DATE:19970714
          var year = lineData[2].replace(reDateForDateFunction, "$1")
          var month = lineData[2].replace(reDateForDateFunction, "$2")
          var day = lineData[2].replace(reDateForDateFunction, "$3")
          data[data.length - 1][col] = new Date(year, month-1, day)
        }
        else if(lineData[1].substr(0, 4) == "TZID") {
          // DTSTART;TZID=America/New_York:19970714T133000
          data[data.length - 1][col] = new Date(toUtc(lineData[2],lineData[1].substr(5)))
        }
      }
    }
  }
  SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getRange(1, 1, data.length, data[0].length).setValues(data)
}

@error9900
Copy link

Hmm. Looks like it still doesn't work for recurring events, but looks like that's gonna involve doing some calculations whenever an event has the RRULE field, which is a bit more involved...

BEGIN:VEVENT
DTSTART;TZID=America/New_York:20210527T170000
DTEND;TZID=America/New_York:20210527T180000
RRULE:FREQ=WEEKLY;WKST=TU;UNTIL=20210603T035959Z
DTSTAMP:20210908T183617Z
UID:xxxxxxxxxxxxxxxxxxxxxxx@google.com
CREATED:20210528T170413Z
DESCRIPTION:
LAST-MODIFIED:20210602T183748Z
LOCATION:
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Some event
TRANSP:OPAQUE
END:VEVENT

@mrhr95
Copy link

mrhr95 commented Dec 1, 2021

Thank you Tom!

Any idea how I can load the .ics-file direcly from the web from a known URL? I do not understand exactly whitch type of data is passed from upload() to importICS(form)

@mrhr95
Copy link

mrhr95 commented Dec 1, 2021

Change line 22 from
data[data.length - 1][col] = (lineData[0].substr(0, 2) === 'DT') ? new Date(lineData[1].replace(reDateForDateFunction, "$1-$2-$3T$4:$5:$6Z")) : lineData[1]
to
data[data.length - 1][col] = (lineData[0].substr(0, 2) === 'DT') ? new Date(lineData[1].replace(reDateForDateFunction, "$1-$2-$3T$4:$5:$6Z")) : lineData.slice(1).join(':')
to avoid cutting off data witch contains :

@Herriaan
Copy link

@mrhr95 Any answer found on your question how to load the .ics-file direcly from the web? Would love to automate my Google Calendar to Sheets, and that's the only missing piece for me.

@mrhr95
Copy link

mrhr95 commented Jan 20, 2022

@Herriaan Yes indeed. I'll post it below, but i'd recommend not using this for syncing Goolge Calender with Google Sheet, especially not your private ones. Instead you should use the built in ability of Apps Script to directly read and write events in Google Calender. Works better and is more secure (you don't need to publish an .ics of your calender)
See documentation of the Calendar-Service

This following function downloads an .ics-file that is publicly available under a known domain, and hands it to the function importICS(form):

function downloadICS() {

  var fileURL = 'https://.../calendar.ics';

  var response = UrlFetchApp.fetch(fileURL, {muteHttpExceptions: true}) ;
  var rc = response.getResponseCode()
  if (rc == 200) {
    importICS(response)
  }
    
}

@colchesterwebsiteservices

Hi Tom et al, this is an incredibly useful script which i have working. I can't work out how to display the start and end time of each appointment though. I guess it is $4 and $5 but I can't work out how to display it. Any help much appreciated. Thanks very much

@colchesterwebsiteservices

While I still can't display the time properly, in case it helps anyone else, I had to change Line 15 to lines = form.toString().split(/\r\n|\n|\r/); to avoid an error message. Brilliant script, i'm using it to push data to a web portal

@Hal-software
Copy link

Hal-software commented Oct 4, 2023

There where some errors in the code above, like for example when date is including TimeZone or when a FIELD is not existing on the ics file.
I have done some corrections and this is my working code. It allows not only to upload a .ics file from your computer but also import it from a URL (that has to be obtained from your Google calendar private url).
`

function onOpen(e) {
  SpreadsheetApp.getUi().createAddonMenu()
  .addItem('Upload file', 'upload')
  .addItem('Open file from URL', 'downloadICS')
  .addToUi();
  //downloadICS()
}

function downloadICS() {
  var fileURL = 'https://calendar.google.com/....................../basic.ics';
  var response = UrlFetchApp.fetch(fileURL, {muteHttpExceptions: true}) ;
  var rc = response.getResponseCode()
  if (rc == 200) {
    importICS(response.getContentText())
  }
}

function upload() {
  SpreadsheetApp.getUi().showModalDialog(HtmlService.createHtmlOutput(
    '<form><input type="file" name="file" onchange="var reader = new FileReader(); reader.onloadend = (e) => google.script.run.withSuccessHandler(google.script.host.close).importICS(e.target.result); reader.readAsText(this.parentNode.file.files[0]);"></form>'
  ), 'Select .ics file');
}

function importICS(form) {
  var inputFields = ['DTSTART', 'SUMMARY', 'DESCRIPTION'], // add to this to add more FIELDs
      data = [['Inicio', 'Resumen', 'Descripcion']], // should correspond to inputFields
      lines = form.split(/\r\n|\n|\r/);
  for (i in lines) {
    var lineData = lines[i].split(':'), col = inputFields.indexOf(lineData[0].split(';',1)[0])
    if(lines[i] === "BEGIN:VEVENT") { //Adds a new row and initialize it to avoid issues when not found
      data[data.length] = ["","",""]; //If a FIELD is not present on the file, it makes an error: Better to initialize!
      // Its length should correspond to inputFields. Eg: for 7 read fiels --> = ["","","","","","",""]; 
    }
    else if (lines[i].substr(0, 1) === ' ') {
      //If row starts with just SPACE instead of a FIELD, it must be added to previous line as it belows to a multiple line FIELD
      // https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.11
      data[data.length - 1][previous_col] = data[data.length - 1][previous_col] + lines[i]
    }
    else if(col !== -1 && data.length > 1) { // exists in inputFields, and BEGIN:VEVENT found
      //Browser.msgBox("Dealing with: /////////0:" + lineData[0] + " //////////////1: " +  lineData[1] )
      // Date (YYYY,MM,DD) Thus: lineData[1].substr(0, 4) is YYYY, lineData[1].substr(4, 2)-1 is MM (from 0-11) and the other is DD. And last, lineData[1] is the text DATA to import when it is not a Date (DTStart or DTend)
      data[data.length - 1][col] = (lineData[0].substr(0, 2) === 'DT') ? new Date(lineData[1].substr(0, 4),lineData[1].substr(4, 2)-1,lineData[1].substr(6, 2) ) : lineData[1]
      
      for (var j=2; j<lineData.length ; j=j+1) { //It might happen that someone added ':' to the FIELD and therefore it was split too 
        data[data.length - 1][col] = data[data.length - 1][col] + ":" + lineData[j]
      }
      var previous_col = col //To remember the last FIELD saved
    }
  }
  Browser.msgBox( "Total imported lines: " + data.length + " Columns: " + data[0].length )
  SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getRange(1, 1, data.length, data[0].length).setValues(data)
}

`

@claydegruchy
Copy link

claydegruchy commented Jun 4, 2024

Another version that avoid usage of set values to allow the output to be inserted into the context of the command.

usage: =insertICS(".../basic.ics")
:


function icsToTable(icsString) {
  var inputFields = ['DTSTART', 'SUMMARY', 'DESCRIPTION'], // add to this to add more FIELDs
    data = [['Start', 'Summary', 'Description']], // should correspond to inputFields
    lines = icsString.split(/\r\n|\n|\r/);
  for (i in lines) {
    var lineData = lines[i].split(':'), col = inputFields.indexOf(lineData[0].split(';', 1)[0])
    if (lines[i] === "BEGIN:VEVENT") { //Adds a new row and initialize it to avoid issues when not found
      data[data.length] = ["", "", ""]; //If a FIELD is not present on the file, it makes an error: Better to initialize!
      // Its length should correspond to inputFields. Eg: for 7 read fiels --> = ["","","","","","",""]; 
    }
    else if (lines[i].substr(0, 1) === ' ') {
      //If row starts with just SPACE instead of a FIELD, it must be added to previous line as it belows to a multiple line FIELD
      // https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.11
      data[data.length - 1][previous_col] = data[data.length - 1][previous_col] + lines[i]
    }
    else if (col !== -1 && data.length > 1) { // exists in inputFields, and BEGIN:VEVENT found
      //Browser.msgBox("Dealing with: /////////0:" + lineData[0] + " //////////////1: " +  lineData[1] )
      // Date (YYYY,MM,DD) Thus: lineData[1].substr(0, 4) is YYYY, lineData[1].substr(4, 2)-1 is MM (from 0-11) and the other is DD. And last, lineData[1] is the text DATA to import when it is not a Date (DTStart or DTend)
      data[data.length - 1][col] = (lineData[0].substr(0, 2) === 'DT') ? new Date(lineData[1].substr(0, 4), lineData[1].substr(4, 2) - 1, lineData[1].substr(6, 2)) : lineData[1]

      for (var j = 2; j < lineData.length; j = j + 1) { //It might happen that someone added ':' to the FIELD and therefore it was split too 
        data[data.length - 1][col] = data[data.length - 1][col] + ":" + lineData[j]
      }
      var previous_col = col //To remember the last FIELD saved
    }
  }
  return data
}

async function download(fileURL) {
  let response = UrlFetchApp.fetch(fileURL, { muteHttpExceptions: true });
  let rc = await response.getResponseCode()
  console.log(rc)
  if (rc == 200) {
    return response.getContentText()
  }

}

async function insertICS(url) {
  let content = await download(url)
  return icsToTable(content)

}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment