AutomationFlowsData & Sheets › Sync Workflow Schedules Between Google Sheets and Google Calendar

Sync Workflow Schedules Between Google Sheets and Google Calendar

ByPaolo Ronco @paoloronco on n8n.io

Reads every workflow on your n8n instance every 30 minutes, extracts their schedule triggers, and keeps a matching recurring event on Google Calendar — one event per workflow, forever in sync.

Cron / scheduled trigger★★★★★ complexity44 nodesGoogle CalendarGoogle Sheetsn8n
Data & Sheets Trigger: Cron / scheduled Nodes: 44 Complexity: ★★★★★ Added:
Sync Workflow Schedules Between Google Sheets and Google Calendar — n8n workflow card showing Google Calendar, Google Sheets, n8n integration

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

This workflow follows the Google Calendar → Google Sheets 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
{
  "name": "Sync n8n Workflow Schedules to Google Calendar",
  "nodes": [
    {
      "id": "9ae22e9d-0b4b-476c-9138-819eda9e0635",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -1728,
        512
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtMinute": 30
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "3dcf261e-1442-470a-b828-8feb7ec668dd",
      "name": "Code: parsing",
      "type": "n8n-nodes-base.code",
      "position": [
        -1152,
        512
      ],
      "parameters": {
        "jsCode": "/**\n * Output: 1 row per trigger \"schedulabile\"\n * - include: scheduleTrigger, cron\n * - exclude: webhook, errorTrigger, manualTrigger (configurabile)\n */\n\nconst INCLUDE_MANUAL = false; // metti true se vuoi includere manualTrigger come \"Evento (manual)\"\n\nconst pad2 = (n) => String(n ?? 0).padStart(2, '0');\nconst DAY_NUM_TO_ICAL = { 1: 'MO', 2: 'TU', 3: 'WE', 4: 'TH', 5: 'FR', 6: 'SA', 7: 'SU' };\nconst DAY_STR_TO_ICAL = {\n  mon: 'MO', monday: 'MO', mo: 'MO',\n  tue: 'TU', tuesday: 'TU', tu: 'TU',\n  wed: 'WE', wednesday: 'WE', we: 'WE',\n  thu: 'TH', thursday: 'TH', th: 'TH',\n  fri: 'FR', friday: 'FR', fr: 'FR',\n  sat: 'SA', saturday: 'SA', sa: 'SA',\n  sun: 'SU', sunday: 'SU', su: 'SU',\n};\nconst icalToIt = { MO: 'lun', TU: 'mar', WE: 'mer', TH: 'gio', FR: 'ven', SA: 'sab', SU: 'dom' };\n\nfunction normalizeByDay(value) {\n  if (value == null) return null;\n  if (Array.isArray(value)) {\n    const mapped = value\n      .map(v => normalizeByDay(v))\n      .flatMap(v => (v ? v.split(',') : []));\n    const uniq = [...new Set(mapped)].filter(Boolean);\n    return uniq.length ? uniq.join(',') : null;\n  }\n  if (typeof value === 'number') return DAY_NUM_TO_ICAL[value] ?? null;\n  if (typeof value === 'string') {\n    const s = value.trim();\n    if (!s) return null;\n    if (/^(MO|TU|WE|TH|FR|SA|SU)(,(MO|TU|WE|TH|FR|SA|SU))*$/i.test(s)) return s.toUpperCase();\n    const lower = s.toLowerCase();\n    if (DAY_STR_TO_ICAL[lower]) return DAY_STR_TO_ICAL[lower];\n    const asNum = Number(lower);\n    if (!Number.isNaN(asNum)) return DAY_NUM_TO_ICAL[asNum] ?? null;\n  }\n  return null;\n}\n\nfunction humanSchedule(s) {\n  if (!s || !s.freq) return '';\n\n  const it = s.interval ?? 1;\n\n  if (s.freq === 'minutely') return it === 1 ? 'Ogni minuto' : `Ogni ${it} minuti`;\n  if (s.freq === 'hourly')   return `Ogni ora :${pad2(s.atMinute ?? 0)}`;\n  if (s.freq === 'daily')    return `Ogni giorno ${pad2(s.atHour)}:${pad2(s.atMinute)}`;\n\n  if (s.freq === 'weekly') {\n    const days = (s.byDay ?? '')\n      .split(',')\n      .filter(Boolean)\n      .map(d => icalToIt[d] ?? d)\n      .join(', ');\n    const dayTxt = days ? `Ogni ${days}` : 'Ogni settimana';\n    return `${dayTxt} ${pad2(s.atHour)}:${pad2(s.atMinute)}`;\n  }\n\n  if (s.freq === 'monthly') {\n    const dom = s.byMonthDay ?? '?';\n    return `Ogni mese (giorno ${dom}) ${pad2(s.atHour)}:${pad2(s.atMinute)}`;\n  }\n\n  if (s.freq === 'cron') {\n    return s.cronExpression ? `Cron: ${s.cronExpression}` : 'Cron';\n  }\n\n  if (s.freq === 'event') {\n    return s.eventType ? `Evento (${s.eventType})` : 'Evento';\n  }\n\n  return '';\n}\n\n/**\n * Robust scheduleTrigger parser:\n * handles:\n *  - rule.interval[0].field present (minutes/hours/days/weeks/months)\n *  - field missing but triggerAtHour/triggerAtMinute present (infer daily)\n *  - weekly/monthly signals via triggerAtDay / dayOfMonth / weekday / days\n */\nfunction parseScheduleTrigger(node) {\n  const rule = node?.parameters?.rule ?? null;\n  const tz = node?.parameters?.timezone || null;\n\n  const intervals = rule?.interval;\n  const i = Array.isArray(intervals) && intervals.length ? intervals[0] : (rule ?? {});\n\n  const out = {\n    triggerType: 'scheduleTrigger',\n    freq: null,\n    interval: 1,\n    atHour: null,\n    atMinute: null,\n    byDay: null,\n    byMonthDay: null,\n    timezone: tz,\n    scheduleRaw: { rule },\n    cronExpression: null,\n  };\n\n  // If cronExpression exists here\n  if (rule?.cronExpression) {\n    out.freq = 'cron';\n    out.cronExpression = rule.cronExpression;\n    out.scheduleHuman = humanSchedule(out);\n    return out;\n  }\n\n  const field = i.field;\n\n  // Helper reads (some versions use different keys)\n  const atHour = i.triggerAtHour ?? i.atHour ?? null;\n  const atMinute = i.triggerAtMinute ?? i.atMinute ?? null;\n  const trigDay = i.triggerAtDay ?? i.weekday ?? i.dayOfWeek ?? i.days ?? null;\n  const monthDay = i.dayOfMonth ?? i.monthDay ?? null;\n\n  // If field is present, use it\n  if (field === 'minutes') {\n    out.freq = 'minutely';\n    out.interval = i.minutesInterval ?? i.interval ?? 1;\n\n  } else if (field === 'hours') {\n    out.freq = 'hourly';\n    out.interval = i.hoursInterval ?? i.interval ?? 1;\n    out.atMinute = atMinute ?? 0;\n\n  } else if (field === 'days') {\n    out.freq = 'daily';\n    out.interval = i.daysInterval ?? i.interval ?? 1;\n    out.atHour = atHour ?? 0;\n    out.atMinute = atMinute ?? 0;\n\n  } else if (field === 'weeks') {\n    out.freq = 'weekly';\n    out.interval = i.weeksInterval ?? i.interval ?? 1;\n    out.atHour = atHour ?? 0;\n    out.atMinute = atMinute ?? 0;\n    out.byDay = normalizeByDay(trigDay);\n\n  } else if (field === 'months') {\n    out.freq = 'monthly';\n    out.interval = i.monthsInterval ?? i.interval ?? 1;\n    out.atHour = atHour ?? 0;\n    out.atMinute = atMinute ?? 0;\n    const dom = i.triggerAtDay ?? monthDay;\n    const domNum = typeof dom === 'number' ? dom : Number(dom);\n    out.byMonthDay = Number.isNaN(domNum) ? null : domNum;\n\n  } else {\n    // field missing -> infer\n    const hasHour = atHour !== null;\n    const hasMinute = atMinute !== null;\n\n    const byDay = normalizeByDay(trigDay);\n    const dom = i.triggerAtDay ?? monthDay;\n\n    if (byDay) {\n      // weekly-ish\n      out.freq = 'weekly';\n      out.atHour = atHour ?? 0;\n      out.atMinute = atMinute ?? 0;\n      out.byDay = byDay;\n    } else if (dom != null) {\n      // monthly-ish\n      out.freq = 'monthly';\n      out.atHour = atHour ?? 0;\n      out.atMinute = atMinute ?? 0;\n      const domNum = typeof dom === 'number' ? dom : Number(dom);\n      out.byMonthDay = Number.isNaN(domNum) ? null : domNum;\n    } else if (hasHour || hasMinute) {\n      // assume daily at HH:MM\n      out.freq = 'daily';\n      out.atHour = atHour ?? 0;\n      out.atMinute = atMinute ?? 0;\n    } else if (rule?.interval?.[0]?.field === undefined && Array.isArray(rule?.interval) && rule.interval.length) {\n      // still unknown shape; keep as unknown for debugging\n      out.freq = 'unknown';\n    } else {\n      out.freq = 'unknown';\n    }\n  }\n\n  out.scheduleHuman = humanSchedule(out);\n  return out;\n}\n\nfunction parseCronNode(node) {\n  const p = node?.parameters ?? {};\n  const cron =\n    p.cronExpression ||\n    p.expression ||\n    p.cron ||\n    p.rules?.[0]?.cronExpression ||\n    null;\n\n  return {\n    triggerType: 'cron',\n    freq: 'cron',\n    interval: 1,\n    atHour: null,\n    atMinute: null,\n    byDay: null,\n    byMonthDay: null,\n    timezone: p.timezone || null,\n    cronExpression: cron,\n    scheduleRaw: { parameters: p },\n    scheduleHuman: humanSchedule({ freq: 'cron', cronExpression: cron }),\n  };\n}\n\nfunction getTags(wf) {\n  const tags = wf.tags ?? [];\n  return tags.map(t => (typeof t === 'string' ? t : t.name)).filter(Boolean);\n}\n\nfunction getNodes(wf) {\n  return wf.activeVersion?.nodes || wf.nodes || [];\n}\n\nconst output = [];\n\nfor (const item of $input.all()) {\n  const wf = item.json;\n  const nodes = getNodes(wf);\n\n  // pick triggers\n  const triggers = nodes.filter(n => {\n    if (n.type === 'n8n-nodes-base.webhook') return false;          // EXCLUDE\n    if (n.type === 'n8n-nodes-base.errorTrigger') return false;     // EXCLUDE\n    if (n.type === 'n8n-nodes-base.manualTrigger') return INCLUDE_MANUAL; // optional\n    return (\n      n.type === 'n8n-nodes-base.scheduleTrigger' ||\n      n.type === 'n8n-nodes-base.cron'\n    );\n  });\n\n  // If no schedulable triggers: do nothing (keeps sheet clean)\n  if (triggers.length === 0) continue;\n\n  for (const t of triggers) {\n    const sched =\n      t.type === 'n8n-nodes-base.scheduleTrigger' ? parseScheduleTrigger(t)\n      : t.type === 'n8n-nodes-base.cron' ? parseCronNode(t)\n      : { triggerType: 'event', freq: 'event', scheduleHuman: 'Evento', scheduleRaw: { parameters: t.parameters ?? {} } };\n\n    output.push({\n      json: {\n        WorkflowID: wf.id,\n        Workflow: wf.name,\n        Tags: JSON.stringify(getTags(wf)),\n        Active: !!wf.active,\n\n        triggerType: sched.triggerType,\n        freq: sched.freq ?? '',\n        interval: sched.interval ?? '',\n        atHour: sched.atHour ?? '',\n        atMinute: sched.atMinute ?? '',\n        byDay: sched.byDay ?? '',\n        byMonthDay: sched.byMonthDay ?? '',\n        timezone: sched.timezone ?? (wf.settings?.timezone || ''),\n\n        schedule: sched.scheduleHuman ?? '',\n        \"Schedule-RAW\": JSON.stringify(sched.scheduleRaw ?? {}),\n      }\n    });\n  }\n}\n\nreturn output;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "807c9b17-1158-4da4-be3e-8de4751009d6",
      "name": "Create an event",
      "type": "n8n-nodes-base.googleCalendar",
      "position": [
        1360,
        512
      ],
      "parameters": {
        "end": "={{$json.calendarPayload.end}}",
        "start": "={{$json.calendarPayload.start}}",
        "calendar": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GOOGLE_CALENDAR_ID",
          "cachedResultName": "YOUR_GOOGLE_CALENDAR_ID"
        },
        "additionalFields": {
          "color": "3",
          "summary": "={{$json.calendarPayload.summary}}",
          "visibility": "private",
          "description": "={{$json.calendarPayload.description}}",
          "repeatUntil": "={{$json.calendarPayload.repeatUntil}}",
          "repeatFrecuency": "={{$json.calendarPayload.repeatFrequency}}"
        },
        "useDefaultReminders": false
      },
      "credentials": {
        "googleCalendarOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "f977bd11-3bc3-48f4-bba2-66531a6a5a90",
      "name": "Code: RRULE",
      "type": "n8n-nodes-base.code",
      "position": [
        1040,
        512
      ],
      "parameters": {
        "jsCode": "/**\n * Google Calendar payload builder.\n *\n * Schedules supportati:\n * - DAILY  \"Ogni giorno HH:MM\"              \u2192 1 evento Daily\n * - WEEKLY \"Ogni settimana HH:MM\"           \u2192 1 evento Weekly\n * - WEEKLY \"Ogni lun HH:MM\" / \"Ogni lun, mer HH:MM\" \u2192 1 evento Weekly\n * - MONTHLY \"Ogni mese (giorno N) HH:MM\"   \u2192 1 evento Monthly\n * - HOURLY \"Ogni ora :MM\"                   \u2192 1 evento Daily (FIX: era 24, ora 1)\n * - MINUTELY / CRON                         \u2192 SKIP\n *\n * FIX: Hourly ora produce 1 solo evento (era 24 \u2192 rate limit).\n * FIX: Aggiunto supporto weekly con giorni e monthly.\n */\n\nconst DEFAULT_TIMEZONE  = 'Europe/Rome';\nconst EVENT_DURATION_MIN = 5;\n\nconst SKIP_WORKFLOW_NAME_CONTAINS = ['Workflow Scheduling Extraction'];\nconst SKIP_SCHEDULE_REGEXES = [\n  /ogni\\s+minuto/i,\n  /ogni\\s+\\d+\\s+minut/i,\n];\n\nconst FREQ = {\n  DAILY:   'Daily',\n  WEEKLY:  'Weekly',\n  MONTHLY: 'Monthly',\n};\n\nconst pad2 = (n) => String(n ?? 0).padStart(2, '0');\n\nfunction localDateTimeString(d) {\n  return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;\n}\n\nfunction startEndForToday(atHour, atMinute) {\n  const now = new Date();\n  const s = new Date(now.getFullYear(), now.getMonth(), now.getDate(), atHour ?? 0, atMinute ?? 0, 0);\n  const e = new Date(s);\n  e.setMinutes(e.getMinutes() + EVENT_DURATION_MIN);\n  return { start: localDateTimeString(s), end: localDateTimeString(e) };\n}\n\nfunction shouldSkip(r) {\n  const wfName = String(r.Workflow || r.workflow || r.name || '');\n  if (SKIP_WORKFLOW_NAME_CONTAINS.some(x => wfName.includes(x))) return true;\n  const sched = String(r.schedule || '');\n  if (SKIP_SCHEDULE_REGEXES.some(rx => rx.test(sched))) return true;\n  return false;\n}\n\n/**\n * Parses humanSchedule string \u2192 schedule type object.\n * Returns null if schedule type is unsupported/should be skipped.\n */\nfunction parseSchedule(scheduleHuman) {\n  if (!scheduleHuman) return null;\n  const txt = String(scheduleHuman).trim().toLowerCase();\n\n  // \"Ogni giorno 09:30\"\n  const mDaily = txt.match(/ogni\\s+giorno\\s+(\\d{1,2})\\s*[:.]\\s*(\\d{1,2})/);\n  if (mDaily) {\n    const h = Number(mDaily[1]), m = Number(mDaily[2]);\n    if (h >= 0 && h <= 23 && m >= 0 && m <= 59) return { type: 'daily', atHour: h, atMinute: m };\n  }\n\n  // \"Ogni settimana 07:00\" (nessun giorno specifico)\n  const mWeekly = txt.match(/ogni\\s+settimana\\s+(\\d{1,2})\\s*[:.]\\s*(\\d{1,2})/);\n  if (mWeekly) {\n    const h = Number(mWeekly[1]), m = Number(mWeekly[2]);\n    if (h >= 0 && h <= 23 && m >= 0 && m <= 59) return { type: 'weekly', atHour: h, atMinute: m };\n  }\n\n  // \"Ogni lun 07:00\" / \"Ogni lun, mer 07:00\" (FIX: weekly con giorni specifici)\n  const mWeeklyDay = txt.match(/^ogni\\s+(?:lun|mar|mer|gio|ven|sab|dom)[\\w,\\s]*\\s+(\\d{1,2})[:.]\\s*(\\d{1,2})/);\n  if (mWeeklyDay) {\n    const h = Number(mWeeklyDay[1]), m = Number(mWeeklyDay[2]);\n    if (h >= 0 && h <= 23 && m >= 0 && m <= 59) return { type: 'weekly', atHour: h, atMinute: m };\n  }\n\n  // \"Ogni mese (giorno N) HH:MM\" (FIX: aggiunto monthly)\n  const mMonthly = txt.match(/ogni\\s+mese.*\\s+(\\d{1,2}):(\\d{2})\\s*$/);\n  if (mMonthly) {\n    const h = Number(mMonthly[1]), m = Number(mMonthly[2]);\n    if (h >= 0 && h <= 23 && m >= 0 && m <= 59) return { type: 'monthly', atHour: h, atMinute: m };\n  }\n\n  // \"Ogni ora :45\" o \"Ogni ora .00\" (FIX: ora produce 1 evento invece di 24)\n  const mHourly = txt.match(/ogni\\s+ora\\s*[.:]\\s*(\\d{1,2})/);\n  if (mHourly) {\n    const m = Number(mHourly[1]);\n    if (m >= 0 && m <= 59) return { type: 'hourly', minute: m };\n  }\n\n  return null;  // cron, unknown, minutely \u2192 skip\n}\n\nfunction buildCommon(r) {\n  const wfName = r.Workflow || r.workflow || r.name || 'workflow';\n  const description =\n`WorkflowID: ${r.WorkflowID || r.workflowId || r.id || ''}\nTags: ${r.Tags || r.tags || ''}\nTriggerType: ${r.triggerType || ''}\nSchedule: ${r.schedule || ''}`;\n  return { summaryBase: `n8n | ${wfName}`, description };\n}\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const r = item.json;\n  if (shouldSkip(r)) continue;\n\n  const parsed = parseSchedule(r.schedule);\n  if (!parsed) {\n    console.log(`SKIP (schedule non supportato): ${r.WorkflowID} | ${r.schedule}`);\n    continue;\n  }\n\n  const timezone = r.timezone || DEFAULT_TIMEZONE;\n  const { summaryBase, description } = buildCommon(r);\n\n  if (parsed.type === 'daily') {\n    const { start, end } = startEndForToday(parsed.atHour, parsed.atMinute);\n    out.push({ json: { ...r, calendarPayload: { summary: summaryBase, description, start, end, timezone, repeatFrequency: FREQ.DAILY, repeatUntil: null } } });\n  }\n\n  if (parsed.type === 'weekly') {\n    const { start, end } = startEndForToday(parsed.atHour, parsed.atMinute);\n    out.push({ json: { ...r, calendarPayload: { summary: summaryBase, description, start, end, timezone, repeatFrequency: FREQ.WEEKLY, repeatUntil: null } } });\n  }\n\n  if (parsed.type === 'monthly') {\n    const { start, end } = startEndForToday(parsed.atHour, parsed.atMinute);\n    out.push({ json: { ...r, calendarPayload: { summary: summaryBase, description, start, end, timezone, repeatFrequency: FREQ.MONTHLY, repeatUntil: null } } });\n  }\n\n  if (parsed.type === 'hourly') {\n    // FIX: 1 evento Daily invece di 24 (riduceva a ~100 API calls \u2192 rate limit)\n    // L'evento \u00e8 collocato a 00:MM e si ripete ogni giorno, con label \"ogni ora :MM\"\n    const { start, end } = startEndForToday(0, parsed.minute);\n    out.push({\n      json: {\n        ...r,\n        calendarPayload: {\n          summary: `${summaryBase} (ogni ora :${pad2(parsed.minute)})`,\n          description,\n          start,\n          end,\n          timezone,\n          repeatFrequency: FREQ.DAILY,\n          repeatUntil: null,\n        }\n      }\n    });\n  }\n}\n\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "7766b246-1694-43f4-8640-884dacb379e1",
      "name": "Delete an event",
      "type": "n8n-nodes-base.googleCalendar",
      "onError": "continueRegularOutput",
      "position": [
        -1136,
        1040
      ],
      "parameters": {
        "eventId": "={{ $json.id }}",
        "options": {
          "sendUpdates": "none"
        },
        "calendar": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GOOGLE_CALENDAR_ID",
          "cachedResultName": "YOUR_GOOGLE_CALENDAR_ID"
        },
        "operation": "delete"
      },
      "credentials": {
        "googleCalendarOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3,
      "alwaysOutputData": true
    },
    {
      "id": "eada40f6-f268-434b-b3f2-2233bc0ae636",
      "name": "Get many events2",
      "type": "n8n-nodes-base.googleCalendar",
      "position": [
        -1424,
        1040
      ],
      "parameters": {
        "options": {
          "recurringEventHandling": "first"
        },
        "timeMax": "2100-12-31T00:00:00",
        "timeMin": "2026-01-16T00:00:00",
        "calendar": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GOOGLE_CALENDAR_ID",
          "cachedResultName": "YOUR_GOOGLE_CALENDAR_ID"
        },
        "operation": "getAll",
        "returnAll": true
      },
      "credentials": {
        "googleCalendarOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "b91a529d-f08e-4b17-86f5-a3972baff2ab",
      "name": "Remove Duplicates",
      "type": "n8n-nodes-base.removeDuplicates",
      "position": [
        720,
        512
      ],
      "parameters": {
        "compare": "selectedFields",
        "options": {},
        "fieldsToCompare": "WorkflowID,current_schedule"
      },
      "typeVersion": 2
    },
    {
      "id": "f4a38c2c-da29-47bb-a0b2-3bb2d5b5f396",
      "name": "Sheets:Lookup-ExistOnCalendar",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -528,
        512
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 607954075,
          "cachedResultName": "n8n Scheduling"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultName": "n8n"
        },
        "authentication": "serviceAccount"
      },
      "credentials": {
        "googleApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7,
      "alwaysOutputData": true
    },
    {
      "id": "5c8aae74-3d10-4432-bf1c-371fd65f1718",
      "name": "Sheets:OnCalendar=YES",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2016,
        512
      ],
      "parameters": {
        "columns": {
          "value": {
            "Tags": "={{ $json.Tags }}",
            "freq": "={{ $json.freq }}",
            "byDay": "={{ $json.byDay }}",
            "atHour": "={{ $json.atHour }}",
            "Workflow": "={{ $json.Workflow }}",
            "atMinute": "={{ $json.atMinute }}",
            "interval": "={{ $json.interval }}",
            "schedule": "={{ $json.schedule }}",
            "WorkflowID": "={{ $json.WorkflowID }}",
            "byMonthDay": "={{ $json.byMonthDay }}",
            "On Calendar": "YES",
            "triggerType": "={{ $json.triggerType }}",
            "Calendar_EventID": "={{ $json.Calendar_EventID }}",
            "calendarLastSync": "={{ $today }}"
          },
          "schema": [
            {
              "id": "WorkflowID",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "WorkflowID",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Workflow",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Workflow",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Tags",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Tags",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "triggerType",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "triggerType",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "schedule",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "schedule",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Calendar_EventID",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Calendar_EventID",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "On Calendar",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "On Calendar",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "calendarLastSync",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "calendarLastSync",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "freq",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "freq",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "atHour",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "atHour",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "atMinute",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "atMinute",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "byDay",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "byDay",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "byMonthDay",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "byMonthDay",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "interval",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "interval",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "WorkflowID"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 607954075,
          "cachedResultName": "n8n Scheduling"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultName": "n8n"
        },
        "authentication": "serviceAccount"
      },
      "credentials": {
        "googleApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "3618a0a7-5b23-4f5a-94a5-9c57999af221",
      "name": "Get many workflows",
      "type": "n8n-nodes-base.n8n",
      "position": [
        -1472,
        512
      ],
      "parameters": {
        "filters": {
          "activeWorkflows": true
        },
        "requestOptions": {}
      },
      "credentials": {
        "n8nApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "alwaysOutputData": false
    },
    {
      "id": "78b8189a-02c8-4598-b3ef-279a0765fe7c",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "disabled": true,
      "position": [
        -1744,
        1040
      ],
      "parameters": {
        "path": "delete-calendar",
        "options": {}
      },
      "typeVersion": 2.1
    },
    {
      "id": "7b47f7cf-a72b-44ce-a30e-4ce2695a6de9",
      "name": "Code: detect changes",
      "type": "n8n-nodes-base.code",
      "position": [
        112,
        512
      ],
      "parameters": {
        "jsCode": "/**\n * Detect Changes - confronta 'schedule' (stringa human) tra stato corrente e Sheets.\n *\n * Logica:\n *   - On Calendar vuoto O schedule salvato vuoto \u2192 CREATE\n *   - schedule corrente \u2260 schedule salvato \u2192 UPDATE\n *   - schedule \u00e8 cron/minutely E gi\u00e0 su Calendar \u2192 SKIP (evita loop 410 su delete)\n *   - altrimenti \u2192 SKIP\n */\n\n// Schedule types che RRULE non pu\u00f2 gestire \u2014 non creare nuovi eventi per questi\nconst UNSUPPORTED_REGEXES = [\n  /ogni\\s+minuto/i,\n  /ogni\\s+\\d+\\s+minut/i,\n  /^cron:/i,\n];\n\nfunction isUnsupported(schedule) {\n  return !schedule || UNSUPPORTED_REGEXES.some(rx => rx.test(String(schedule).trim()));\n}\n\nreturn items.map(item => {\n  const data = item.json;\n\n  const currentSchedule = String(data.current_schedule ?? '');\n  const savedSchedule   = String(data.schedule ?? '');\n  const onCalendar      = data['On Calendar'] || '';\n  const workflowId      = data.WorkflowID || data.current_WorkflowID || '';\n\n  console.log(`=== ${workflowId} | current=\"${currentSchedule}\" | saved=\"${savedSchedule}\" | onCal=\"${onCalendar}\"`);\n\n  if (!workflowId) {\n    return { json: { ...data, action: 'skip', scheduleChanged: false, changedFields: '' } };\n  }\n\n  let action = 'skip';\n  let scheduleChanged = false;\n\n  const savedIsEmpty  = !savedSchedule;\n  const isNewWorkflow = !onCalendar || savedIsEmpty;\n\n  if (isNewWorkflow) {\n    // Nuovo workflow: crea solo se lo schedule \u00e8 supportato da RRULE\n    if (isUnsupported(currentSchedule)) {\n      action = 'skip';\n      console.log('\u2192 skip (nuovo ma schedule non supportato da RRULE)');\n    } else {\n      action = 'create';\n      console.log('\u2192 create (nuovo)');\n    }\n\n  } else if (onCalendar === 'YES') {\n\n    if (currentSchedule !== savedSchedule) {\n      // Schedule cambiato\n      if (isUnsupported(currentSchedule)) {\n        // Nuovo schedule non supportabile \u2192 skip, lascia evento vecchio su Calendar\n        // (l'evento sar\u00e0 stale ma non causa loop 410)\n        action = 'skip';\n        console.log('\u2192 skip (schedule cambiato in tipo non supportato - lascia evento esistente)');\n      } else {\n        action = 'update';\n        scheduleChanged = true;\n        console.log(`\u2192 update (schedule cambiato: \"${currentSchedule}\" vs \"${savedSchedule}\")`);\n      }\n    } else {\n      action = 'skip';\n      console.log('\u2192 skip (invariato)');\n    }\n\n  } else {\n    // On Calendar = 'NO'\n    if (isUnsupported(currentSchedule)) {\n      action = 'skip';\n      console.log('\u2192 skip (On Calendar=NO, schedule non supportato)');\n    } else {\n      action = 'create';\n      console.log('\u2192 create (On Calendar=NO)');\n    }\n  }\n\n  return {\n    json: {\n      ...data,\n      action,\n      scheduleChanged,\n      changedFields: scheduleChanged ? 'schedule' : '',\n      _debug: { workflowId, currentSchedule, savedSchedule, onCalendar, isNewWorkflow }\n    }\n  };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "d6caddd9-77d7-42ee-b9c3-ac6ed24a6bdb",
      "name": "Switch",
      "type": "n8n-nodes-base.switch",
      "position": [
        416,
        496
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "create",
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "145d9953-de54-49da-a77e-4b36ac6746c4",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "create"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "update",
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "e8db78af-3bc3-4902-83e5-1654aab87e60",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "update"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "skip",
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "2cab4207-3a61-421a-a62a-b2c5a3601691",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "skip"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.4
    },
    {
      "id": "98859d16-bf8b-46ad-b2a1-739c1e7e304d",
      "name": "Code: save current values",
      "type": "n8n-nodes-base.code",
      "position": [
        -848,
        512
      ],
      "parameters": {
        "jsCode": "// FIX: usa ?? '' (non || '') per non perdere valori numerici come atHour=0 o atMinute=0\nreturn items.map(item => {\n  return {\n    json: {\n      ...item.json,\n      current_schedule:    item.json.schedule    ?? '',\n      current_freq:        item.json.freq        ?? '',\n      current_atHour:      String(item.json.atHour   != null ? item.json.atHour   : ''),\n      current_atMinute:    String(item.json.atMinute != null ? item.json.atMinute : ''),\n      current_byDay:       item.json.byDay       ?? '',\n      current_byMonthDay:  String(item.json.byMonthDay != null ? item.json.byMonthDay : ''),\n      current_interval:    String(item.json.interval  != null ? item.json.interval  : ''),\n      current_WorkflowID:  item.json.WorkflowID  ?? ''\n    }\n  };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "3d5e5933-7d47-4a01-816e-d6197025821f",
      "name": "Code manual merge",
      "type": "n8n-nodes-base.code",
      "position": [
        -208,
        512
      ],
      "parameters": {
        "jsCode": "/**\n * Match manuale: combina i dati correnti con quelli salvati dal foglio.\n * FIX: Calendar_EventID ora propagato correttamente (serve per cancellare eventi vecchi).\n */\n\nconst currentWorkflows = $('Code: save current values').all();\nconst sheetRows = items;\n\nconsole.log(`Current workflows: ${currentWorkflows.length}`);\nconsole.log(`Sheet rows: ${sheetRows.length}`);\n\nconst savedMap = {};\nfor (const row of sheetRows) {\n  const wfId = row.json.WorkflowID;\n  if (wfId) savedMap[wfId] = row.json;\n}\n\nconsole.log(`Found ${Object.keys(savedMap).length} unique workflows in sheet`);\n\nconst output = currentWorkflows.map(current => {\n  const wfId = current.json.WorkflowID;\n  const saved = savedMap[wfId] || {};\n  const hasSavedData = Object.keys(saved).length > 0;\n\n  return {\n    json: {\n      ...current.json,\n\n      // Dati salvati su Sheets (usa null-coalesce per non perdere valori numerici tipo 0)\n      schedule:         saved.schedule          || '',\n      freq:             saved.freq              || '',\n      atHour:           saved.atHour   != null  ? saved.atHour   : '',\n      atMinute:         saved.atMinute != null  ? saved.atMinute : '',\n      byDay:            saved.byDay             || '',\n      byMonthDay:       saved.byMonthDay != null ? saved.byMonthDay : '',\n      interval:         saved.interval != null  ? saved.interval : '',\n      'On Calendar':    saved['On Calendar']    || '',\n      // \u2705 FIX CRITICO: Calendar_EventID era mancante \u2192 UPDATE non cancellava mai i vecchi eventi\n      'Calendar_EventID': saved['Calendar_EventID'] || '',\n\n      _matchDebug: {\n        workflowId:       wfId,\n        foundInSheet:     hasSavedData,\n        totalInSheet:     Object.keys(savedMap).length,\n        savedSchedule:    saved.schedule           || 'EMPTY',\n        currentSchedule:  current.json.current_schedule || 'EMPTY',\n        calendarEventID:  saved['Calendar_EventID'] || 'EMPTY',\n      }\n    }\n  };\n});\n\nconsole.log(`Returning ${output.length} merged items`);\nreturn output;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "ef89bad9-1015-4fc3-8bcc-6ae0755255e3",
      "name": "Code: post-create",
      "type": "n8n-nodes-base.code",
      "position": [
        1696,
        512
      ],
      "parameters": {
        "jsCode": "/**\n * Post-create: aggrega gli event ID di Google Calendar per workflowId.\n *\n * Input: N items da 'Create an event' (risposta GCal - uno per evento creato)\n * Cross-ref: $('Code: RRULE').all() per recuperare i dati workflow originali\n * Output: 1 item per workflowId -> Sheets:OnCalendar=YES\n */\n\nconst allRruleItems = $('Code: RRULE').all();\nconst byWorkflow = {};\n\nfor (let i = 0; i < items.length; i++) {\n  const created = items[i].json;\n  const rruleItem = allRruleItems[i];\n  if (!rruleItem) continue;\n\n  const d = rruleItem.json;\n  const wfId = d.WorkflowID || d.current_WorkflowID || '';\n  if (!wfId) continue;\n\n  if (!byWorkflow[wfId]) {\n    byWorkflow[wfId] = { data: d, eventIds: [] };\n  }\n  const eventId = created.id || '';\n  if (eventId) byWorkflow[wfId].eventIds.push(eventId);\n}\n\nreturn Object.entries(byWorkflow).map(([wfId, { data: d, eventIds }]) => ({\n  json: {\n    WorkflowID: wfId,\n    Workflow: d.Workflow || '',\n    Tags: d.Tags || '',\n    triggerType: d.triggerType || '',\n    schedule: d.current_schedule || d.schedule || '',\n    freq: d.current_freq || d.freq || '',\n    atHour: String(d.current_atHour ?? d.atHour ?? ''),\n    atMinute: String(d.current_atMinute ?? d.atMinute ?? ''),\n    byDay: String(d.current_byDay ?? d.byDay ?? ''),\n    byMonthDay: String(d.current_byMonthDay ?? d.byMonthDay ?? ''),\n    interval: String(d.current_interval ?? d.interval ?? ''),\n    'On Calendar': 'YES',\n    Calendar_EventID: eventIds.join(' '),\n    calendarLastSync: new Date().toISOString().split('T')[0]\n  }\n}));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "d57f09f3-25bb-4b9a-8e98-9374cb7be397",
      "name": "Code: split eventIds for delete",
      "type": "n8n-nodes-base.code",
      "position": [
        768,
        1040
      ],
      "parameters": {
        "jsCode": "/**\n * Split eventIds: estrae un item per Calendar_EventID da eliminare.\n *\n * Input: items da Switch[update] (hanno Calendar_EventID spazio-separato)\n * Output: 1 item per eventId { calendarEventIdToDelete: 'gCal_event_id' }\n *\n * Se Calendar_EventID e' vuoto -> nessun output (niente da eliminare).\n * Il percorso UPDATE crea comunque nuovi eventi via Remove Duplicates.\n */\n\nconst output = [];\nfor (const item of items) {\n  const raw = String(item.json.Calendar_EventID || '').trim();\n  const ids = raw.split(/\\s+/).filter(id => id.length > 0);\n  for (const eid of ids) {\n    output.push({ json: { calendarEventIdToDelete: eid } });\n  }\n}\nreturn output;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "f22523dc-c631-4daa-8bd1-40a3de59d8b3",
      "name": "Delete old event",
      "type": "n8n-nodes-base.googleCalendar",
      "position": [
        1056,
        1040
      ],
      "parameters": {
        "eventId": "={{ $json.calendarEventIdToDelete }}",
        "options": {
          "sendUpdates": "none"
        },
        "calendar": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GOOGLE_CALENDAR_ID",
          "cachedResultName": "YOUR_GOOGLE_CALENDAR_ID"
        },
        "operation": "delete"
      },
      "credentials": {
        "googleCalendarOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3,
      "continueOnFail": true
    },
    {
      "id": "ea296d83-c150-4241-81a9-228c5b3f2db5",
      "name": "__title",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1824,
        48
      ],
      "parameters": {
        "color": 7,
        "width": 4104,
        "height": 80,
        "content": "## \ud83d\udd52 n8n \u2013 Workflow Scheduling Extraction\nScans all active n8n workflows every **30 min** \u00b7 syncs schedules as recurring events on Google Calendar `YOUR_GOOGLE_ACCOUNT@gmail.com` \u00b7 state store: Google Sheets `n8n Scheduling` tab"
      },
      "typeVersion": 1
    },
    {
      "id": "f4b3437c-a404-460a-aa80-abfbfbe3e6c3",
      "name": "__sec_fetch",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1824,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 584,
        "height": 544,
        "content": "### A \u00b7 Fetch\n\nRetrieves all n8n workflows via the n8n REST API. Both active and inactive workflows are fetched; filtering to scheduleTrigger-only happens in **Code: parsing**."
      },
      "typeVersion": 1
    },
    {
      "id": "e5a7b6f2-3ea5-45f1-98d0-1947f109854e",
      "name": "__sec_parse",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1216,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 908,
        "height": 544,
        "content": "### B \u00b7 Parse & Lookup\n\nNormalises each workflow's trigger configuration into a human-readable schedule string. Then reads the current Sheets state (schedule, On Calendar, Calendar_EventID) for every WorkflowID so it can be compared downstream."
      },
      "typeVersion": 1
    },
    {
      "id": "9f5e07d5-b3f7-4535-bcb0-18282622262a",
      "name": "__sec_detect",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -272,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 880,
        "height": 544,
        "content": "### C \u00b7 Change Detection\n\nMerges live workflow data with Sheets state. Compares `current_schedule` (live) vs `schedule` (Sheets) and assigns an **action**: `create` (new) \u00b7 `update` (changed) \u00b7 `skip` (unchanged or unsupported type). Cron and minutely schedules are always skipped."
      },
      "typeVersion": 1
    },
    {
      "id": "4d76bdd7-e4b0-4322-a8b9-e41596259524",
      "name": "__sec_create",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 1640,
        "height": 528,
        "content": "### D \u00b7 Create / Update Path\n\nBuilds a Google Calendar recurring event payload (RRULE) and creates the event. On **update** this branch runs in parallel with the Delete branch (E). After creation, writes Calendar_EventID + On Calendar=YES back to Sheets."
      },
      "typeVersion": 1
    },
    {
      "id": "297bdd17-8445-4781-99ed-814945170bdb",
      "name": "__sec_delete",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        704
      ],
      "parameters": {
        "color": 7,
        "width": 700,
        "height": 496,
        "content": "### E \u00b7 Delete Old Event\n\nRuns in parallel with branch D on **update**. Deletes the previous Calendar event(s) identified by Calendar_EventID from Sheets. `continueOnFail = true` \u2014 HTTP 410 (already gone) does not halt execution."
      },
      "typeVersion": 1
    },
    {
      "id": "3f1980e5-cc38-4362-9a4f-4f7295193e77",
      "name": "__sec_webhook",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1824,
        736
      ],
      "parameters": {
        "color": 7,
        "width": 940,
        "height": 470,
        "content": "### F \u00b7 Webhook Sub-flow  *(disconnected \u2013 manual trigger)*\n\nStandalone maintenance endpoint. Accepts an HTTP POST with event IDs, fetches the matching Calendar events, and hard-deletes them. Not connected to the main 30-min pipeline."
      },
      "typeVersion": 1
    },
    {
      "id": "eec1eecc-2a8f-4cfa-9ea9-e49372c00922",
      "name": "__node_schedule_trigger",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1808,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 380,
        "content": "**Schedule Trigger**\n\nFires every 30 min (cron `*/30 * * * *`).\nAlso supports manual execution from the n8n UI.\nEntry point for the entire sync pipeline."
      },
      "typeVersion": 1
    },
    {
      "id": "07b02e91-7f2b-496a-b2db-ec4d36833299",
      "name": "__node_get_many_workflows",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1536,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 412,
        "content": "**Get many workflows**\n\nCalls `GET /api/v1/workflows` on the local n8n instance.\nReturns all workflows (active + inactive).\nNo filter applied here \u2014 filtering is done in Code: parsing."
      },
      "typeVersion": 1
    },
    {
      "id": "d6c8e7a1-7073-4074-8c61-6e3b3fff0e79",
      "name": "__node_code_parsing",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1216,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 412,
        "content": "**Code: parsing**\n\n- Filters to workflows that have a `scheduleTrigger` node\n- Excludes system / internal workflows by name\n- Normalises trigger config \u2192 human schedule string\n- Extracts: WorkflowID, name, tags, triggerType, schedule, freq, atHour, atMinute, byDay, byMonthDay, interval"
      },
      "typeVersion": 1
    },
    {
      "id": "abe5abb3-7c89-4f77-b7a9-23d51ef51daf",
      "name": "__node_code_save",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -912,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 412,
        "content": "**Code: save current values**\n\nSnapshots all parsed fields into `current_*` prefixed copies\n(e.g. `current_schedule`, `current_freq`, \u2026).\nPreserves the live state before it is overwritten by the Sheets\nmerge so change detection can compare fresh vs. persisted data."
      },
      "typeVersion": 1
    },
    {
      "id": "e7c98db5-8183-4a2d-aa4d-a95f0f6be0d4",
      "name": "__node_sheets_lookup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -592,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 412,
        "content": "**Sheets: Lookup ExistOnCalendar**\n\nReads every row in the \"n8n Scheduling\" tab of the spreadsheet.\nReturns per-workflow: `schedule`, `On Calendar`, `Calendar_EventID`, `calendarLastSync`.\nUses a Google Service Account (does not expire \u2014 no OAuth re-auth needed)."
      },
      "typeVersion": 1
    },
    {
      "id": "f3b6e523-8268-4cd6-9a86-fab7a0b3b663",
      "name": "__node_code_merge",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -256,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 412,
        "content": "**Code manual merge**\n\nJoins live workflow items with Sheets rows on WorkflowID.\nPropagates from Sheets: `schedule`, `On Calendar`, `Calendar_EventID`.\n`current_*` fields are kept intact from Code: save current values.\nCritical: `Calendar_EventID` must flow through here for the delete branch to work."
      },
      "typeVersion": 1
    },
    {
      "id": "b2edab53-4877-4dae-9f95-a7a144be4b41",
      "name": "__node_code_detect",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        48,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 396,
        "content": "**Code: detect changes**\n\nCompares `current_schedule` (live) vs `schedule` (Sheets).\nUses only the human string \u2014 not granular fields \u2014 to avoid false positives from stale rows.\n- Empty saved schedule or no Calendar entry \u2192 **create**\n- Strings differ \u2192 **update** (unless new schedule is unsupported \u2192 skip)\n- Strings equal \u2192 **skip**\n- cron / minutely / unsupported type \u2192 always **skip**"
      },
      "typeVersion": 1
    },
    {
      "id": "0135024e-1f68-4378-a436-fbe7e4c755bc",
      "name": "__node_switch",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        336,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 412,
        "content": "**Switch**\n\nRoutes items by the `action` field:\n- `out0` \u2192 **create**  (new workflow, no Calendar event yet)\n- `out1` \u2192 **update**  (schedule changed; two parallel branches: D + E)\n- `out2` \u2192 **skip**    (no change; terminal \u2014 no downstream nodes)"
      },
      "typeVersion": 1
    },
    {
      "id": "b0fc2d09-58c9-4093-9587-c04b19c302c1",
      "name": "__node_remove_dupes",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        656,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 380,
        "content": "**Remove Duplicates**\n\nDeduplicates by WorkflowID before event creation.\nDefensive node: prevents accidental double-creation if a workflow\nappears multiple times in the current batch (e.g. after a manual re-run)."
      },
      "typeVersion": 1
    },
    {
      "id": "30577841-cce6-48e4-b9c8-9064856eeac5",
      "name": "__node_code_rrule",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        960,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 380,
        "content": "**Code: RRULE**\n\nConverts `current_schedule` string \u2192 Google Calendar event payload with RRULE recurrence.\nSupported types: daily \u00b7 weekly (with or without specific days) \u00b7 monthly \u00b7 hourly.\nHourly \u2192 1 daily event at 00:MM (not 24 events \u2014 avoids API rate limiting).\nUnsupported: cron, minutely \u2192 item silently dropped (returns empty array)."
      },
      "typeVersion": 1
    },
    {
      "id": "afb8e9c5-a0be-4c7c-9649-77d97d68a22f",
      "name": "__node_create_event",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1280,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 396,
        "content": "**Create an event**\n\nGoogle Calendar node. Creates a recurring event on `YOUR_GOOGLE_ACCOUNT@gmail.com`.\nUses `repeatFrequency` + `repeatUntil = null` for infinite recurrence.\nReturns the full GCal event object including the `id` used downstream.\nOAuth credential: `Google Calendar OAuth2` (expires periodically)."
      },
      "typeVersion": 1
    },
    {
      "id": "8009e9f3-59ef-47aa-af81-00344237954b",
      "name": "__node_code_post_create",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1616,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 412,
        "content": "**Code: post-create**\n\nAggregates created event IDs by WorkflowID (handles batch creates).\nAdds `On Calendar = YES`, `Calendar_EventID`, `calendarLastSync` (ISO timestamp).\nPrepares the exact row structure expected by Sheets: OnCalendar=YES."
      },
      "typeVersion": 1
    },
    {
      "id": "0da373d3-2a4d-4fed-9d54-4541e73c0899",
      "name": "__node_sheets_yes",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1952,
        256
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 412,
        "content": "**Sheets: OnCalendar=YES**\n\n`appendOrUpdate` operation matching on the **WorkflowID** column.\nWrites all schedule fields + `On Calendar=YES` + `Calendar_EventID` + `calendarLastSync`.\nService Account credential (never expires).\nThis is the canonical state store \u2014 downstream runs read from here."
      },
      "typeVersion": 1
    },
    {
      "id": "d693564a-7a09-4880-bf53-e760331b118d",
      "name": "__node_code_split",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        688,
        816
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 366,
        "content": "**Code: split eventIds for delete**\n\nSplits the `Calendar_EventID` field (comma-separated string) into one item per ID.\nRequired because a workflow can accumulate multiple event IDs across runs.\nFeeds individual IDs to Delete old event."
      },
      "typeVersion": 1
    },
    {
      "id": "57e6e365-7fd8-40b5-b948-a20a635dd9c0",
      "name": "__node_delete_old",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        992,
        816
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 382,
        "content": "**Delete old event**\n\nDeletes the previous Calendar event(s) when a schedule update occurs.\n`continueOnFail = true` \u2014 HTTP 410 \"Resource has been deleted\" is tolerated and does not halt execution.\nTerminal node \u2014 no downstream connections."
      },
      "typeVersion": 1
    },
    {
      "id": "32cdfa17-1447-4dc3-9e38-0e42dd21fadb",
      "name": "__node_webhook",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1808,
        848
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 350,
        "content": "**Webhook**\n\nHTTP POST trigger for manual Calendar cleanup.\nNot connected to the main 30-min pipeline.\nAccepts event IDs in the request body."
      },
      "typeVersion": 1
    },
    {
      "id": "09035062-7137-45c7-a4b4-d3ffca205ba2",
      "name": "__node_get_events2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1504,
        848
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 350,
        "content": "**Get many events2**\n\nRetrieves Calendar events by ID or search query.\nFeeds the event list into Delete an event."
      },
      "typeVersion": 1
    },
    {
      "id": "0b136e75-9d51-4280-a375-08b2ac28dbae",
      "name": "__node_delete_event",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1184,
        848
      ],
      "parameters": {
        "color": 7,
        "width": 260,
        "height": 350,
        "content": "**Delete an event**\n\nHard-deletes Calendar events by ID.\nUsed only in the manual webhook-triggered maintenance flow."
      },
      "typeVersion": 1
    },
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "name": "__overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2544,
        48
      ],
      "parameters": {
        "width": 680,
        "height": 1288,
        "content": "## n8n Workflow Scheduling Extraction\n\nReads all n8n workflows every 30 min via REST API, extracts their schedules, compares against a Google Sheets state store, and syncs Google Calendar with recurring events.\n\n\ud83d\udcc4 Full docs: https://paoloronco.notion.site/n8n-Workflow-Scheduling-Extraction-Setup-Docs-330f0ba27c3280ef99b2c5e8e7dfd497\n\n---\n\n### What it does\n- **Fetch** all workflows via `GET /api/v1/workflows`\n- **Parse** scheduleTrigger / cron nodes \u2192 human-readable string (e.g. \"Every day 09:30\")\n- **Lookup** saved state from Sheets: `schedule`, `On Calendar`, `Calendar_EventID`\n- **Detect changes**: `create` \u00b7 `update` \u00b7 `skip`\n  - create: new workflow, no Calendar event yet\n  - update: schedule changed \u2192 delete old event + create new one\n  - skip: unchanged, or unsupported type (cron / minutely)\n- **Create**: build RRULE payload \u2192 create GCal event \u2192 write EventID to Sheets\n- **Update**: parallel branches \u2014 delete old event + create path\n- This workflow itself is always skipped (name filter)\n\n---\n\n### Required credentials\n- **n8n API key** \u2014 Settings \u2192 API \u2192 Add key \u2192 credential `n8n account`\n- **Google Calendar OAuth2** \u2014 credential `Oauth GCalendar`\n  \u26a0\ufe0f Expires periodically \u2014 reconnect from Settings \u2192 Credentials\n- **Google Service Account** \u2014 credential `GCP_SA`\n  Never expires. Used exclusively for Google Sheets\n\n---\n\n### Setup\n1. n8n Settings \u2192 API \u2192 create API key \u2192 credential `n8n account`\n2. GCP Console \u2192 create Service Account \u2192 download JSON key\n   \u2192 import in n8n as Google Service Account (`GCP_SA`)\n3. Share the Google Sheets with the SA email (Editor role)\n   Sheet: `YOUR_SPREADSHEET_ID` \u00b7 Tab: `n8n Scheduling`\n4. GCP \u2192 OAuth 2.0 client ID \u2192 credential Google Calendar OAuth2 (`Oauth GCalendar`)\n   \u2192 click \"Connect\" in n8n to authorize\n5. Workflow Settings \u2192 Error Workflow = `YOUR_ERROR_WORKFLOW_ID`\n6. Activate the workflow \u2014 first run populates Sheets and Calendar\n\n---\n\n### Known limits\n- **Hourly**: 1 daily event at 00:MM, not 24 (avoids GCal rate limit)\n- **Cron / minutely**: always skipped, never appear on Calendar\n- **Webhook sub-flow** (section F): disconnected, manual maintenance only\n- **OAuth GCalendar**: expiry blocks sections D/E \u2014 monitor regularly\n"
      },
      "typeVersion": 1
    }
  ],
  "active": true,
  "settings": {
    "callerPolicy": "workflowsFromSameOwner",
    "executionOrder": "v1"
  },
  "connections": {
    "Switch": {
      "main": [
        [
          {
            "node": "Remove Duplicates",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Remove Duplicates",
            "type": "main",
            "index": 0
          },
          {
            "node": "Code: split eventIds for delete",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook": {
      "main": [
        [
          {
            "node": "Get many events2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: RRULE": {
      "main": [
        [
          {
            "node": "Create an event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: parsing": {
      "main": [
        [
          {
            "node": "Code: save current values",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create an event": {
      "main": [
        [
          {
            "node": "Code: post-create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get many events2": {
      "main": [
        [
          {
            "node": "Delete an event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Get many workflows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code manual merge": {
      "main": [
        [
          {
            "node": "Code: detect changes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: post-create": {
      "main": [
        [
          {
            "node": "Sheets:OnCalendar=YES",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Remove Duplicates": {
      "main": [
        [
          {
            "node": "Code: RRULE",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get many workflows": {
      "main": [
        [
          {
            "node": "Code: parsing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: detect changes": {
      "main": [
        [
          {
            "node": "Switch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: save current values": {
      "main": [
        [
          {
            "node": "Sheets:Lookup-ExistOnCalendar",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sheets:Lookup-ExistOnCalendar": {
      "main": [
        [
          {
            "node": "Code manual merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: split eventIds for delete": {
      "main": [
        [
          {
            "node": "Delete old event",
            "type": "main",
            "index": 0
          }
 

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

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

About this workflow

Reads every workflow on your n8n instance every 30 minutes, extracts their schedule triggers, and keeps a matching recurring event on Google Calendar — one event per workflow, forever in sync.

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

More Data & Sheets workflows → · Browse all categories →

Related workflows

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

Data & Sheets

How it works Listens for new or updated events in your chosen Google Calendar. Extracts key details like event name, date, time, description, and attendees. Adds each event as a new row in your connec

Google Calendar, Google Sheets
Data & Sheets

Teams that manage tasks in ClickUp and want those tasks reflected—and kept in sync—in Google Calendar automatically.

Google Calendar, Google Sheets, ClickUp Trigger +1
Data & Sheets

This workflow monitors customer health by combining payment behavior, complaint signals, and AI-driven feedback analysis. It runs on daily and weekly schedules to evaluate risk levels, escalate high-r

Google Sheets, HTTP Request, Gmail +2
Data & Sheets

Workflow Description:

GraphQL, Google Sheets
Data & Sheets

Code Postgres. Uses httpRequest, splitInBatches, postgres, hubspot. Scheduled trigger; 23 nodes.

HTTP Request, Postgres, HubSpot +1