{
  "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
          }
        ]
      ]
    }
  }
}