{
 "name": "Vacation handover: Nextcloud calendar, Deck, files and Talk → handover note and comic (daniele-michelin.com)",
 "nodes": [
  {
   "parameters": {
    "httpMethod": "POST",
    "path": "your-secret-path",
    "responseMode": "onReceived",
    "options": {}
   },
   "id": "w1",
   "name": "Visitor button (Webhook)",
   "type": "n8n-nodes-base.webhook",
   "typeVersion": 2,
   "position": [
    0,
    400
   ],
   "webhookId": "7e3c1a52-4d6b-4f0e-9a21-2c0000000h01"
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// FIX THE DEMO SPACE (hard-wired) – the ONLY places in the real Nextcloud account this workflow may read or write:\n//   • Calendar  brightside-demo               (/remote.php/dav/calendars/Family/brightside-demo/)   read only\n//   • Mail      ONE demo mailbox (Nextcloud Mail app, account/mailbox ids below; the Mail guard refuses every other account)  read only\n//   • Deck      board 3 «Brightside Studio (Demo)», stacks 10 Inbox · 11 Do today · 12 In progress · 13 Waiting for · 14 Done\n//   • Folder    /Brightside-Demo/  (Projects/*.md are read; ONLY Outbox/ is written)\n//   • Talk      room YOUR-TALK-TOKEN «Brightside Demo - Team»\n// Everything in these spaces is fake data of the fictional «Brightside Studio». Visitors of the public website trigger this workflow,\n// so it must never touch any other calendar, board, folder or conversation. Every URL used by the HTTP nodes comes from here and the\n// guard below stops the run if one points anywhere else. The visitor only chooses WHO is away, for HOW LONG and the comic STYLE.\nconst NC = 'https://cloud.example.com';\nconst USER = 'your-user';\nconst CAL = `${NC}/remote.php/dav/calendars/${USER}/brightside-demo/`;\nconst FILES = `${NC}/remote.php/dav/files/${USER}/Brightside-Demo/`;\nconst OUTBOX = `${FILES}Outbox/`;\nconst PROJECTS = `${FILES}Projects/`;\nconst DECK_BOARD = 3, DECK_STACK = 10;\nconst DECK = `${NC}/index.php/apps/deck/api/v1.0/boards/${DECK_BOARD}`;\nconst TALK_TOKEN = 'YOUR-TALK-TOKEN';\nconst TALK = `${NC}/ocs/v2.php/apps/spreed/api/v1/chat/${TALK_TOKEN}`;\n// Nextcloud Mail (app v5): ONE dedicated demo mailbox (the placeholders are replaced once at import). The Mail guard node refuses any\n// other account, and the page is public, so the owner's real mailbox can never be read from here.\nconst MAIL_ACCOUNT = Number('1'), MAIL_MAILBOX = Number('1');\nconst MAIL_ADDRESS = 'your-inbox@example.com';\nif (!Number.isInteger(MAIL_ACCOUNT) || MAIL_ACCOUNT < 1 || !Number.isInteger(MAIL_MAILBOX) || MAIL_MAILBOX < 1 || !/^[^@\\s]+@[^@\\s]+$/.test(MAIL_ADDRESS)) throw new Error('mail_not_configured');\nconst MAIL_API = `${NC}/index.php/apps/mail/api`;\nconst MAIL_ACCOUNT_URL = `${MAIL_API}/mailboxes?accountId=${MAIL_ACCOUNT}`;\nconst MAIL_MESSAGES_URL = `${MAIL_API}/messages?mailboxId=${MAIL_MAILBOX}&limit=50&view=singleton`;\nconst DEMO_ANCHOR = false;      // demo: the newest mail counts as 3 h old, so the demo never goes stale; false = real ages\n\nconst ALLOWED = [CAL, FILES, DECK + '/', TALK + '?', TALK + '/'];\nconst ok = u => String(u) === TALK || String(u) === DECK || String(u) === MAIL_ACCOUNT_URL || String(u) === MAIL_MESSAGES_URL || ALLOWED.some(p => String(u).startsWith(p));\n\nconst req = ($('Visitor button (Webhook)').first().json.body) || {};\nif (!/^[a-z0-9]{10}$/.test(String(req.id || ''))) throw new Error('bad id');\nconst lang = ['de', 'en', 'it'].includes(req.lang) ? req.lang : 'en';\nconst WHO = ['Maya', 'Tom', 'Priya', 'Leo', 'Sam', 'Nora'];\nconst who = WHO.includes(req.who) ? req.who : 'Maya';\nconst days = [3, 7, 14].includes(Number(req.days)) ? Number(req.days) : 7;\nconst style = ['noir', 'space', 'pirate', 'kids'].includes(req.style) ? req.style : 'noir';\n\nconst TZ = 'Europe/Berlin';\nconst parts = ts => { const o = {}; for (const p of new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',\n  hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(ts))) o[p.type] = p.value; return o; };\nconst offset = ts => { const p = parts(ts); return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - Math.floor(ts / 1000) * 1000; };\nconst local = (y, m, d, h, mi) => { const g = Date.UTC(y, m - 1, d, h, mi); let t = g - offset(g); t = g - offset(t); return t; };\nconst now = Date.now();\nconst p = parts(now);\nconst iso = t => new Date(t).toISOString().replace(/\\.\\d{3}Z$/, 'Z');\nconst ical = t => iso(t).replace(/[-:]/g, '');\nconst ymd = t => { const q = parts(t); return `${q.year}-${q.month}-${q.day}`; };\nconst D0 = local(+p.year, +p.month, +p.day, 0, 0);\n// the person leaves tomorrow evening: absent from tomorrow for `days` days; first day back = the day after the last absent day\nconst start = local(+p.year, +p.month, +p.day + 1, 0, 0), end = local(+p.year, +p.month, +p.day + 1 + days, 0, 0);\n\nconst urls = {\n  cal: CAL, mail_account: MAIL_ACCOUNT_URL, mail_messages: MAIL_MESSAGES_URL, projects: ['fernhill-dental.md', 'kite-mountain-launch.md', 'orchard-bakery-billing.md'].map(f => PROJECTS + f),\n  outbox: OUTBOX, deck_stacks: `${DECK}/stacks`, deck_cards: `${DECK}/stacks/${DECK_STACK}/cards`, deck_base: DECK,\n  talk_get: `${TALK}?lookIntoFuture=0&limit=60&setReadMarker=0`, talk_post: TALK, talk_base: TALK,\n};\nfor (const [k, u] of Object.entries(urls)) for (const x of [].concat(u)) if (!ok(x)) throw new Error('URL outside the demo space: ' + k);\n\nconst report = '<?xml version=\"1.0\" encoding=\"utf-8\"?>'\n  + '<c:calendar-query xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:prop><d:getetag/>'\n  + `<c:calendar-data><c:expand start=\"${ical(start)}\" end=\"${ical(end)}\"/></c:calendar-data></d:prop>`\n  + `<c:filter><c:comp-filter name=\"VCALENDAR\"><c:comp-filter name=\"VEVENT\"><c:time-range start=\"${ical(start)}\" end=\"${ical(end)}\"/>`\n  + '</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';\nconst propfind = '<?xml version=\"1.0\" encoding=\"utf-8\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:getlastmodified/><d:getcontentlength/>'\n  + '<d:getcontenttype/><d:resourcetype/></d:prop></d:propfind>';\n\nreturn [{ json: { id: req.id, lang, who, days, style, now: iso(now), today: ymd(now), from: ymd(start), to: ymd(end - 3600000),\n  back: ymd(end + 3600000), range: [iso(start), iso(end)], urls, allowed: ALLOWED, talk: TALK, deck_board: DECK_BOARD, deck_stack: DECK_STACK,\n  mail: { account: MAIL_ACCOUNT, mailbox: MAIL_MAILBOX, address: MAIL_ADDRESS, anchor: DEMO_ANCHOR },\n  report, propfind } }];"
   },
   "id": "c0",
   "name": "Fix the demo space",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    220,
    400
   ]
  },
  {
   "parameters": {
    "method": "GET",
    "url": "={{ $('Fix the demo space').first().json.urls.mail_account }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "OCS-APIRequest",
       "value": "true"
      },
      {
       "name": "Accept",
       "value": "application/json"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    }
   },
   "id": "r0",
   "name": "Read: mail account",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    440,
    400
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// Mail guard: runs BEFORE any message is requested. The account answered by the Mail app must be exactly the demo account\n// (id AND e-mail address) and the configured mailbox must belong to it. Anything else stops the workflow: the page is public, so the\n// owner's real mailbox must never be readable through it. Only booleans go on (no address leaves this node).\nconst cfg = $('Fix the demo space').first().json;\nconst r = $('Read: mail account').first().json;\nif (Number(r.statusCode) !== 200) throw new Error('mail_account_unreadable');\nlet j;\ntry { j = JSON.parse(typeof r.data === 'string' ? r.data : (typeof r.body === 'string' ? r.body : JSON.stringify(r.data || r.body))); } catch (e) { throw new Error('mail_account_bad_json'); }\nif (!j || Number(j.id) !== cfg.mail.account) throw new Error('mail_account_refused');\nif (String(j.email || '').toLowerCase() !== String(cfg.mail.address).toLowerCase()) throw new Error('mail_account_refused');\nconst box = (Array.isArray(j.mailboxes) ? j.mailboxes : []).find(b => b && Number(b.databaseId) === cfg.mail.mailbox);\nif (!box || Number(box.accountId) !== cfg.mail.account) throw new Error('mail_mailbox_refused');\nreturn [{ json: Object.assign({}, cfg, { mail_ok: true, mailbox_name: String(box.name || '').slice(0, 40) }) }];"
   },
   "id": "cg",
   "name": "Mail guard",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    640,
    400
   ]
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// Project notes: one item per demo note file (URLs were checked in \"Fix the demo space\").\nconst cfg = $('Fix the demo space').first().json;\nreturn cfg.urls.projects.filter(u => cfg.allowed.some(p => u.startsWith(p))).map(url => ({ json: { url } }));"
   },
   "id": "c6",
   "name": "Projects: file list",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    840,
    740
   ]
  },
  {
   "parameters": {
    "method": "REPORT",
    "url": "={{ $('Fix the demo space').first().json.urls.cal }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "Depth",
       "value": "1"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    },
    "sendBody": true,
    "contentType": "raw",
    "rawContentType": "application/xml",
    "body": "={{ $('Fix the demo space').first().json.report }}"
   },
   "id": "r1",
   "name": "Calendar: events in the absence window",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    860,
    0
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  },
  {
   "parameters": {
    "method": "GET",
    "url": "={{ $('Fix the demo space').first().json.urls.deck_stacks }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "OCS-APIRequest",
       "value": "true"
      },
      {
       "name": "Accept",
       "value": "application/json"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    }
   },
   "id": "r2",
   "name": "Deck: read board",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    860,
    160
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  },
  {
   "parameters": {
    "method": "GET",
    "url": "={{ $('Fix the demo space').first().json.urls.mail_messages }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "OCS-APIRequest",
       "value": "true"
      },
      {
       "name": "Accept",
       "value": "application/json"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    }
   },
   "id": "r3",
   "name": "Read: mail messages",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    860,
    320
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  },
  {
   "parameters": {
    "method": "GET",
    "url": "={{ $json.url }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "OCS-APIRequest",
       "value": "true"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    }
   },
   "id": "r4",
   "name": "Files: read project notes",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    860,
    740
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  },
  {
   "parameters": {
    "method": "PROPFIND",
    "url": "={{ $('Fix the demo space').first().json.urls.outbox }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "Depth",
       "value": "1"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    },
    "sendBody": true,
    "contentType": "raw",
    "rawContentType": "application/xml",
    "body": "={{ $('Fix the demo space').first().json.propfind }}"
   },
   "id": "r5",
   "name": "Files: list Outbox",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    860,
    480
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  },
  {
   "parameters": {
    "method": "GET",
    "url": "={{ $('Fix the demo space').first().json.urls.talk_get }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "OCS-APIRequest",
       "value": "true"
      },
      {
       "name": "Accept",
       "value": "application/json"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    }
   },
   "id": "r6",
   "name": "Talk: read chat",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    860,
    620
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  },
  {
   "parameters": {
    "numberInputs": 6
   },
   "id": "m1",
   "name": "Collect everything",
   "type": "n8n-nodes-base.merge",
   "typeVersion": 3,
   "position": [
    1080,
    400
   ]
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// ANALYSE THE ABSENCE: reads the answers from the DEMO spaces only, keeps a strict whitelist of fields (titles, times, first names of\n// the fictional team; no user ids, no real names), works out what falls into the absence window and who covers it, plans the cleanup\n// (previous handover cards, old Outbox files, old handover messages in the demo Talk room) and writes the prompt for the AI.\n// Everything that matters is computed here deterministically; the AI only phrases it (and writes the comic).\nconst cfg = $('Fix the demo space').first().json;\nif ($('Mail guard').first().json.mail_ok !== true) throw new Error('mail_mailbox_refused');\nconst pick = n => { try { return $(n).first().json; } catch (e) { return null; } };\nconst pickAll = n => { try { return $(n).all().map(i => i.json); } catch (e) { return []; } };\nconst txt = r => r ? (typeof r.data === 'string' ? r.data : typeof r.body === 'string' ? r.body : (r.data && typeof r.data === 'object' ? JSON.stringify(r.data) : '')) : '';\nconst okr = r => r && Number(r.statusCode) >= 200 && Number(r.statusCode) < 300;\nconst inDemo = u => String(u) === cfg.talk || cfg.allowed.some(p => String(u).startsWith(p));\nconst NCH = cfg.urls.cal.match(/^https?:\\/\\/[^\\/]+/)[0];\nconst now = Date.parse(cfg.now);\nconst clean = (s, n) => String(s == null ? '' : s).replace(/[\\u0000-\\u001f]+/g, ' ').replace(/[<>]/g, '').replace(/\\s+/g, ' ').trim().slice(0, n);\n\nconst TZ = 'Europe/Berlin';\nconst parts = ts => { const o = {}; for (const p of new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',\n  hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', weekday: 'short' }).formatToParts(new Date(ts))) o[p.type] = p.value; return o; };\nconst offset = ts => { const p = parts(ts); return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - Math.floor(ts / 1000) * 1000; };\nconst local = (y, m, d, h, mi) => { const g = Date.UTC(y, m - 1, d, h, mi); let t = g - offset(g); t = g - offset(t); return t; };\nconst ymd = t => { const q = parts(t); return `${q.year}-${q.month}-${q.day}`; };\nconst hm = t => { const q = parts(t); return `${q.hour}:${q.minute}`; };\nconst dayLabel = t => new Intl.DateTimeFormat('en-GB', { timeZone: TZ, weekday: 'short', day: 'numeric', month: 'short' }).format(new Date(t));\nconst P0 = parts(now);\nconst at = (dd, h, mi) => local(+P0.year, +P0.month, +P0.day + dd, h, mi);\nconst xmlDec = s => String(s).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '\"').replace(/&#039;|&apos;/g, \"'\")\n  .replace(/&#13;|&#x0?D;/gi, '').replace(/&#(\\d+);/g, (_, n) => String.fromCharCode(+n)).replace(/&amp;/g, '&');\n\nconst TEAM = {\n  Maya: { full: 'Maya Reyes', role: 'operations lead', buddy: 'Priya' }, Tom: { full: 'Tom Becker', role: 'designer', buddy: 'Nora' },\n  Priya: { full: 'Priya Nair', role: 'account manager', buddy: 'Maya' }, Leo: { full: 'Leo Hartmann', role: 'developer', buddy: 'Tom' },\n  Sam: { full: 'Sam Okafor', role: 'finance', buddy: 'Maya' }, Nora: { full: 'Nora Wells', role: 'intern', buddy: 'Tom' },\n};\nconst first = cfg.who, me = TEAM[first], buddy = me.buddy;\nconst finance = first === 'Sam' ? me.buddy : 'Sam';\nconst EVENT_OWNER = [[/kite/i, 'Priya'], [/fernhill/i, 'Priya'], [/critique/i, 'Tom'], [/payroll/i, 'Sam']];\n\n// ---- WebDAV multistatus\nconst responses = xml => {\n  const out = [];\n  for (const m of String(xml).matchAll(/<(?:\\w+:)?response\\b[\\s\\S]*?<\\/(?:\\w+:)?response>/gi)) {\n    const b = m[0];\n    const href = xmlDec(((b.match(/<(?:\\w+:)?href>([\\s\\S]*?)<\\/(?:\\w+:)?href>/i) || [])[1] || '').trim());\n    const prop = n => { const r = b.match(new RegExp('<(?:\\\\w+:)?' + n + '(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)<\\\\/(?:\\\\w+:)?' + n + '>', 'i')); return r ? xmlDec(r[1]) : ''; };\n    out.push({ href, b, prop, dir: /<(?:\\w+:)?collection\\s*\\/>/i.test(b) });\n  }\n  return out;\n};\n\n// ---- calendar (the server expands recurring events with c:expand; a fallback expands the simple rules of the demo if it does not)\nconst icsDate = v => {\n  const m = String(v).match(/^(\\d{4})(\\d{2})(\\d{2})(?:T(\\d{2})(\\d{2})(\\d{2})?(Z)?)?$/);\n  if (!m) return null;\n  if (!m[4]) return local(+m[1], +m[2], +m[3], 0, 0);\n  if (m[7]) return Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0));\n  return local(+m[1], +m[2], +m[3], +m[4], +m[5]);\n};\nconst vevents = ics => {\n  const lines = String(ics).replace(/\\r\\n[ \\t]/g, '').replace(/\\n[ \\t]/g, '').split(/\\r?\\n/);\n  const evs = []; let cur = null;\n  for (const l of lines) {\n    if (l === 'BEGIN:VEVENT') cur = {};\n    else if (l === 'END:VEVENT') { if (cur) evs.push(cur); cur = null; }\n    else if (cur) { const i = l.indexOf(':'); if (i > 0) { const k = l.slice(0, i).split(';')[0].toUpperCase(); if (!(k in cur)) cur[k] = l.slice(i + 1); } }\n  }\n  return evs;\n};\nconst unesc = s => String(s || '').replace(/\\\\n/gi, ' ').replace(/\\\\([,;\\\\])/g, '$1');\nconst R0 = Date.parse(cfg.range[0]), R1 = Date.parse(cfg.range[1]);\nconst DOW = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];\nconst dowOf = t => DOW[new Date(Date.UTC(+parts(t).year, +parts(t).month - 1, +parts(t).day)).getUTCDay()];\nconst expand = (s, dur, rr) => {                                // fallback: DAILY / WEEKLY BYDAY / MONTHLY BYMONTHDAY / MONTHLY BYDAY=nXX\n  const freq = (rr.match(/FREQ=(\\w+)/) || [])[1], by = (rr.match(/BYDAY=([-\\dA-Z,]+)/) || [])[1], bmd = (rr.match(/BYMONTHDAY=(\\d+)/) || [])[1];\n  const sp = parts(s), out = [];\n  const days = Math.round((R1 - R0) / 86400000) + 2;\n  const p0 = parts(R0);\n  for (let d = -1; d < days; d++) {\n    const dayStart = local(+p0.year, +p0.month, +p0.day + d, 0, 0);\n    const q = parts(dayStart), dom = +q.day, code = dowOf(dayStart);\n    let hit = false;\n    if (freq === 'DAILY') hit = true;\n    else if (freq === 'WEEKLY') hit = !by || by.split(',').includes(code);\n    else if (freq === 'MONTHLY' && bmd) hit = dom === +bmd;\n    else if (freq === 'MONTHLY' && by) hit = by.split(',').some(x => { const m = x.match(/^(-?\\d)?([A-Z]{2})$/); return m && m[2] === code && (!m[1] || Math.ceil(dom / 7) === +m[1]); });\n    if (!hit) continue;\n    const st = local(+q.year, +q.month, +q.day, +sp.hour, +sp.minute);\n    if (st >= s) out.push({ s: st, e: st + dur });\n  }\n  return out;\n};\nconst occ = [];\nconst calR = pick('Calendar: events in the absence window');\nif (okr(calR)) {\n  for (const r of responses(txt(calR))) {\n    if (!inDemo(NCH + r.href) && !inDemo(r.href)) continue;\n    for (const e of vevents(r.prop('calendar-data'))) {\n      const s = icsDate(e.DTSTART), en = icsDate(e.DTEND) || (s + 3600000);\n      if (s == null) continue;\n      const base = { uid: String(e.UID || ''), t: clean(unesc(e.SUMMARY), 80), where: clean(unesc(e.LOCATION), 40) };\n      if (e.RRULE && !e['RECURRENCE-ID']) { for (const o of expand(s, en - s, String(e.RRULE))) if (o.e > R0 && o.s < R1) occ.push({ ...base, ...o }); }\n      else if (en > R0 && s < R1) occ.push({ ...base, s, e: en });\n    }\n  }\n}\nocc.sort((a, b) => a.s - b.s);\nconst seen = new Set(); const occU = occ.filter(o => { const k = o.uid + o.s; if (seen.has(k)) return false; seen.add(k); return !!o.t; });\nconst groups = new Map();\nfor (const o of occU) { const g = groups.get(o.t) || { t: o.t, n: 0, where: o.where, s0: o.s, e0: o.e }; g.n++; groups.set(o.t, g); }\nconst events = [...groups.values()].sort((a, b) => a.s0 - b.s0).slice(0, 8).map(g => {\n  const ow = (EVENT_OWNER.find(([re]) => re.test(g.t)) || [])[1] || null;\n  return { t: g.t, n: g.n, first: `${dayLabel(g.s0)} ${hm(g.s0)}`, where: g.where, owner: ow, ritual: !ow };\n});\n\n// ---- Deck: cards of the person (description line owner:<Name>), due dates relative to today via demo-due:<days>\nconst stacksR = pick('Deck: read board');\nlet stacks = [];\ntry { const j = JSON.parse(txt(stacksR)); stacks = (j && j.ocs ? j.ocs.data : j) || []; if (!Array.isArray(stacks)) stacks = []; } catch (e) { stacks = []; }\nconst allCards = []; const prevHandover = [];\nfor (const st of stacks) for (const c of (st.cards || [])) {\n  const desc = String(c.description || ''), title = String(c.title || '');\n  if (/^Handover demo:/.test(title) && /handover-demo/.test(desc)) { prevHandover.push({ id: c.id, stack: c.stackId || st.id }); continue; }\n  const ow = (desc.match(/owner:(\\w+)/) || [])[1] || '';\n  const dd = desc.match(/demo-due:(-?\\d+)/);\n  allCards.push({ t: clean(title, 80), stack: clean(st.title, 24), owner: ow, off: dd ? +dd[1] : null, done: /^Done$/i.test(st.title),\n    labels: (c.labels || []).map(l => clean(l.title, 20)).filter(Boolean).slice(0, 3) });\n}\nconst mine = allCards.filter(c => c.owner === first && !c.done);\nconst dayAt = n => ymd(at(n, 12, 0));\nconst cards = mine.map(c => {\n  const when = c.off == null ? 'none' : c.off <= 0 ? 'before' : c.off <= cfg.days ? 'in' : 'after';\n  const to = c.labels.includes('Finance') ? finance : buddy;\n  return { t: c.t, stack: c.stack, labels: c.labels, due: c.off == null ? null : dayAt(c.off), off: c.off, when, to };\n}).sort((a, b) => (a.off == null ? 99 : a.off) - (b.off == null ? 99 : b.off));\n\n// ---- mail (Nextcloud Mail app): the messages of the dedicated demo mailbox, IMAP flags kept as signals.\n// Unanswered = no IMAP flag answered/forwarded and not written by the mailbox owner. In demo mode the newest mail counts as 3 h old,\n// so the demo never goes stale. Only whitelisted fields are kept (sender label, subject, preview): no addresses leave the node.\nlet mailsAll = [];\nconst inR = pick('Read: mail messages');\nconst cleanM = (s, n) => String(s == null ? '' : s).replace(/[\\u0000-\\u001f]+/g, ' ').replace(/\\s+/g, ' ').trim().slice(0, n);\ntry {\n  const raw = JSON.parse(txt(inR));\n  const list = (Array.isArray(raw) ? raw : []).filter(x => x && typeof x === 'object');\n  const owner = String(cfg.mail.address).toLowerCase();\n  const rows = [];\n  for (const x of list) {\n    const f = x.flags || {};\n    if (Number(x.mailboxId) !== Number(cfg.mail.mailbox)) continue;\n    if (f.deleted === true || f.draft === true || f['$junk'] === true) continue;\n    const fr = Array.isArray(x.from) && x.from[0] ? x.from[0] : {};\n    const email = cleanM(fr.email, 80);\n    if (email.toLowerCase() === owner) continue;\n    let t = Number(x.dateInt) || 0; if (t < 1e11) t *= 1000;\n    rows.push({ t, from_name: cleanM(fr.label || email.split('@')[0], 40), from_email: email, subject: cleanM(x.subject, 90) || '(no subject)',\n      body: cleanM(x.previewText, 400), answered: f.answered === true || f.forwarded === true, seen: f.seen === true, flagged: f.flagged === true });\n  }\n  rows.sort((a, b) => b.t - a.t);\n  const top = rows.slice(0, 30);\n  const shift = cfg.mail.anchor && top.length ? (now - top[0].t) - 3 * 3600000 : 0;\n  mailsAll = top.map(r => ({ ...r, age_hours: Math.max(0, Math.round((now - r.t - shift) / 3600000)) }));\n} catch (e) { mailsAll = []; }\nconst NOISE = /(digest|news@|proleads|talentbridge|no-reply@|calendar\\.example)/i;\nconst mentions = m => new RegExp('\\\\b' + first + '\\\\b', 'i').test(m.from_name + ' ' + m.subject + ' ' + m.body);\nconst relevant = mailsAll.filter(m => !m.answered && !NOISE.test(m.from_email + ' ' + m.subject)).filter(m => first === 'Maya' ? true : mentions(m));\nconst mails = relevant.map(m => ({ from: clean(m.from_name, 40), subject: clean(m.subject, 90), age_h: Math.max(0, Math.round(+m.age_hours || 0)),\n  st: /urgent|today|expires|within \\d+ days|before the \\d+th/i.test(m.subject + ' ' + m.body) ? 'urgent' : (+m.age_hours > 48 ? 'waiting' : 'new') }))\n  .sort((a, b) => { const sc = m => /urgent/i.test(m.subject) ? 0 : m.st === 'urgent' ? 1 : 2; return sc(a) - sc(b) || (sc(a) < 2 ? a.age_h - b.age_h : b.age_h - a.age_h); }).slice(0, 6);\nconst waiting = relevant.filter(m => +m.age_hours > 48).length;\n\n// ---- project notes (context for the AI)\nconst projects = [];\nfor (const r of pickAll('Files: read project notes')) {\n  const t = String(txt(r)); if (!okr(r) || !t) continue;\n  const name = (t.match(/^#\\s*(.+)$/m) || [])[1] || 'Project';\n  projects.push({ name: clean(name, 60), brief: clean(t.replace(/^#.*$/m, '').replace(/_Fictional[^_]*_/g, ''), 260) });\n}\n\n// ---- risks and covers (deterministic)\nconst risks = [], covers = [];\nconst before = cards.filter(c => c.when === 'before'), inwin = cards.filter(c => c.when === 'in');\nif (before.length) risks.push({ t: `${before.length} open card${before.length > 1 ? 's' : ''} overdue or due today: \"${before[0].t}\"`, ifthen: `Close it before you leave or hand it to ${buddy} with a note on the card.`, kind: 'overdue' });\nfor (const c of inwin.slice(0, 2)) risks.push({ t: `\"${c.t}\" is due on ${dayLabel(at(c.off, 12, 0))}`, ifthen: `${c.to} delivers it; you are only asked if ${c.to} is blocked.`, kind: /invoice|payroll|expense|billing/i.test(c.t) ? 'invoice' : /certificate|server|staging/i.test(c.t) ? 'server' : /fernhill/i.test(c.t) ? 'tooth' : 'clock' });\nconst pay = events.find(e => /payroll/i.test(e.t));\nif (pay && (first === 'Maya' || first === 'Sam')) risks.push({ t: `Payroll cut-off falls inside the absence (${pay.first})`, ifthen: `${first === 'Sam' ? buddy : finance} needs the expense OK from ${first === 'Sam' ? 'you' : buddy} the day before.`, kind: 'invoice' });\nconst urgentMail = mails.find(m => m.st === 'urgent');\nif (urgentMail) risks.push({ t: `Urgent mail from ${urgentMail.from}: \"${urgentMail.subject}\"`, ifthen: `${buddy} answers today and tells ${urgentMail.from} who is covering.`, kind: /hours|website|site/i.test(urgentMail.subject) ? 'tooth' : /certificate|expires/i.test(urgentMail.subject) ? 'server' : 'fire' });\nif (waiting > 0) risks.push({ t: `${waiting} mail${waiting > 1 ? 's are' : ' is'} waiting for more than 48 hours`, ifthen: `Forward them to ${buddy} and switch on the auto-reply.`, kind: 'mail' });\nconst owned = events.filter(e => e.owner && e.owner !== first);\nfor (const e of events.filter(e => e.owner === first || (!e.ritual && e.owner === me.buddy && first === 'Maya'))) risks.push({ t: `\"${e.t}\" happens ${e.n}× while you are away`, ifthen: `${buddy} runs it and writes the notes into Deck.`, kind: 'calendar' });\nconst FILL = [{ t: 'A client escalates while you are offline', ifthen: `${buddy} calls back within two hours and pings you only for decisions above budget.`, kind: 'fire' },\n  { t: 'Nobody knows where the latest files are', ifthen: 'Everything lives in the shared Brightside folder; the handover note links the right notes.', kind: 'mail' },\n  { t: 'A deadline moves while you are away', ifthen: `${buddy} moves the card and tells the client the same day.`, kind: 'clock' }];\nfor (const f of FILL) { if (risks.length >= 3) break; risks.push(f); }\nconst PRI = { tooth: 0, invoice: 1, server: 2, fire: 3, calendar: 4, clock: 5, overdue: 6, mail: 7 };\nrisks.sort((a, b) => (PRI[a.kind] ?? 9) - (PRI[b.kind] ?? 9));\nfor (const c of cards.filter(c => c.when !== 'after').slice(0, 4)) covers.push({ item: c.t, to: c.to, why: c.when === 'before' ? 'overdue: needs a decision before you leave' : c.when === 'in' ? `due ${dayLabel(at(c.off, 12, 0))}` : 'no due date' });\nfor (const e of events.filter(e => e.owner && e.owner !== first).slice(0, 2)) covers.push({ item: `${e.t} (${e.n}×)`, to: e.owner, why: 'already theirs; they just need the notes' });\nif (mails.length) covers.push({ item: `${mails.length} open mail${mails.length > 1 ? 's' : ''} (${mails.filter(m => m.st === 'urgent').length} urgent)`, to: buddy, why: 'auto-reply points to them' });\nconst stats = { events: occU.length, event_types: events.length, cards: cards.length, due_in_window: inwin.length, overdue: before.length, mails_open: relevant.length, mails_waiting: waiting };\n\n// ---- cleanup plan (only items this workflow created, only inside the demo spaces)\nconst cleanup = [];\nfor (const c of prevHandover) cleanup.push({ kind: 'card', url: `${cfg.urls.deck_base}/stacks/${+c.stack}/cards/${+c.id}` });\nconst outR = pick('Files: list Outbox');\nif (okr(outR)) {\n  const files = responses(txt(outR)).filter(r => !r.dir).map(r => ({ href: r.href, name: decodeURIComponent(r.href.split('/').pop() || ''), m: Date.parse(r.prop('getlastmodified')) || 0 }))\n    .filter(f => /^Handover-[A-Za-z]+-[0-9T_-]+\\.md$/.test(f.name)).sort((a, b) => b.m - a.m);\n  for (const f of files.slice(19)) cleanup.push({ kind: 'file', url: cfg.urls.outbox + encodeURIComponent(f.name) });\n}\nconst talkR = pick('Talk: read chat');\nif (okr(talkR)) {\n  let msgs = []; try { const j = JSON.parse(txt(talkR)); msgs = (j.ocs && j.ocs.data) || []; } catch (e) { msgs = []; }\n  const mine2 = msgs.filter(m => /^🏖 Handover \\(demo\\)/.test(String(m.message || '')) && m.id).sort((a, b) => b.id - a.id);\n  for (const m of mine2.slice(4)) cleanup.push({ kind: 'msg', url: `${cfg.urls.talk_base}/${+m.id}` });\n}\nfor (const c of cleanup) if (!inDemo(c.url)) throw new Error('cleanup URL outside the demo space');\n\n// ---- prompt for the AI (it phrases; every fact comes from above)\nconst LANGN = { de: 'German', en: 'English', it: 'Italian' }[cfg.lang];\nconst STYLE = {\n  noir: 'hard-boiled detective narration (rain, coffee, a ringing phone), dry humour',\n  space: 'a space captain\\'s log: the office is a starship, mail is incoming transmissions, deadlines are asteroid fields',\n  pirate: 'pirate speech: the inbox is a sea, deadlines are storms, the buddy is the first mate',\n  kids: 'a warm children\\'s picture book in simple short sentences, gentle humour, light rhyme where it comes naturally',\n}[cfg.style];\nconst VOCAB = ['suitcase', 'beach', 'palm', 'laptop', 'calendar', 'mail', 'coffee', 'fire', 'clock', 'phone', 'invoice', 'cake', 'bike', 'tooth', 'mountain', 'server', 'checkmark', 'ghost', 'chat', 'rocket', 'plane', 'key', 'star'];\nconst facts = { person: { name: me.full, first, role: me.role, buddy, finance_buddy: finance }, window: { leaves: cfg.from, last_day: cfg.to, back: cfg.back, days: cfg.days },\n  covers, risks: risks.slice(0, 5).map(r => ({ risk: r.t, if_then: r.ifthen })), events: events.map(e => ({ title: e.t, times: e.n, first: e.first, owner: e.owner })),\n  cards: cards.map(c => ({ title: c.t, due: c.due, when: c.when, covered_by: c.to })), mails: mails.map(m => ({ from: m.from, subject: m.subject, hours: m.age_h, state: m.st })),\n  projects, stats };\nconst body = {\n  model: 'gpt-4o-mini', max_tokens: 3000, temperature: 0.8,\n  messages: [\n    { role: 'system', content: 'You write the handover for a person going on holiday at Brightside Studio, a FICTIONAL 6-person design studio. Use ONLY the facts given; never invent clients, numbers or names. Answer with ONE JSON object, no markdown, no code fences. Keys: '\n      + '\"summary\" (2 sentences, English), \"covers\" (up to 5 of {\"item\",\"to\",\"why\"}, English, only names from the facts), \"risks\" (up to 4 of {\"risk\",\"if_then\"}, English), \"auto_reply\" (an out-of-office e-mail in English, 3-4 sentences, names the buddy and the return date, no phone numbers), '\n      + '\"talk\" (a 2-line message for the team chat, English, may use one emoji), \"back\" (3 short first-day-back actions, English), and \"panels\": EXACTLY 5 comic panels about the absence in the story order (1 leaving, 2 the inbox piles up, 3 the biggest risk strikes, 4 the buddy saves the day, 5 coming back), each {\"title\" (max 4 words), \"caption\" (max 110 chars), \"bubble\" (max 60 chars, a line of speech or empty), \"scene\" (1-3 words ONLY from this list: ' + VOCAB.join(', ') + '), \"mood\" (calm, panic, joy or sleep)}. '\n      + `Write panel titles, captions and bubbles in ${LANGN}, in the voice of ${STYLE}. Keep it funny but kind; no real brands.` },\n    { role: 'user', content: JSON.stringify(facts) },\n  ],\n};\nconst read_ok = { cal: !!okr(calR), deck: !!okr(stacksR) && stacks.length > 0, inbox: !!okr(inR) && mailsAll.length > 0, projects: projects.length > 0, outbox: !!okr(outR), talk: !!okr(talkR) };\nconst state = { id: cfg.id, lang: cfg.lang, style: cfg.style, days: cfg.days, who: { first, full: me.full, role: me.role, buddy, finance }, now: cfg.now, today: cfg.today,\n  window: { from: cfg.from, to: cfg.to, back: cfg.back, days: cfg.days, from_label: dayLabel(at(1, 12, 0)), back_label: dayLabel(at(cfg.days + 1, 12, 0)) },\n  events, cards, mails, risks: risks.slice(0, 5).map(r => ({ t: r.t, ifthen: r.ifthen, kind: r.kind })), covers: covers.slice(0, 6), stats, projects: projects.map(p => p.name), read_ok };\nreturn [{ json: { state, cleanup, body, vocab: VOCAB } }];"
   },
   "id": "c1",
   "name": "Analyse the absence",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    1300,
    400
   ]
  },
  {
   "parameters": {
    "method": "POST",
    "url": "https://openai.inference.de-txl.ionos.com/v1/chat/completions",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpHeaderAuth",
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify($json.body) }}",
    "options": {
     "timeout": 90000
    }
   },
   "id": "a1",
   "name": "Assistant writes (AI)",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    1520,
    400
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpHeaderAuth": {
     "id": "",
     "name": "YOUR OpenAI-compatible API key (Header Auth: Authorization = Bearer …)"
    }
   }
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// CHECK AND COMPOSE: reads the AI answer field by field (every field that is missing or invalid is replaced by a template built from\n// the same facts, so the workflow also works without the AI), then prepares the three writes into the demo spaces:\n// Outbox/Handover-<Name>-<stamp>.md, the Deck card «Handover demo: …» (stack Inbox) and the team-chat message in the demo Talk room.\nconst A = $('Analyse the absence').first().json;\nconst S = A.state, VOCAB = A.vocab;\nconst cfg = $('Fix the demo space').first().json;\nconst clean = (s, n) => String(s == null ? '' : s).replace(/[*_#`>\\[\\]<]/g, '').replace(/[\\u0000-\\u001f]+/g, ' ').replace(/\\s+/g, ' ').trim().slice(0, n);\nlet j = {};\ntry { const m = String($input.first().json.choices[0].message.content).match(/\\{[\\s\\S]*\\}/); j = JSON.parse(m[0]); } catch (e) { j = {}; }\nconst W = S.who, first = W.first, buddy = W.buddy, n = S.window.days;\nconst top = S.risks[0] || { t: 'a deadline', kind: 'fire' };\nconst MOODS = ['calm', 'panic', 'joy', 'sleep'];\n\n// ---- templates (English; used when the AI answer is missing or a field is invalid)\nconst summary = `${W.full} is away from ${S.window.from_label} for ${n} days and back on ${S.window.back_label}. ${buddy} covers ${S.covers.length} item${S.covers.length === 1 ? '' : 's'}; ${S.stats.mails_open} mail${S.stats.mails_open === 1 ? ' is' : 's are'} open and ${S.stats.events} team meeting${S.stats.events === 1 ? '' : 's'} fall into the window.`;\nconst autoReply = `Hi, thanks for your message. I am out of office from ${S.window.from_label} and back on ${S.window.back_label}. For anything urgent please write to ${buddy}, who is covering for me. I will answer everything else when I am back.\\nBest, ${first}`;\nconst talkText = `${first} is away ${S.window.from_label} to ${S.window.back_label}. ${buddy} covers; ${S.stats.due_in_window + S.stats.overdue} card${S.stats.due_in_window + S.stats.overdue === 1 ? '' : 's'} need attention, notes are in Deck. Have a great break!`;\nconst back = ['Read the handover card and the Outbox note first', `Ask ${buddy} what changed, before opening the inbox`, 'Re-plan the cards that moved while you were away'];\nconst SC = { noir: ['It was a Thursday, and {n} days of silence were about to start.', 'The mail kept coming like rain on a tin roof: {m} of them.', 'Then it hit: {risk}. Trouble always knocks twice.', '{buddy} stepped out of the shadows with the file and a plan.', 'Back at the desk the coffee was still warm. The case was closed.'],\n  space: ['Captain\\'s log: {n} days of leave granted, the ship is in good hands.', 'Incoming transmissions: {m} unread. The inbox drifts quietly.', 'Alert! {risk}. Shields are down.', '{buddy} takes the helm and plots a safe course.', 'Landing complete. The crew is fine and the coffee replicator works.'],\n  pirate: ['Arrr, {n} days ashore! The ship stays with {buddy}.', 'The sea of mails rises: {m} bottles in all.', 'Storm ahead: {risk}! All hands on deck!', '{buddy} grabs the wheel and steers through the waves.', 'Back on board, the treasure chest is full and the rum is cold.'],\n  kids: ['{first} packs a little suitcase for {n} days of rest.', 'While {first} naps, {m} letters come to visit the inbox.', 'Oh no! {risk}. The clock ticks very loudly.', '{buddy} smiles, rolls up their sleeves and fixes it.', '{first} comes home. Everything is tidy. Hooray!'] };\nconst SCENES = [['suitcase', 'calendar'], ['mail', 'clock'], [{ invoice: 'invoice', server: 'server', tooth: 'tooth', calendar: 'calendar', clock: 'clock', mail: 'mail', overdue: 'clock' }[top.kind] || 'fire', 'fire'], ['chat', 'checkmark'], ['coffee', 'cake']];\nconst TITLE = { noir: ['The last case', 'Rain of mail', 'Trouble knocks', 'The cover', 'Case closed'], space: ['Leave granted', 'Incoming', 'Red alert', 'The helm', 'Touchdown'],\n  pirate: ['Shore leave', 'Sea of mails', 'Storm ahead', 'First mate', 'Home port'], kids: ['Off we go', 'Letters come', 'Oh no!', 'The helper', 'Welcome back'] };\nconst MOOD = ['joy', 'calm', 'panic', 'joy', 'joy'];\nconst SHORT = { overdue: 'an overdue card', invoice: 'the invoice query', server: 'an expiring certificate', tooth: 'a website mix-up', calendar: 'a missed meeting', clock: 'a closing deadline', mail: 'a flood of mail', fire: 'a client escalation' };\nconst fill = s => s.replace(/\\{n\\}/g, n).replace(/\\{m\\}/g, S.stats.mails_open).replace(/\\{risk\\}/g, SHORT[top.kind] || 'a client escalation').replace(/\\{buddy\\}/g, buddy).replace(/\\{first\\}/g, first);\nconst fbPanels = SC[S.style].map((c, i) => ({ title: TITLE[S.style][i], caption: clean(fill(c), 110), bubble: i === 3 ? `I've got this, ${first}!` : i === 2 ? 'Not today!' : '', scene: SCENES[i].filter((x, k, a) => a.indexOf(x) === k), mood: MOOD[i] }));\n\n// ---- read the AI fields\nconst arr = v => Array.isArray(v) ? v : [];\nconst NAMES = ['Maya', 'Tom', 'Priya', 'Leo', 'Sam', 'Nora'];\nlet covers = arr(j.covers).map(x => ({ item: clean(x && x.item, 80), to: clean(x && x.to, 20), why: clean(x && x.why, 100) })).filter(x => x.item && NAMES.some(nm => x.to.startsWith(nm))).slice(0, 5);\nconst coversAi = covers.length >= 2;\nif (!coversAi) covers = S.covers.map(c => ({ item: c.item, to: c.to, why: c.why }));\nlet risks = arr(j.risks).map(x => ({ t: clean(x && x.risk, 120), ifthen: clean(x && x.if_then, 160) })).filter(x => x.t && x.ifthen).slice(0, 4);\nconst risksAi = risks.length >= 2;\nif (!risksAi) risks = S.risks.map(r => ({ t: r.t, ifthen: r.ifthen }));\nconst sum = clean(j.summary, 320), summaryOut = sum.length > 30 ? sum : summary;\nconst reply = String(j.auto_reply || '').replace(/[\\u0000-\\u0008\\u000b-\\u001f<>]/g, '').trim().slice(0, 700);\nconst autoOut = reply.length > 60 ? reply : autoReply;\nconst talk = clean(j.talk, 280), talkOut = talk.length > 20 ? talk : talkText;\nlet backOut = arr(j.back).map(x => clean(x, 100)).filter(x => x.length > 5).slice(0, 3); if (backOut.length < 2) backOut = back;\nlet panels = arr(j.panels).map(p => ({ title: clean(p && p.title, 28), caption: clean(p && p.caption, 120), bubble: clean(p && p.bubble, 64),\n  scene: arr(p && p.scene).map(x => String(x).toLowerCase().trim()).filter(x => VOCAB.includes(x)).filter((x, i, a) => a.indexOf(x) === i).slice(0, 3),\n  mood: MOODS.includes(p && p.mood) ? p.mood : 'calm' }));\nconst panelsAi = panels.length === 5 && panels.every(p => p.title && p.caption.length > 8);\nif (!panelsAi) panels = fbPanels;\nelse panels = panels.map((p, i) => ({ ...p, scene: p.scene.length ? p.scene : fbPanels[i].scene }));\nconst ai = coversAi || risksAi || panelsAi || summaryOut !== summary;\n\n// ---- the three writes\nconst P = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' })\n  .formatToParts(new Date(S.now)).reduce((o, p) => (o[p.type] = p.value, o), {});\nconst stamp = `${P.year}${P.month}${P.day}T${P.hour}${P.minute}${P.second}`;\nconst fname = `Handover-${first}-${stamp}.md`;\nconst md = [`# Handover: ${W.full} (${W.role})`, '', `**Away:** ${S.window.from_label} – ${S.window.to} (${n} days) · **Back:** ${S.window.back_label} · **Covering:** ${buddy}${S.who.finance !== buddy ? ' (finance: ' + S.who.finance + ')' : ''}`, '',\n  `> ${summaryOut}`, '', '## Who covers what', ...(covers.length ? covers.map(c => `- ${c.item} → **${c.to}** (${c.why})`) : ['- nothing open']), '',\n  '## Deadlines and open cards', ...(S.cards.length ? S.cards.map(c => `- ${c.t}${c.due ? ' · due ' + c.due : ''} · ${c.stack} → ${c.to}`) : ['- no cards assigned']), '',\n  '## Meetings in the window', ...(S.events.length ? S.events.map(e => `- ${e.t} ×${e.n} (first: ${e.first})${e.owner ? ' · ' + e.owner + ' runs it' : ''}`) : ['- none']), '',\n  '## Mail to watch', ...(S.mails.length ? S.mails.map(m => `- ${m.from}: ${m.subject} (${m.age_h} h, ${m.st})`) : ['- nothing']), '',\n  '## If this happens, do that', ...risks.map(r => `- **${r.t}** → ${r.ifthen}`), '',\n  '## Out-of-office reply', ...autoOut.split(/\\n+/).map(l => '> ' + l), '', '## First day back', ...backOut.map((b, i) => `${i + 1}. ${b}`), '',\n  '---', '_Created by n8n · Vacation Handover (demo) · fictional data of Brightside Studio · https://daniele-michelin.com/en/vacation-handover/_', ''].join('\\n');\nconst card = { title: `Handover demo: ${first} away ${n} days`, type: 'plain', order: 0,\n  description: `${summaryOut}\\n\\n${covers.map(c => `- ${c.item} → ${c.to}`).join('\\n')}\\n\\nBack: ${S.window.back_label}\\n\\n---\\nhandover-demo · created by n8n (demo, fictional data)`,\n  duedate: `${S.window.back}T08:00:00Z` };\nconst msg = `🏖 Handover (demo): ${talkOut}`.slice(0, 600);\nreturn [{ json: { ...S, ai, parts_ai: { covers: coversAi, risks: risksAi, panels: panelsAi }, covers, risks, handover: { summary: summaryOut, back: backOut }, auto_reply: autoOut, talk: talkOut, panels,\n  write: { md, file: fname, file_url: cfg.urls.outbox + encodeURIComponent(fname), card, card_url: cfg.urls.deck_cards, msg, msg_body: { message: msg, actorDisplayName: 'n8n demo' }, talk_url: cfg.urls.talk_post } } }];"
   },
   "id": "c2",
   "name": "Check and compose",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    1740,
    400
   ]
  },
  {
   "parameters": {
    "method": "PUT",
    "url": "={{ $('Check and compose').first().json.write.file_url }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "OCS-APIRequest",
       "value": "true"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    },
    "sendBody": true,
    "contentType": "raw",
    "rawContentType": "text/markdown",
    "body": "={{ $('Check and compose').first().json.write.md }}"
   },
   "id": "x1",
   "name": "Outbox: save handover note",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    1960,
    400
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  },
  {
   "parameters": {
    "method": "POST",
    "url": "={{ $('Check and compose').first().json.write.card_url }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "OCS-APIRequest",
       "value": "true"
      },
      {
       "name": "Accept",
       "value": "application/json"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    },
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify($('Check and compose').first().json.write.card) }}"
   },
   "id": "x2",
   "name": "Deck: create handover card",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    2180,
    400
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  },
  {
   "parameters": {
    "method": "POST",
    "url": "={{ $('Check and compose').first().json.write.talk_url }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "OCS-APIRequest",
       "value": "true"
      },
      {
       "name": "Accept",
       "value": "application/json"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    },
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify($('Check and compose').first().json.write.msg_body) }}"
   },
   "id": "x3",
   "name": "Talk: post summary",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    2400,
    400
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// ASSEMBLE RESULT: the receipts of the three writes (status codes of the Nextcloud answers) plus the whole handover for the page.\n// Only demo data and generated texts go out; no URLs, user names or ids of the Nextcloud account.\nconst B = $('Check and compose').first().json;\nconst pick = n => { try { return $(n).first().json; } catch (e) { return null; } };\nconst A = (() => { try { return $('Analyse the absence').first().json; } catch (e) { return { cleanup: [] }; } })();\nconst code = r => r ? Number(r.statusCode || 0) : 0;\nconst okc = r => code(r) >= 200 && code(r) < 300;\nconst rf = pick('Outbox: save handover note'), rc = pick('Deck: create handover card'), rt = pick('Talk: post summary');\nconst { write, ...s } = B;\nconst cn = k => (A.cleanup || []).filter(x => x.kind === k).length;\nreturn [{ json: {\n  id: s.id, lang: s.lang, style: s.style, days: s.days, who: s.who, window: s.window, ai: s.ai, read_ok: s.read_ok, stats: s.stats,\n  events: s.events, cards: s.cards, mails: s.mails, covers: s.covers, risks: s.risks, handover: s.handover, auto_reply: s.auto_reply, talk: s.talk,\n  panels: s.panels, md: write.md,\n  receipts: {\n    file: { ok: okc(rf), name: write.file, folder: 'Brightside-Demo/Outbox', kb: Math.max(1, Math.round(write.md.length / 1024)) },\n    deck: { ok: okc(rc), title: write.card.title, stack: 'Inbox', board: 'Brightside Studio (Demo)' },\n    talk: { ok: okc(rt), text: write.msg },\n  },\n  cleaned: { cards: cn('card'), files: cn('file'), msgs: cn('msg') },\n} }];"
   },
   "id": "c3",
   "name": "Assemble result",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    2620,
    300
   ]
  },
  {
   "parameters": {
    "method": "POST",
    "url": "https://YOUR-SITE.example/handover-ingest.php",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "X-Handover-Token",
       "value": "YOUR-SECRET-TOKEN"
      }
     ]
    },
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify($json) }}",
    "options": {
     "timeout": 60000
    }
   },
   "id": "p1",
   "name": "Result to the page",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    2840,
    300
   ]
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// CLEANUP: one DELETE per item this workflow created earlier inside the demo spaces (previous handover cards, Outbox notes beyond the\n// newest 20, handover messages beyond the newest 5 in the demo Talk room). Checked against the demo space once more here.\nconst A = $('Analyse the absence').first().json;\nconst cfg = $('Fix the demo space').first().json;\nconst ok = u => String(u) === cfg.talk || cfg.allowed.some(p => String(u).startsWith(p));\nreturn A.cleanup.filter(x => ok(x.url)).map(x => ({ json: { url: x.url, kind: x.kind } }));"
   },
   "id": "c4",
   "name": "Cleanup: plan",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    2620,
    520
   ]
  },
  {
   "parameters": {
    "method": "DELETE",
    "url": "={{ $json.url }}",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpBasicAuth",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "OCS-APIRequest",
       "value": "true"
      },
      {
       "name": "Accept",
       "value": "application/json"
      }
     ]
    },
    "options": {
     "webdavMethods": true,
     "timeout": 20000,
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    }
   },
   "id": "d1",
   "name": "Cleanup: delete",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    2840,
    520
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpBasicAuth": {
     "id": "",
     "name": "YOUR Nextcloud app password (Basic Auth: user + app password)"
    }
   }
  }
 ],
 "connections": {
  "Visitor button (Webhook)": {
   "main": [
    [
     {
      "node": "Fix the demo space",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Fix the demo space": {
   "main": [
    [
     {
      "node": "Read: mail account",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Read: mail account": {
   "main": [
    [
     {
      "node": "Mail guard",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Mail guard": {
   "main": [
    [
     {
      "node": "Calendar: events in the absence window",
      "type": "main",
      "index": 0
     },
     {
      "node": "Deck: read board",
      "type": "main",
      "index": 0
     },
     {
      "node": "Read: mail messages",
      "type": "main",
      "index": 0
     },
     {
      "node": "Files: list Outbox",
      "type": "main",
      "index": 0
     },
     {
      "node": "Talk: read chat",
      "type": "main",
      "index": 0
     },
     {
      "node": "Projects: file list",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Projects: file list": {
   "main": [
    [
     {
      "node": "Files: read project notes",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Calendar: events in the absence window": {
   "main": [
    [
     {
      "node": "Collect everything",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Deck: read board": {
   "main": [
    [
     {
      "node": "Collect everything",
      "type": "main",
      "index": 1
     }
    ]
   ]
  },
  "Read: mail messages": {
   "main": [
    [
     {
      "node": "Collect everything",
      "type": "main",
      "index": 2
     }
    ]
   ]
  },
  "Files: read project notes": {
   "main": [
    [
     {
      "node": "Collect everything",
      "type": "main",
      "index": 3
     }
    ]
   ]
  },
  "Files: list Outbox": {
   "main": [
    [
     {
      "node": "Collect everything",
      "type": "main",
      "index": 4
     }
    ]
   ]
  },
  "Talk: read chat": {
   "main": [
    [
     {
      "node": "Collect everything",
      "type": "main",
      "index": 5
     }
    ]
   ]
  },
  "Collect everything": {
   "main": [
    [
     {
      "node": "Analyse the absence",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Analyse the absence": {
   "main": [
    [
     {
      "node": "Assistant writes (AI)",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Assistant writes (AI)": {
   "main": [
    [
     {
      "node": "Check and compose",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Check and compose": {
   "main": [
    [
     {
      "node": "Outbox: save handover note",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Outbox: save handover note": {
   "main": [
    [
     {
      "node": "Deck: create handover card",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Deck: create handover card": {
   "main": [
    [
     {
      "node": "Talk: post summary",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Talk: post summary": {
   "main": [
    [
     {
      "node": "Assemble result",
      "type": "main",
      "index": 0
     },
     {
      "node": "Cleanup: plan",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Assemble result": {
   "main": [
    [
     {
      "node": "Result to the page",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Cleanup: plan": {
   "main": [
    [
     {
      "node": "Cleanup: delete",
      "type": "main",
      "index": 0
     }
    ]
   ]
  }
 },
 "active": false,
 "settings": {
  "executionOrder": "v1",
  "timezone": "Europe/Berlin",
  "saveDataSuccessExecution": "none",
  "saveDataErrorExecution": "all"
 },
 "pinData": {}
}