appscript.dev
Automation Advanced Calendar

Import external iCal feeds into your calendar

Pull events from other systems into Northwind's calendar — e.g., a vendor's release calendar.

Published Sep 10, 2025

Northwind depends on a handful of vendors whose release dates, maintenance windows, and webinars live in their own iCal feeds. Google Calendar does support subscribing to a URL, but the refresh is slow and the events end up on a separate overlay that nobody enables. The releases get missed anyway.

This script pulls an iCal feed, parses out the events, and creates real events on a dedicated Northwind calendar — first-class, searchable, and visible to everyone subscribed. It also de-duplicates on every run so you can schedule it without piling up copies.

What you’ll need

  • A dedicated calendar to import into (so you can show/hide the whole feed at once). Grab its ID from Calendar settings and paste it into TARGET_CAL.
  • The HTTPS URL of the iCal feed. Most vendors publish one under Settings → Integrations → Calendar.
  • Nothing else — the parser handles standard BEGIN:VEVENT blocks with SUMMARY, DTSTART, and DTEND lines, which covers the vast majority of feeds.

The script

// The calendar to import events into.
const TARGET_CAL = '1abcImportedCalId';

/**
 * Fetches an iCal feed, parses the events, and creates any that are
 * not already on the target calendar. Safe to run repeatedly.
 */
function importIcal(icalUrl) {
  if (!icalUrl) {
    Logger.log('No URL passed to importIcal — nothing to do.');
    return;
  }

  const cal = CalendarApp.getCalendarById(TARGET_CAL);
  if (!cal) {
    Logger.log('Target calendar not found — check TARGET_CAL.');
    return;
  }

  // 1. Pull the feed. muteHttpExceptions so we log a useful message
  //    instead of throwing on a 404 or a flaky vendor.
  const res = UrlFetchApp.fetch(icalUrl, { muteHttpExceptions: true });
  if (res.getResponseCode() !== 200) {
    Logger.log('Fetch failed: ' + res.getResponseCode());
    return;
  }

  const events = parseIcal(res.getContentText());
  Logger.log('Parsed ' + events.length + ' events from feed.');

  // 2. For each event, look for an existing one with the same title in
  //    the same window. If found, skip — otherwise create it.
  let created = 0;
  for (const e of events) {
    const matches = cal.getEvents(e.start, e.end, { search: e.title });
    if (matches.length) continue;
    cal.createEvent(e.title, e.start, e.end);
    created++;
  }
  Logger.log('Created ' + created + ' new events.');
}

/**
 * Splits an iCal string on BEGIN:VEVENT and pulls SUMMARY/DTSTART/DTEND
 * out of each block. Good enough for most vendor feeds.
 */
function parseIcal(text) {
  const events = [];
  const blocks = text.split(/BEGIN:VEVENT/).slice(1);
  for (const b of blocks) {
    const title = (b.match(/SUMMARY:(.+)/) || [])[1] || 'Event';
    const start = parseDate((b.match(/DTSTART(?:;[^:]+)?:([^\r\n]+)/) || [])[1]);
    const end = parseDate((b.match(/DTEND(?:;[^:]+)?:([^\r\n]+)/) || [])[1]);
    if (start && end) events.push({ title: title.trim(), start, end });
  }
  return events;
}

/**
 * Parses an iCal DTSTART/DTEND value into a Date. Handles the common
 * `YYYYMMDDTHHMMSS` UTC form; ignores anything more exotic.
 */
function parseDate(s) {
  if (!s) return null;
  const m = s.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/);
  if (!m) return null;
  return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
}

/**
 * Wrapper to run on a schedule against the Northwind vendor feed.
 * Add one of these per feed you want to mirror.
 */
function importVendorFeed() {
  importIcal('https://vendor.example.com/calendar.ics');
}

How it works

  1. importIcal validates the URL and opens the target calendar, bailing out with a log line on either failure.
  2. It fetches the feed with muteHttpExceptions: true so a transient 5xx from the vendor logs a message instead of crashing.
  3. It hands the raw text to parseIcal, which splits on BEGIN:VEVENT to get one block per event, then runs three small regexes to pull out the summary and the start/end timestamps.
  4. parseDate matches the common YYYYMMDDTHHMMSS format used by most feeds. Anything more exotic (timezones, all-day with VALUE=DATE) is skipped — fine for a vendor mirror, and you find out fast if you need more.
  5. Back in importIcal, it asks the target calendar for any existing event with the same title in the same window. The search option does a text match, which is enough to dedupe a stable feed.
  6. Anything not already there gets created. The log line at the end tells you how much was new on this run.

Example run

Say the vendor’s feed contains a release window:

BEGIN:VEVENT
SUMMARY:Acme Cloud — v4.2 release
DTSTART:20250915T080000Z
DTEND:20250915T100000Z
END:VEVENT

On the first run, the Northwind import calendar gets a new “Acme Cloud — v4.2 release” event for 8am–10am UTC on 15 September 2025. On every later run, the search finds the existing event and skips it — no duplicates, no churn. When the vendor adds a new release, the next scheduled run picks it up.

Trigger it

Run it nightly so new feed entries show up by the morning:

  1. In the Apps Script editor, open Triggers (clock icon).
  2. Add a trigger for importVendorFeed, event source Time-driven, type Day timer, time 2am to 3am.
  3. Approve the authorisation prompt the first time.

Add one wrapper function per feed you want to mirror, and one trigger per wrapper.

Watch out for

  • The parser is deliberately small. It handles SUMMARY, DTSTART, and DTEND with UTC timestamps — about 90% of feeds in the wild. If yours has VTIMEZONE, RRULE, or all-day VALUE=DATE entries you need, swap in a dedicated iCal library instead.
  • The dedup is by title within a time window. If the vendor renames an event (“v4.2 release” → “v4.2 release (delayed)”) the next run will create a second event. For a strict match, store the iCal UID in the event’s description and search on that.
  • Events created here are not synced. If the vendor moves a release a day later, your calendar still shows the old time. The honest fix is to wipe and re-import; the cheap fix is to delete by hand.
  • A flaky vendor feed should not crash the run. The muteHttpExceptions flag plus the response-code check above keep the script polite about transient failures.

Related