AutomationFlowsWeb Scraping › Sync Ics Calendar Feeds with Google Calendar

Sync Ics Calendar Feeds with Google Calendar

ByJay Tee @jay-tee on n8n.io

This workflow runs every 15 minutes (configurable) to mirror events from an ICS/webcal feed into a Google Calendar by fetching and parsing the ICS file, comparing it with existing calendar events, and creating missing events or optionally deleting previously mirrored events that…

Cron / scheduled trigger★★★★☆ complexity20 nodesHTTP RequestGoogle Calendar
Web Scraping Trigger: Cron / scheduled Nodes: 20 Complexity: ★★★★☆ Added:
Sync Ics Calendar Feeds with Google Calendar — n8n workflow card showing HTTP Request, Google Calendar integration

This workflow corresponds to n8n.io template #18074 — we link there as the canonical source.

This workflow follows the Google Calendar → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "lGnE5jZ4h5bEjuZz",
  "meta": {
    "builderVariant": "mcp",
    "aiBuilderAssisted": true
  },
  "name": "ICS to Google Calendar Sync Template",
  "tags": [],
  "nodes": [
    {
      "id": "ce321625-e6c8-43f3-b71f-b504b70676ef",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1312,
        0
      ],
      "parameters": {
        "width": 480,
        "height": 896,
        "content": "## ICS to Google Calendar Sync Template\n\n### How it works\n\nThis workflow periodically synchronizes an external ICS calendar feed into Google Calendar. It loads configuration values, fetches and parses the ICS feed, retrieves existing Google Calendar events, compares both datasets, and then creates missing events or optionally deletes stale ones. Events that already match are routed to a no-op branch.\n\n### Setup steps\n\n- Configure the Schedule Trigger interval if the default 15-minute sync cadence is not appropriate.\n- Update the Config node with the ICS feed URL, target Google Calendar ID, deletion setting, and sync lookback window.\n- Connect valid Google Calendar credentials to the Google Calendar nodes and ensure the account has permission to read, create, and delete events on the target calendar.\n- Verify the ICS URL is reachable from n8n; webcal:// URLs are converted to https:// by the workflow before fetching.\n\n### Customization\n\nAdjust enableDeletions to control whether stale destination events are removed, change syncDaysPast to widen or narrow the comparison window, and modify the diff code if event matching should use different fields such as UID, title, time, or location."
      },
      "typeVersion": 1
    },
    {
      "id": "2f6753fe-866d-4cee-a57a-193bf1d73c60",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -752,
        432
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 336,
        "content": "## Schedule and configuration\n\nStarts the sync every 15 minutes and defines the core settings used by both branches, including the ICS feed URL, target Google Calendar ID, deletion behavior, and lookback window."
      },
      "typeVersion": 1
    },
    {
      "id": "1600e6c1-1c6a-4be0-bbfb-7ff39e1f29dc",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -192,
        480
      ],
      "parameters": {
        "color": 7,
        "width": 768,
        "height": 464,
        "content": "## Collect calendar sources\n\nFetches the external ICS feed, parses it into event data, retrieves existing Google Calendar events, and merges the two branches once both source datasets are available."
      },
      "typeVersion": 1
    },
    {
      "id": "f43bdcb7-4496-4bcb-81f2-5e6704c410f3",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        624,
        432
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 368,
        "content": "## Compare and route\n\nCalculates the difference between parsed ICS events and existing Google Calendar events, then routes each item to the appropriate sync action: create, delete, or leave unchanged."
      },
      "typeVersion": 1
    },
    {
      "id": "359df06a-4ec2-4f60-9a9d-38adf0745259",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1328,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 368,
        "content": "## Create missing events\n\nCreates Google Calendar events for ICS entries that are present in the feed but missing from the destination calendar."
      },
      "typeVersion": 1
    },
    {
      "id": "f3280c8e-338b-4924-a9c1-baba2593f5da",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1328,
        400
      ],
      "parameters": {
        "color": 7,
        "width": 448,
        "height": 496,
        "content": "## Handle optional deletions\n\nChecks whether deletions are enabled and either removes stale Google Calendar events or skips deletion when the configuration disables it."
      },
      "typeVersion": 1
    },
    {
      "id": "f8228bd4-3bc8-4790-9c6c-ac2e9adaa8a9",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1328,
        928
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 368,
        "content": "## Skip unchanged events\n\nTerminates the branch for events that already match between the ICS feed and Google Calendar, requiring no calendar update."
      },
      "typeVersion": 1
    },
    {
      "id": "be057d2d-14f2-4a0b-9f3f-5a236fbd49c8",
      "name": "When Every 15 Minutes Elapsed",
      "type": "n8n-nodes-base.scheduleTrigger",
      "notes": "Change the sync interval here.",
      "position": [
        -704,
        608
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "d6a3ec43-a0b1-49ce-b787-d50160013a84",
      "name": "Set Configuration Parameters",
      "type": "n8n-nodes-base.set",
      "notes": "Every setting for the sync lives here.",
      "position": [
        -464,
        608
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "cfg-ics-url",
              "name": "icsUrl",
              "type": "string",
              "value": ""
            },
            {
              "id": "cfg-calendar-id",
              "name": "calendarId",
              "type": "string",
              "value": ""
            },
            {
              "id": "cfg-enable-deletions",
              "name": "enableDeletions",
              "type": "boolean",
              "value": true
            },
            {
              "id": "cfg-days-past",
              "name": "syncDaysPast",
              "type": "number",
              "value": 7
            },
            {
              "id": "cfg-days-future",
              "name": "syncDaysFuture",
              "type": "number",
              "value": 90
            },
            {
              "id": "cfg-default-tz",
              "name": "defaultTimeZone",
              "type": "string",
              "value": "UTC"
            },
            {
              "id": "cfg-prefix",
              "name": "eventIdPrefix",
              "type": "string",
              "value": "icsmirror"
            },
            {
              "id": "cfg-match-on",
              "name": "matchOn",
              "type": "string",
              "value": "identity"
            },
            {
              "id": "cfg-max-occurrences",
              "name": "maxOccurrencesPerEvent",
              "type": "number",
              "value": 500
            },
            {
              "id": "cfg-max-deletions",
              "name": "maxDeletionsPerRun",
              "type": "number",
              "value": 100
            }
          ]
        }
      },
      "typeVersion": 3.5
    },
    {
      "id": "a0f23587-f310-42ef-af0d-60991410ca27",
      "name": "Fetch ICS Data",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Fails the run on error, so a bad feed can never trigger deletions.",
      "position": [
        -144,
        608
      ],
      "parameters": {
        "url": "={{ $json.icsUrl.replace('webcal://', 'https://') }}",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "responseFormat": "text",
              "outputPropertyName": "icsData"
            }
          }
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "5f8850ba-ef29-44d2-9179-59bf8229275a",
      "name": "Parse ICS File Data",
      "type": "n8n-nodes-base.code",
      "position": [
        112,
        608
      ],
      "parameters": {
        "jsCode": "/**\n * n8n Code node: \"Parse ICS\"\n * Mode: Run Once for All Items\n *\n * Input : one item from the HTTP Request node containing the raw .ics body\n *         in `icsData`.\n * Output: one item per *occurrence* that falls inside the sync window, with a\n *         deterministic Google Calendar event id so the diff step is a plain\n *         set comparison.\n *\n * No external libraries: the n8n Code sandbox does not allow `require` by\n * default, so RFC 5545 parsing, timezone maths and hashing are all done here.\n */\n\n// ---------------------------------------------------------------- config ---\n\nconst cfg = $('Set Configuration Parameters').first().json;\n\nconst prefix =\n  String(cfg.eventIdPrefix || 'ics')\n    .toLowerCase()\n    .replace(/[^0-9a-v]/g, '') || 'ics';\n\nconst defaultTz = String(cfg.defaultTimeZone || 'UTC');\nconst matchOn = cfg.matchOn === 'content' ? 'content' : 'identity';\nconst maxOccurrencesPerEvent = Number(cfg.maxOccurrencesPerEvent) || 500;\n\nconst now = Date.now();\nconst windowStart = now - Number(cfg.syncDaysPast || 0) * 86400000;\nconst windowEnd = now + Number(cfg.syncDaysFuture || 0) * 86400000;\n\n// ------------------------------------------------------------ ics source ---\n\nconst input = $input.first().json;\nconst icsText = String(input.icsData ?? input.data ?? input.body ?? '');\n\n// Guard rail: if the feed did not come back as a calendar we must NOT continue,\n// otherwise the diff step would see \"zero ICS events\" and delete the mirror.\nif (!/BEGIN:VCALENDAR/i.test(icsText)) {\n  throw new Error(\n    'The fetched document is not an ICS calendar (no BEGIN:VCALENDAR found). ' +\n      'Aborting before the diff step so that nothing gets deleted from Google Calendar. ' +\n      'First 200 chars: ' +\n      icsText.slice(0, 200),\n  );\n}\n\n// ------------------------------------------------------------- timezones ---\n\nconst WINDOWS_TZ = {\n  utc: 'UTC',\n  'gmt standard time': 'Europe/London',\n  'greenwich standard time': 'Atlantic/Reykjavik',\n  'w. europe standard time': 'Europe/Berlin',\n  'central europe standard time': 'Europe/Budapest',\n  'central european standard time': 'Europe/Warsaw',\n  'romance standard time': 'Europe/Paris',\n  'e. europe standard time': 'Europe/Chisinau',\n  'fle standard time': 'Europe/Kiev',\n  'eastern standard time': 'America/New_York',\n  'central standard time': 'America/Chicago',\n  'mountain standard time': 'America/Denver',\n  'pacific standard time': 'America/Los_Angeles',\n  'us eastern standard time': 'America/Indiana/Indianapolis',\n  'india standard time': 'Asia/Kolkata',\n  'singapore standard time': 'Asia/Singapore',\n  'china standard time': 'Asia/Shanghai',\n  'tokyo standard time': 'Asia/Tokyo',\n  'korea standard time': 'Asia/Seoul',\n  'aus eastern standard time': 'Australia/Sydney',\n  'e. australia standard time': 'Australia/Brisbane',\n  'cen. australia standard time': 'Australia/Adelaide',\n  'w. australia standard time': 'Australia/Perth',\n  'new zealand standard time': 'Pacific/Auckland',\n  'south africa standard time': 'Africa/Johannesburg',\n  'arabian standard time': 'Asia/Dubai',\n};\n\nconst zoneValidity = new Map();\nfunction isValidZone(tz) {\n  if (zoneValidity.has(tz)) return zoneValidity.get(tz);\n  let ok = true;\n  try {\n    new Intl.DateTimeFormat('en-US', { timeZone: tz });\n  } catch (e) {\n    ok = false;\n  }\n  zoneValidity.set(tz, ok);\n  return ok;\n}\n\n/** Map a TZID from the feed onto an IANA zone, falling back to the config default. */\nfunction resolveTz(tzid) {\n  if (!tzid) return defaultTz;\n  const z = String(tzid).trim().replace(/^\"|\"$/g, '');\n  if (isValidZone(z)) return z;\n  const win = WINDOWS_TZ[z.toLowerCase()];\n  if (win) return win;\n  return defaultTz;\n}\n\nconst formatterCache = new Map();\n/** Offset (ms) of `tz` at the instant `ms`, i.e. localTime - utcTime. */\nfunction zoneOffset(ms, tz) {\n  let dtf = formatterCache.get(tz);\n  if (dtf === undefined) {\n    try {\n      dtf = new Intl.DateTimeFormat('en-US', {\n        timeZone: tz,\n        hour12: false,\n        year: 'numeric',\n        month: '2-digit',\n        day: '2-digit',\n        hour: '2-digit',\n        minute: '2-digit',\n        second: '2-digit',\n      });\n    } catch (e) {\n      dtf = null;\n    }\n    formatterCache.set(tz, dtf);\n  }\n  if (!dtf) return 0;\n  const parts = {};\n  for (const p of dtf.formatToParts(new Date(ms))) parts[p.type] = p.value;\n  const asUtc = Date.UTC(\n    Number(parts.year),\n    Number(parts.month) - 1,\n    Number(parts.day),\n    Number(parts.hour) % 24,\n    Number(parts.minute),\n    Number(parts.second),\n  );\n  return asUtc - ms;\n}\n\n/**\n * Convert a \"wall clock\" value (encoded as if it were UTC) in zone `tz` into a\n * real UTC timestamp. Two passes so DST boundaries resolve correctly.\n */\nfunction wallToUtc(wallMs, tz) {\n  if (!tz || tz === 'UTC') return wallMs;\n  const firstGuess = wallMs - zoneOffset(wallMs, tz);\n  return wallMs - zoneOffset(firstGuess, tz);\n}\n\n// --------------------------------------------------------- ics tokenising ---\n\n/** RFC 5545 line unfolding + normalised newlines. */\nfunction unfold(text) {\n  return text\n    .replace(/\\r\\n/g, '\\n')\n    .replace(/\\r/g, '\\n')\n    .replace(/\\n[ \\t]/g, '');\n}\n\nfunction indexOfUnquoted(str, char) {\n  let quoted = false;\n  for (let i = 0; i < str.length; i++) {\n    const c = str[i];\n    if (c === '\"') quoted = !quoted;\n    else if (c === char && !quoted) return i;\n  }\n  return -1;\n}\n\nfunction splitUnquoted(str, char) {\n  const out = [];\n  let quoted = false;\n  let current = '';\n  for (let i = 0; i < str.length; i++) {\n    const c = str[i];\n    if (c === '\"') {\n      quoted = !quoted;\n      current += c;\n    } else if (c === char && !quoted) {\n      out.push(current);\n      current = '';\n    } else {\n      current += c;\n    }\n  }\n  out.push(current);\n  return out;\n}\n\nfunction unescapeText(value) {\n  return String(value)\n    .replace(/\\\\n/gi, '\\n')\n    .replace(/\\\\,/g, ',')\n    .replace(/\\\\;/g, ';')\n    .replace(/\\\\\\\\/g, '\\\\');\n}\n\nfunction parseLine(line) {\n  const colon = indexOfUnquoted(line, ':');\n  if (colon === -1) return null;\n  const left = line.slice(0, colon);\n  const value = line.slice(colon + 1);\n  const segments = splitUnquoted(left, ';');\n  const name = String(segments.shift() || '').toUpperCase();\n  const params = {};\n  for (const segment of segments) {\n    const eq = segment.indexOf('=');\n    if (eq === -1) continue;\n    params[segment.slice(0, eq).toUpperCase()] = segment\n      .slice(eq + 1)\n      .replace(/^\"|\"$/g, '');\n  }\n  return { name, params, value };\n}\n\n/** Split the calendar into VEVENT blocks, ignoring VTIMEZONE / VALARM noise. */\nfunction extractVevents(text) {\n  const lines = unfold(text).split('\\n');\n  const events = [];\n  let current = null;\n  let skipDepth = 0;\n\n  for (const rawLine of lines) {\n    const line = rawLine.trim();\n    if (!line) continue;\n\n    const upper = line.toUpperCase();\n\n    if (upper === 'BEGIN:VEVENT') {\n      current = [];\n      continue;\n    }\n    if (upper === 'END:VEVENT') {\n      if (current) events.push(current);\n      current = null;\n      skipDepth = 0;\n      continue;\n    }\n    if (!current) continue;\n\n    // Nested components inside a VEVENT (VALARM) are not needed.\n    if (upper.startsWith('BEGIN:')) {\n      skipDepth += 1;\n      continue;\n    }\n    if (upper.startsWith('END:')) {\n      if (skipDepth > 0) skipDepth -= 1;\n      continue;\n    }\n    if (skipDepth > 0) continue;\n\n    const parsed = parseLine(line);\n    if (parsed) current.push(parsed);\n  }\n  return events;\n}\n\n// ------------------------------------------------------- value converters ---\n\n/**\n * Parse a DATE / DATE-TIME value.\n * Returns { allDay, wall, tz, utc } where `wall` is the local clock time\n * encoded as a UTC timestamp (used for calendar-safe recurrence maths).\n */\nfunction parseDt(value, params) {\n  const raw = String(value).trim();\n  const dateOnly = /^(\\d{4})(\\d{2})(\\d{2})$/.exec(raw);\n\n  if (dateOnly || (params && params.VALUE === 'DATE')) {\n    const m = dateOnly || /^(\\d{4})(\\d{2})(\\d{2})/.exec(raw);\n    if (!m) return null;\n    const wall = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));\n    return { allDay: true, wall, tz: 'UTC', utc: wall };\n  }\n\n  const m = /^(\\d{4})(\\d{2})(\\d{2})T(\\d{2})(\\d{2})(\\d{2})(Z)?$/.exec(raw);\n  if (!m) return null;\n\n  const wall = Date.UTC(\n    Number(m[1]),\n    Number(m[2]) - 1,\n    Number(m[3]),\n    Number(m[4]),\n    Number(m[5]),\n    Number(m[6]),\n  );\n\n  if (m[7]) return { allDay: false, wall, tz: 'UTC', utc: wall };\n\n  const tz = resolveTz(params && params.TZID);\n  return { allDay: false, wall, tz, utc: wallToUtc(wall, tz) };\n}\n\nfunction parseDuration(value) {\n  const m = /^([+-])?P(?:(\\d+)W)?(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?$/.exec(\n    String(value).trim(),\n  );\n  if (!m) return null;\n  const sign = m[1] === '-' ? -1 : 1;\n  const seconds =\n    (Number(m[2]) || 0) * 604800 +\n    (Number(m[3]) || 0) * 86400 +\n    (Number(m[4]) || 0) * 3600 +\n    (Number(m[5]) || 0) * 60 +\n    (Number(m[6]) || 0);\n  return sign * seconds * 1000;\n}\n\n// --------------------------------------------------------------- hashing ---\n\nconst B32HEX = '0123456789abcdefghijklmnopqrstuv';\n\nfunction encode32(n) {\n  let value = n >>> 0;\n  let out = '';\n  for (let i = 0; i < 7; i++) {\n    out = B32HEX[value & 31] + out;\n    value = Math.floor(value / 32);\n  }\n  return out;\n}\n\n/**\n * 128-bit FNV-1a style digest rendered in base32hex (0-9, a-v) \u2014 exactly the\n * alphabet Google Calendar accepts for a caller-supplied event id.\n */\nfunction hash128(str) {\n  const seeds = [0x811c9dc5, 0x01000193, 0x9e3779b9, 0x85ebca6b];\n  return seeds\n    .map((seed) => {\n      let h = seed >>> 0;\n      for (let i = 0; i < str.length; i++) {\n        const code = str.charCodeAt(i);\n        h = Math.imul(h ^ (code & 0xff), 16777619) >>> 0;\n        h = Math.imul(h ^ ((code >> 8) & 0xff), 16777619) >>> 0;\n      }\n      return encode32(h);\n    })\n    .join('');\n}\n\n// ------------------------------------------------------ recurrence rules ---\n\nconst WEEKDAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];\n\nfunction parseRrule(value) {\n  const rule = {};\n  for (const part of String(value).split(';')) {\n    const eq = part.indexOf('=');\n    if (eq === -1) continue;\n    rule[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1).toUpperCase();\n  }\n  return rule;\n}\n\nfunction addDaysWall(wall, days) {\n  return wall + days * 86400000;\n}\n\nfunction addMonthsWall(wall, months) {\n  const d = new Date(wall);\n  const target = new Date(\n    Date.UTC(\n      d.getUTCFullYear(),\n      d.getUTCMonth() + months,\n      1,\n      d.getUTCHours(),\n      d.getUTCMinutes(),\n      d.getUTCSeconds(),\n    ),\n  );\n  const daysInMonth = new Date(\n    Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0),\n  ).getUTCDate();\n  target.setUTCDate(Math.min(d.getUTCDate(), daysInMonth));\n  return target.getTime();\n}\n\n/** Wall time for the nth (1-based, negative = from the end) weekday of a month. */\nfunction nthWeekdayOfMonth(year, month, weekday, ordinal, hours, minutes, seconds) {\n  const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();\n  const matches = [];\n  for (let day = 1; day <= daysInMonth; day++) {\n    const d = new Date(Date.UTC(year, month, day));\n    if (d.getUTCDay() === weekday) matches.push(day);\n  }\n  const day = ordinal > 0 ? matches[ordinal - 1] : matches[matches.length + ordinal];\n  if (!day) return null;\n  return Date.UTC(year, month, day, hours, minutes, seconds);\n}\n\nfunction parseByDay(byDay) {\n  return String(byDay)\n    .split(',')\n    .map((token) => {\n      const m = /^([+-]?\\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(token.trim());\n      if (!m) return null;\n      return {\n        ordinal: m[1] ? Number(m[1]) : null,\n        weekday: WEEKDAYS.indexOf(m[2]),\n      };\n    })\n    .filter(Boolean);\n}\n\n/**\n * Expand an RRULE into wall-clock start times. Everything is done on the wall\n * clock so that a 09:00 meeting stays at 09:00 across a DST change; the caller\n * converts back to UTC afterwards.\n */\nfunction expandRule(rule, startWall, limitWallEnd) {\n  const freq = rule.FREQ;\n  const interval = Math.max(1, Number(rule.INTERVAL) || 1);\n  const count = rule.COUNT ? Number(rule.COUNT) : null;\n\n  let untilUtc = null;\n  if (rule.UNTIL) {\n    const parsed = parseDt(rule.UNTIL, {});\n    if (parsed) untilUtc = parsed.allDay ? parsed.utc + 86400000 - 1 : parsed.utc;\n  }\n\n  const base = new Date(startWall);\n  const hours = base.getUTCHours();\n  const minutes = base.getUTCMinutes();\n  const seconds = base.getUTCSeconds();\n\n  const byDay = rule.BYDAY ? parseByDay(rule.BYDAY) : [];\n  const byMonthDay = rule.BYMONTHDAY\n    ? String(rule.BYMONTHDAY).split(',').map(Number).filter((n) => !Number.isNaN(n))\n    : [];\n  const byMonth = rule.BYMONTH\n    ? String(rule.BYMONTH).split(',').map(Number).filter((n) => !Number.isNaN(n))\n    : [];\n\n  const results = [];\n  const seen = new Set();\n\n  const push = (wall) => {\n    if (wall === null || wall === undefined) return true;\n    if (wall < startWall) return true;\n    if (untilUtc !== null && wall > untilUtc + 86400000) return false;\n    if (seen.has(wall)) return true;\n    seen.add(wall);\n    results.push(wall);\n    return true;\n  };\n\n  const maxBlocks = 2000;\n\n  if (!freq || freq === 'SECONDLY' || freq === 'MINUTELY' || freq === 'HOURLY') {\n    // Unsupported / pathological frequencies: emit only the master occurrence.\n    return [startWall];\n  }\n\n  if (freq === 'DAILY') {\n    for (let block = 0; block < maxBlocks; block++) {\n      const wall = addDaysWall(startWall, block * interval);\n      if (wall > limitWallEnd) break;\n      push(wall);\n      if (count && results.length >= count) break;\n    }\n  } else if (freq === 'WEEKLY') {\n    const targets = byDay.length ? byDay.map((d) => d.weekday) : [base.getUTCDay()];\n    const wkst = WEEKDAYS.indexOf(rule.WKST || 'MO');\n    const weekStartOffset = (base.getUTCDay() - (wkst === -1 ? 1 : wkst) + 7) % 7;\n    const weekStart = addDaysWall(startWall, -weekStartOffset);\n\n    for (let block = 0; block < maxBlocks; block++) {\n      const blockStart = addDaysWall(weekStart, block * interval * 7);\n      if (blockStart > limitWallEnd) break;\n      for (const weekday of targets.slice().sort((a, b) => a - b)) {\n        const offset = (weekday - new Date(blockStart).getUTCDay() + 7) % 7;\n        push(addDaysWall(blockStart, offset));\n        if (count && results.length >= count) break;\n      }\n      if (count && results.length >= count) break;\n    }\n  } else if (freq === 'MONTHLY') {\n    for (let block = 0; block < maxBlocks; block++) {\n      const blockStart = addMonthsWall(startWall, block * interval);\n      if (blockStart > limitWallEnd) break;\n      const d = new Date(blockStart);\n      const year = d.getUTCFullYear();\n      const month = d.getUTCMonth();\n\n      if (byDay.length) {\n        for (const entry of byDay) {\n          const ordinals = entry.ordinal ? [entry.ordinal] : [1, 2, 3, 4, 5];\n          for (const ordinal of ordinals) {\n            push(\n              nthWeekdayOfMonth(year, month, entry.weekday, ordinal, hours, minutes, seconds),\n            );\n          }\n        }\n      } else if (byMonthDay.length) {\n        const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();\n        for (const rawDay of byMonthDay) {\n          const day = rawDay > 0 ? rawDay : daysInMonth + rawDay + 1;\n          if (day < 1 || day > daysInMonth) continue;\n          push(Date.UTC(year, month, day, hours, minutes, seconds));\n        }\n      } else {\n        push(blockStart);\n      }\n      if (count && results.length >= count) break;\n    }\n  } else if (freq === 'YEARLY') {\n    const months = byMonth.length ? byMonth.map((m) => m - 1) : [base.getUTCMonth()];\n    for (let block = 0; block < maxBlocks; block++) {\n      const year = base.getUTCFullYear() + block * interval;\n      let emitted = false;\n      for (const month of months) {\n        if (byDay.length) {\n          for (const entry of byDay) {\n            const ordinals = entry.ordinal ? [entry.ordinal] : [1, 2, 3, 4, 5];\n            for (const ordinal of ordinals) {\n              push(\n                nthWeekdayOfMonth(year, month, entry.weekday, ordinal, hours, minutes, seconds),\n              );\n            }\n          }\n        } else {\n          const days = byMonthDay.length ? byMonthDay : [base.getUTCDate()];\n          const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();\n          for (const rawDay of days) {\n            const day = rawDay > 0 ? rawDay : daysInMonth + rawDay + 1;\n            if (day < 1 || day > daysInMonth) continue;\n            push(Date.UTC(year, month, day, hours, minutes, seconds));\n          }\n        }\n        emitted = true;\n      }\n      if (!emitted) break;\n      if (Date.UTC(year, 0, 1) > limitWallEnd) break;\n      if (count && results.length >= count) break;\n    }\n  }\n\n  results.sort((a, b) => a - b);\n  const trimmed = count ? results.slice(0, count) : results;\n  return trimmed.slice(0, maxOccurrencesPerEvent);\n}\n\n// ------------------------------------------------------- build the events ---\n\nfunction firstValue(properties, name) {\n  const hit = properties.find((p) => p.name === name);\n  return hit ? hit : null;\n}\n\nconst blocks = extractVevents(icsText);\n\nconst masters = [];\nconst overrides = [];\n\nfor (const properties of blocks) {\n  const uidProp = firstValue(properties, 'UID');\n  const dtStartProp = firstValue(properties, 'DTSTART');\n  if (!uidProp || !dtStartProp) continue;\n\n  const start = parseDt(dtStartProp.value, dtStartProp.params);\n  if (!start) continue;\n\n  const dtEndProp = firstValue(properties, 'DTEND');\n  const durationProp = firstValue(properties, 'DURATION');\n\n  let end = dtEndProp ? parseDt(dtEndProp.value, dtEndProp.params) : null;\n  let durationMs;\n  if (end) {\n    durationMs = end.wall - start.wall;\n  } else if (durationProp) {\n    durationMs = parseDuration(durationProp.value) ?? 0;\n  } else {\n    durationMs = start.allDay ? 86400000 : 3600000;\n  }\n  if (durationMs <= 0) durationMs = start.allDay ? 86400000 : 3600000;\n\n  const statusProp = firstValue(properties, 'STATUS');\n  const recurrenceIdProp = firstValue(properties, 'RECURRENCE-ID');\n  const rruleProp = firstValue(properties, 'RRULE');\n  const summaryProp = firstValue(properties, 'SUMMARY');\n  const descriptionProp = firstValue(properties, 'DESCRIPTION');\n  const locationProp = firstValue(properties, 'LOCATION');\n\n  const exdates = [];\n  for (const property of properties) {\n    if (property.name !== 'EXDATE') continue;\n    for (const piece of String(property.value).split(',')) {\n      const parsed = parseDt(piece, property.params);\n      if (parsed) exdates.push(parsed.wall);\n    }\n  }\n\n  const record = {\n    uid: uidProp.value,\n    start,\n    durationMs,\n    cancelled: statusProp ? String(statusProp.value).toUpperCase() === 'CANCELLED' : false,\n    summary: summaryProp ? unescapeText(summaryProp.value) : '(No title)',\n    description: descriptionProp ? unescapeText(descriptionProp.value) : '',\n    location: locationProp ? unescapeText(locationProp.value) : '',\n    rrule: rruleProp ? parseRrule(rruleProp.value) : null,\n    exdates,\n    recurrenceId: recurrenceIdProp\n      ? parseDt(recurrenceIdProp.value, recurrenceIdProp.params)\n      : null,\n  };\n\n  if (record.recurrenceId) overrides.push(record);\n  else masters.push(record);\n}\n\n// Overrides keyed by \"uid|original wall start\" so the master can skip that slot.\nconst overrideKeys = new Set(\n  overrides.map((o) => `${o.uid}|${o.recurrenceId.wall}`),\n);\n\n// --------------------------------------------------------- occurrence set ---\n\nconst occurrences = new Map();\n\nfunction addOccurrence(record, startWall) {\n  if (record.cancelled) return;\n\n  const tz = record.start.tz;\n  const allDay = record.start.allDay;\n  const startUtc = allDay ? startWall : wallToUtc(startWall, tz);\n  const endWall = startWall + record.durationMs;\n  const endUtc = allDay ? endWall : wallToUtc(endWall, tz);\n\n  // Google's list call returns anything overlapping the window, so match that.\n  if (endUtc <= windowStart || startUtc >= windowEnd) return;\n\n  const startIso = new Date(startUtc).toISOString();\n  const endIso = new Date(endUtc).toISOString();\n\n  const basis =\n    matchOn === 'content'\n      ? [record.uid, startIso, endIso, record.summary, record.location, record.description].join(\n          ' ',\n        )\n      : [record.uid, startIso].join(' ');\n\n  const googleEventId = prefix + hash128(basis);\n\n  const pad = (n) => String(n).padStart(2, '0');\n  const startDate = new Date(startWall);\n  const endDate = new Date(endWall);\n\n  // All-day: hand the node a naive local datetime \u2014 it formats it to YYYY-MM-DD\n  // keeping the wall clock, so noon is immune to any offset rounding.\n  const gcalStart = allDay\n    ? `${startDate.getUTCFullYear()}-${pad(startDate.getUTCMonth() + 1)}-${pad(\n        startDate.getUTCDate(),\n      )}T12:00:00`\n    : startIso;\n  const gcalEnd = allDay\n    ? `${endDate.getUTCFullYear()}-${pad(endDate.getUTCMonth() + 1)}-${pad(\n        endDate.getUTCDate(),\n      )}T12:00:00`\n    : endIso;\n\n  occurrences.set(googleEventId, {\n    googleEventId,\n    uid: record.uid,\n    summary: record.summary,\n    description: record.description,\n    location: record.location,\n    allDay,\n    timeZone: allDay ? null : tz,\n    start: startIso,\n    end: endIso,\n    gcalStart,\n    gcalEnd,\n    isOverride: Boolean(record.recurrenceId),\n  });\n}\n\nfor (const record of masters) {\n  if (!record.rrule) {\n    addOccurrence(record, record.start.wall);\n    continue;\n  }\n\n  // Expand a little past the window so long events starting earlier are caught.\n  const limitWallEnd = windowEnd + 366 * 86400000;\n  const starts = expandRule(record.rrule, record.start.wall, limitWallEnd);\n  const excluded = new Set(record.exdates);\n\n  for (const startWall of starts) {\n    if (excluded.has(startWall)) continue;\n    if (overrideKeys.has(`${record.uid}|${startWall}`)) continue;\n    addOccurrence(record, startWall);\n  }\n}\n\nfor (const record of overrides) {\n  addOccurrence(record, record.start.wall);\n}\n\nconst output = Array.from(occurrences.values()).sort((a, b) =>\n  a.start < b.start ? -1 : a.start > b.start ? 1 : 0,\n);\n\nconsole.log(\n  `Parse ICS: ${blocks.length} VEVENT block(s) -> ${output.length} occurrence(s) ` +\n    `between ${new Date(windowStart).toISOString()} and ${new Date(windowEnd).toISOString()}`,\n);\n\nreturn output.map((json) => ({ json }));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "6419bb2f-6a5d-47ec-bd41-970b25bf0ac2",
      "name": "Merge ICS and Google Data",
      "type": "n8n-nodes-base.merge",
      "notes": "Synchronises the two branches; the diff reads each one by name.",
      "position": [
        432,
        624
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "964885e6-0e9c-49ae-b6e6-7c59740ff249",
      "name": "Fetch Google Calendar Events",
      "type": "n8n-nodes-base.googleCalendar",
      "position": [
        -144,
        768
      ],
      "parameters": {
        "options": {},
        "timeMax": "={{ $now.plus({ days: $json.syncDaysFuture }).toISO() }}",
        "timeMin": "={{ $now.minus({ days: $json.syncDaysPast }).toISO() }}",
        "calendar": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.calendarId }}"
        },
        "operation": "getAll",
        "returnAll": true
      },
      "credentials": {},
      "typeVersion": 1.3
    },
    {
      "id": "4094916d-9060-4708-9969-f22c1fddb4d5",
      "name": "Compare ICS and Google Events",
      "type": "n8n-nodes-base.code",
      "position": [
        672,
        624
      ],
      "parameters": {
        "jsCode": "/**\n * n8n Code node: \"Diff ICS vs Google Calendar\"\n * Mode: Run Once for All Items\n *\n * Pulls both branches directly (the Merge node upstream only exists to make\n * sure both have finished) and classifies every event into one of three cases:\n *\n *   CASE-A  create  in ICS, not in Google Calendar   -> create it\n *   CASE-B  delete  in Google Calendar, not in ICS   -> delete it (if enabled)\n *   CASE-C  keep    in both                          -> leave it alone\n *\n * Only events whose Google id carries our managed prefix are ever considered\n * for deletion, so anything a human added to the calendar is untouchable.\n */\n\nconst cfg = $('Set Configuration Parameters').first().json;\n\nconst prefix =\n  String(cfg.eventIdPrefix || 'ics')\n    .toLowerCase()\n    .replace(/[^0-9a-v]/g, '') || 'ics';\n\nconst deletionsEnabled = cfg.enableDeletions === true || cfg.enableDeletions === 'true';\nconst maxDeletionsPerRun = Number(cfg.maxDeletionsPerRun) || 0; // 0 = no limit\n\nfunction itemsFrom(nodeName) {\n  try {\n    return $(nodeName)\n      .all()\n      .map((item) => item.json)\n      .filter((json) => json && typeof json === 'object');\n  } catch (error) {\n    return [];\n  }\n}\n\n// ------------------------------------------------------------- both sides ---\n\nconst icsEvents = itemsFrom('Parse ICS').filter((event) => event.googleEventId);\n\nconst googleEvents = itemsFrom('Get Google Calendar Events').filter(\n  (event) =>\n    event.id &&\n    event.status !== 'cancelled' &&\n    String(event.id).startsWith(prefix),\n);\n\nconst icsById = new Map(icsEvents.map((event) => [event.googleEventId, event]));\nconst googleById = new Map(googleEvents.map((event) => [String(event.id), event]));\n\n// ------------------------------------------------------------ classify -----\n\nconst results = [];\n\n// CASE-A \u2014 present in the ICS feed but missing from Google Calendar.\nfor (const event of icsEvents) {\n  if (googleById.has(event.googleEventId)) continue;\n  results.push({\n    ...event,\n    action: 'create',\n    case: 'A',\n    reason: 'Present in ICS feed, missing from Google Calendar',\n    deletionsEnabled,\n  });\n}\n\n// CASE-B \u2014 present in Google Calendar but gone from the ICS feed.\nconst toDelete = [];\nfor (const event of googleEvents) {\n  if (icsById.has(String(event.id))) continue;\n  toDelete.push({\n    googleEventId: String(event.id),\n    summary: event.summary ?? '(No title)',\n    start: event.start?.dateTime ?? event.start?.date ?? null,\n    end: event.end?.dateTime ?? event.end?.date ?? null,\n    htmlLink: event.htmlLink ?? null,\n    action: 'delete',\n    case: 'B',\n    reason: 'Present in Google Calendar, no longer in the ICS feed',\n    deletionsEnabled,\n  });\n}\n\n// Safety net: a truncated or wrong feed should not be able to wipe the mirror.\nif (\n  deletionsEnabled &&\n  maxDeletionsPerRun > 0 &&\n  toDelete.length > maxDeletionsPerRun\n) {\n  throw new Error(\n    `Refusing to delete ${toDelete.length} events in a single run (maxDeletionsPerRun = ` +\n      `${maxDeletionsPerRun}). This usually means the ICS feed returned partial data. ` +\n      'Check the feed, then raise maxDeletionsPerRun in the Config node if the deletions are genuinely wanted.',\n  );\n}\nresults.push(...toDelete);\n\n// CASE-C \u2014 in both places, left exactly as-is.\nfor (const event of icsEvents) {\n  if (!googleById.has(event.googleEventId)) continue;\n  results.push({\n    ...event,\n    action: 'keep',\n    case: 'C',\n    reason: 'Present in both the ICS feed and Google Calendar',\n    deletionsEnabled,\n  });\n}\n\n// ---------------------------------------------------------------- summary ---\n\nconst counts = {\n  ics: icsEvents.length,\n  googleManaged: googleEvents.length,\n  create: results.filter((r) => r.action === 'create').length,\n  delete: results.filter((r) => r.action === 'delete').length,\n  keep: results.filter((r) => r.action === 'keep').length,\n  deletionsEnabled,\n};\n\nconsole.log('Sync plan:', JSON.stringify(counts));\n\nreturn results.map((json) => ({ json: { ...json, counts } }));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "52ac2c57-fc1a-4de7-ac1c-d399348ba998",
      "name": "Route By Sync Action Type",
      "type": "n8n-nodes-base.switch",
      "position": [
        912,
        608
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "CASE-A create",
              "conditions": {
                "options": {
                  "version": 1,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "rule-create",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "create"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "CASE-B delete",
              "conditions": {
                "options": {
                  "version": 1,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "rule-delete",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "delete"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "CASE-C keep",
              "conditions": {
                "options": {
                  "version": 1,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "rule-keep",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "keep"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.4
    },
    {
      "id": "2f086932-cd01-4594-9ed4-f6f0f109a946",
      "name": "Add Event to Google Calendar",
      "type": "n8n-nodes-base.googleCalendar",
      "notes": "One rejected event does not stop the run.",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1376,
        208
      ],
      "parameters": {
        "end": "={{ $json.gcalEnd }}",
        "start": "={{ $json.gcalStart }}",
        "calendar": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Configuration Parameters').first().json.calendarId }}"
        },
        "additionalFields": {
          "id": "={{ $json.googleEventId }}",
          "allday": "={{ $json.allDay ? 'yes' : 'no' }}",
          "summary": "={{ $json.summary }}",
          "location": "={{ $json.location }}",
          "description": "={{ $json.description }}"
        }
      },
      "credentials": {},
      "retryOnFail": true,
      "typeVersion": 1.3,
      "waitBetweenTries": 2000
    },
    {
      "id": "6263a44c-f16e-4ea0-84d0-0caaa5b957f4",
      "name": "Check If Deletions Enabled",
      "type": "n8n-nodes-base.if",
      "notes": "Driven by enableDeletions in the Config node.",
      "position": [
        1376,
        624
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "deletions-enabled",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.deletionsEnabled }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.3
    },
    {
      "id": "fb1e414b-c42c-4ddb-911d-cc45c20748d0",
      "name": "Remove Event from Google Calendar",
      "type": "n8n-nodes-base.googleCalendar",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1632,
        560
      ],
      "parameters": {
        "eventId": "={{ $json.googleEventId }}",
        "options": {},
        "calendar": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Configuration Parameters').first().json.calendarId }}"
        },
        "operation": "delete"
      },
      "credentials": {},
      "retryOnFail": true,
      "typeVersion": 1.3,
      "waitBetweenTries": 2000
    },
    {
      "id": "3eff8694-599c-4710-b0c9-df02045cc9be",
      "name": "Skip Deletion as Disabled",
      "type": "n8n-nodes-base.noOp",
      "position": [
        1632,
        736
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "c2acda5e-020b-4e66-8fbc-b40c9f7c0bb5",
      "name": "No Changes Required for Sync",
      "type": "n8n-nodes-base.noOp",
      "position": [
        1376,
        1136
      ],
      "parameters": {},
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "d90909f4-d2fe-4125-8dc9-1d7404dbfb54",
  "nodeGroups": [],
  "connections": {
    "Fetch ICS Data": {
      "main": [
        [
          {
            "node": "Parse ICS File Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse ICS File Data": {
      "main": [
        [
          {
            "node": "Merge ICS and Google Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge ICS and Google Data": {
      "main": [
        [
          {
            "node": "Compare ICS and Google Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route By Sync Action Type": {
      "main": [
        [
          {
            "node": "Add Event to Google Calendar",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check If Deletions Enabled",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No Changes Required for Sync",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check If Deletions Enabled": {
      "main": [
        [
          {
            "node": "Remove Event from Google Calendar",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip Deletion as Disabled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Google Calendar Events": {
      "main": [
        [
          {
            "node": "Merge ICS and Google Data",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Set Configuration Parameters": {
      "main": [
        [
          {
            "node": "Fetch ICS Data",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Google Calendar Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare ICS and Google Events": {
      "main": [
        [
          {
            "node": "Route By Sync Action Type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Every 15 Minutes Elapsed": {
      "main": [
        [
          {
            "node": "Set Configuration Parameters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow runs every 15 minutes (configurable) to mirror events from an ICS/webcal feed into a Google Calendar by fetching and parsing the ICS file, comparing it with existing calendar events, and creating missing events or optionally deleting previously mirrored events that…

Source: https://n8n.io/workflows/18074/ — original creator credit. Request a take-down →

More Web Scraping workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Web Scraping

[TEMPLATE] Full Class -> Calendar Sync. Uses httpRequest, googleCalendar. Scheduled trigger; 24 nodes.

HTTP Request, Google Calendar
Web Scraping

Teams that track absences in Everhour and want a shared Google Calendar view for quick planning. Ideal for managers, HR/OPS, and teammates who need instant visibility into approved time off. Pulls app

HTTP Request, Google Calendar
Web Scraping

🕌 How it works

Google Calendar, HTTP Request
Web Scraping

This workflow automates the process of finding local events and adding them directly to your Google Calendar. It eliminates the need for manual event tracking by automatically scraping event informati

HTTP Request, Google Calendar
Web Scraping

Import Forex Factory Calendar events into Google Calendar. Delete past Forex Factory Calendar events from Google Calendar. Get reminders for important economic data releases — especially High Impact n

HTTP Request, Google Calendar