This workflow corresponds to n8n.io template #14040 — we link there as the canonical source.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "QiHX0W61otMoi1AP",
"name": "ClickUp List to Nextcloud Deck (one-off)",
"tags": [
{
"id": "8bXQxvAHY2O7MTAL",
"name": "BizLog",
"createdAt": "2026-02-21T07:36:16.153Z",
"updatedAt": "2026-02-21T07:36:16.153Z"
}
],
"nodes": [
{
"id": "8530c39c-04c9-4ed4-b40c-1cdeded0be8b",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"notes": "Starts the one-off migration manually.\n\nUse this workflow for ad hoc imports, not for a recurring sync.\nBefore running, decide which config node you want active:\n- **Set Config - View** for importing everything visible in a ClickUp view\n- **Set Config - Task Root** for importing one root task tree\n\nThis trigger currently feeds the task-root config branch. To run in view mode, connect/activate the view config path instead.",
"position": [
-1216,
672
],
"parameters": {},
"notesInFlow": false,
"typeVersion": 1
},
{
"id": "d9d51e75-2997-4701-ba9d-548b9a88f631",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"notes": "ClickUp View/List \u2192 Nextcloud Deck (one-off import)\n\nThis workflow supports two source modes:\n1. **view** = resolve the real home list from a ClickUp view, then import parent tasks from that list\n2. **task_root** = fetch one ClickUp root task, discover its home list, collect its descendants, and import only that subtree\n\nShared behavior:\n- parent tasks become Deck cards\n- subtasks are appended into the parent card description\n- ClickUp comments are appended after subtasks/checklists\n- Deck stacks are created from mapped ClickUp statuses\n- Deck labels are created from OKR, Progress, Priority, and Finished\n- completed ClickUp tasks are marked done in Deck after card creation",
"position": [
-1280,
32
],
"parameters": {
"color": 5,
"width": 430,
"height": 420,
"content": "ClickUp View/List \u2192 Nextcloud Deck (one-off import)\n\nHow this version works:\n1. Uses the ClickUp view id from Set Config to resolve the real home list id.\n2. Fetches the full list with subtasks=true and include_timl=true.\n3. Imports only parent tasks as Deck cards and appends subtasks into the parent card description.\n4. Appends task comments to the end of the card description.\n5. Creates Deck labels from ClickUp OKR, Progress, and Priority, then assigns them to cards.\n6. Sends due dates to Deck in ISO-8601 format.\n\nBefore you run:\n- Keep the Set Config values as they are unless you want a different view/board.\n- Attach the same Nextcloud HTTP Basic Auth credential to the Deck HTTP nodes.\n- Best used as a one-off migration on a clean or lightly used board.\n\nNew in this version:\n7. A second manual trigger can import one ClickUp root task tree into the same downstream Deck migration flow."
},
"notesInFlow": false,
"typeVersion": 1
},
{
"id": "cf17069e-29a8-4be9-86cc-fee725178c8e",
"name": "Deck - Validate Board",
"type": "n8n-nodes-base.httpRequest",
"notes": "Validates the Nextcloud Deck target before any ClickUp-heavy work starts.\n\nWhat it checks:\n- base URL resolves\n- Deck app API path is correct\n- board ID exists\n- attached HTTP Basic Auth credential can access the board\n\nIf this node fails, fix the Nextcloud URL, board ID, or credential attachment before debugging anything else.",
"position": [
-448,
576
],
"parameters": {
"url": "={{ $('Set Config').first().json.nextcloud_base_url.replace(/\\/$/, '') + '/index.php/apps/deck/api/v1.0/boards/' + $('Set Config').first().json.nextcloud_deck_board_id }}",
"options": {},
"sendHeaders": true,
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "OCS-APIRequest",
"value": "true"
},
{
"name": "Accept",
"value": "application/json"
}
]
}
},
"credentials": {
"httpBasicAuth": {
"name": "<your credential>"
}
},
"notesInFlow": false,
"typeVersion": 4.2
},
{
"id": "bb4bb97f-987c-45c8-a7a4-df6b2748ed80",
"name": "Build Page Numbers",
"type": "n8n-nodes-base.code",
"notes": "View-mode bootstrap.\n\nCreates a single seed item:\n- `page = 0`\n- `clickup_list_id` from config\n\nWhy this exists:\n- the view endpoint is queried first only to discover the actual home list ID behind the view\n- full pagination starts later after the real list ID is known",
"position": [
48,
384
],
"parameters": {
"jsCode": "const cfg = $('Set Config').first().json;\nreturn [{ json: { page: 0, clickup_list_id: cfg.clickup_list_id } }];"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "0482a998-f170-4c87-887b-0528cd37650c",
"name": "ClickUp - Get View Tasks Page",
"type": "n8n-nodes-base.httpRequest",
"notes": "Calls ClickUp's **view tasks** endpoint for page 0.\n\nRequest behavior:\n- includes subtasks\n- includes closed tasks\n\nPurpose:\n- gather enough task data to infer the real ClickUp home list ID used by the view\n- this is not the full import yet; it is a discovery step",
"position": [
288,
384
],
"parameters": {
"url": "={{ $('Set Config').first().json.clickup_base_url.replace(/\\/$/, '') + '/api/v2/view/' + $('Set Config').first().json.clickup_list_id + '/task' }}",
"options": {},
"sendQuery": true,
"sendHeaders": true,
"queryParameters": {
"parameters": [
{
"name": "page",
"value": "={{ $json.page }}"
},
{
"name": "subtasks",
"value": "true"
},
{
"name": "include_closed",
"value": "true"
}
]
},
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ $('Set Config').first().json.clickup_api_token }}"
},
{
"name": "Accept",
"value": "application/json"
}
]
}
},
"notesInFlow": false,
"typeVersion": 4.2
},
{
"id": "c00b4025-1c7b-43ee-8b80-6723d6f934c0",
"name": "Append Comments To Task",
"type": "n8n-nodes-base.code",
"notes": "Merges ClickUp comments into each prepared task description.\n\nFormatting behavior:\n- comments are normalized into markdown\n- author and date are included when available\n- comments are appended under a `Comments` section after the existing description\n\nOutput is the final card content used for card creation.",
"position": [
4064,
464
],
"parameters": {
"jsCode": "function toText(value) {\n if (value === null || value === undefined) return '';\n return String(value);\n}\n\nfunction normalizeBreaks(text) {\n return toText(text).replace(/\\r\\n/g, '\\n').trim();\n}\n\nfunction extractComments(payload) {\n if (Array.isArray(payload?.comments)) return payload.comments;\n if (Array.isArray(payload?.data?.comments)) return payload.data.comments;\n if (Array.isArray(payload?.comment_response?.comments)) return payload.comment_response.comments;\n return [];\n}\n\nfunction extractCommentText(comment) {\n return normalizeBreaks(\n comment?.comment_text ??\n comment?.comment ??\n comment?.text ??\n comment?.text_content ??\n ''\n );\n}\n\nfunction extractCommentAuthor(comment) {\n return (\n comment?.user?.username ||\n comment?.user?.name ||\n comment?.user?.email ||\n 'Unknown'\n );\n}\n\nfunction extractCommentDateIso(comment) {\n const raw =\n comment?.date ??\n comment?.date_created ??\n comment?.date_added ??\n null;\n\n if (raw === null || raw === undefined || raw === '') return null;\n\n const n = Number(raw);\n if (Number.isFinite(n)) return new Date(n).toISOString();\n\n const d = new Date(raw);\n if (!Number.isNaN(d.getTime())) return d.toISOString();\n\n return null;\n}\n\nfunction buildCommentsMarkdown(comments) {\n const rows = comments\n .map((comment) => {\n const text = extractCommentText(comment);\n if (!text) return null;\n\n const author = extractCommentAuthor(comment);\n const dateIso = extractCommentDateIso(comment);\n const header = `- **${author}**${dateIso ? ` (${dateIso})` : ''}`;\n const body = text.replace(/\\n/g, '\\n ');\n\n return `${header}\\n\\n ${body}`;\n })\n .filter(Boolean);\n\n if (!rows.length) return '';\n\n return `## Comments\\n\\n${rows.join('\\n\\n')}`;\n}\n\nconst preparedCards = $('Prepare Task Work Items').all().map(item => item.json ?? {});\nconst commentItems = $input.all();\n\nreturn commentItems.map((item, index) => {\n const base = preparedCards[index] ?? {};\n const payload = item.json ?? {};\n const comments = extractComments(payload);\n\n const existingDescription = normalizeBreaks(base.description ?? base.description_base ?? '');\n const commentsMd = buildCommentsMarkdown(comments);\n\n const mergedDescription = commentsMd\n ? (existingDescription ? `${existingDescription}\\n\\n${commentsMd}` : commentsMd)\n : existingDescription;\n\n return {\n json: {\n ...base,\n comments,\n description: mergedDescription,\n },\n pairedItem: item.pairedItem ?? { item: index },\n };\n});"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "86ef54e9-41fc-44e0-ab78-0a2a8afdb57e",
"name": "Build Stack Items",
"type": "n8n-nodes-base.code",
"notes": "Collects the Deck stacks that should exist.\n\nLogic:\n- starts from `status_stack_map_json`\n- also adds unmapped statuses observed in normalized tasks\n- normalizes keys and sorts stacks by configured order\n\nResult:\n- one item per target Deck stack to create/read back later",
"position": [
1664,
464
],
"parameters": {
"jsCode": "const cfg = $('Set Config').first().json;\nconst tasks = $('Normalize ClickUp Parent Tasks').all().map(item => item.json ?? {});\n\nfunction normalizeStatusKey(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .replace(/\\s+/g, ' ');\n}\n\nfunction titleCase(value) {\n return String(value || '')\n .split(' ')\n .filter(Boolean)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1))\n .join(' ') || 'Other';\n}\n\nlet statusMap = [];\ntry {\n statusMap = JSON.parse(cfg.status_stack_map_json || '[]');\n} catch (error) {\n throw new Error('status_stack_map_json is not valid JSON.');\n}\n\nconst byKey = new Map();\n\nfor (const item of statusMap) {\n const key = normalizeStatusKey(item.status_key);\n if (!key) continue;\n byKey.set(key, {\n stack_key: key,\n stack_title: item.stack_title || titleCase(key),\n order: Number(item.order || 999),\n });\n}\n\nfor (const task of tasks) {\n const key = normalizeStatusKey(task.stack_key || task.stack_title);\n if (!key) continue;\n if (!byKey.has(key)) {\n byKey.set(key, {\n stack_key: key,\n stack_title: task.stack_title || titleCase(key),\n order: Number(task.stack_order || 900),\n });\n }\n}\n\nreturn Array.from(byKey.values())\n .sort((a, b) => (a.order || 999) - (b.order || 999))\n .map(item => ({ json: item }));"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "833bc781-60ef-49ce-9975-5c305fbb0566",
"name": "Deck - Create Stack",
"type": "n8n-nodes-base.httpRequest",
"notes": "Creates each target stack in Nextcloud Deck.\n\nImplementation details:\n- sends one request per stack\n- `continueOnFail=true` so reruns or duplicate-stack situations do not stop the workflow\n- the workflow later re-reads stacks from Deck, so exact create responses are not the final source of truth",
"position": [
1904,
464
],
"parameters": {
"url": "={{ $('Set Config').first().json.nextcloud_base_url.replace(/\\/$/, '') + '/index.php/apps/deck/api/v1.0/boards/' + $('Set Config').first().json.nextcloud_deck_board_id + '/stacks' }}",
"body": "={{ JSON.stringify({ title: $json.stack_title, order: Number($json.order || 999) }) }}",
"method": "POST",
"options": {
"batching": {
"batch": {
"batchSize": 1,
"batchInterval": 100
}
}
},
"sendBody": true,
"contentType": "raw",
"sendHeaders": true,
"authentication": "genericCredentialType",
"rawContentType": "application/json",
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "OCS-APIRequest",
"value": "true"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"notesInFlow": false,
"typeVersion": 4.2,
"continueOnFail": true
},
{
"id": "11f5cec7-c260-4d30-bc6f-76ed7a77a844",
"name": "After Stack Creation - Emit Once",
"type": "n8n-nodes-base.code",
"notes": "Collapses the per-stack stream back to a single item.\n\nPurpose:\n- later phases only need to know that stack creation was attempted\n- prevents label creation from running once per stack",
"position": [
2144,
464
],
"parameters": {
"jsCode": "return [{\n json: {\n stack_create_attempts: $input.all().length\n }\n}];"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "0c0846e8-3b6e-4fdf-a60d-aeadb2c0d4d7",
"name": "Deck - Get Stacks",
"type": "n8n-nodes-base.httpRequest",
"notes": "Reads the board's stacks after stack creation.\n\nWhy this matters:\n- stack IDs are required to create cards in the correct stack\n- this read is the authoritative stack source, which is safer than trusting create responses during reruns",
"position": [
3344,
464
],
"parameters": {
"url": "={{ $('Set Config').first().json.nextcloud_base_url.replace(/\\/$/, '') + '/index.php/apps/deck/api/v1.0/boards/' + $('Set Config').first().json.nextcloud_deck_board_id + '/stacks' }}",
"options": {},
"sendHeaders": true,
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "OCS-APIRequest",
"value": "true"
},
{
"name": "Accept",
"value": "application/json"
}
]
}
},
"notesInFlow": false,
"typeVersion": 4.2
},
{
"id": "537c05f3-531f-42f5-9062-9e68f759b53b",
"name": "Prepare Task Work Items",
"type": "n8n-nodes-base.code",
"notes": "Combines normalized tasks with live Deck board data.\n\nMain jobs:\n- map each task to the correct `stack_id`\n- map label titles to actual `label_id` values from the board\n- create a stable card order within each stack\n- carry forward title, description, due date, and label metadata for later steps\n\nIf expected stacks are missing, this node throws an error early.",
"position": [
3584,
464
],
"parameters": {
"jsCode": "const tasks = $('Normalize ClickUp Parent Tasks').all().map(item => item.json ?? {});\nconst stackItems = $('Deck - Get Stacks').all();\nconst board = $('Deck - Get Board After Labels').first().json ?? {};\n\nfunction normalizeKey(value) {\n return String(value || '').trim().toLowerCase().replace(/\\s+/g, ' ');\n}\nfunction extractStacks(items) {\n const out = [];\n for (const item of items) {\n const j = item.json ?? item ?? {};\n if (Array.isArray(j)) { out.push(...j); continue; }\n const nested = [j.stacks, j.data?.stacks, j.data, j.items, j.results].find(v => Array.isArray(v));\n if (nested) { out.push(...nested); continue; }\n if (j && typeof j === 'object' && j.id && j.title) out.push(j);\n }\n return out;\n}\n\nconst stacks = extractStacks(stackItems);\nif (!stacks.length) throw new Error('No Deck stacks were returned. Check board permissions, base URL, or Deck API path.');\nconst stackByTitle = new Map(stacks.map(stack => [normalizeKey(stack.title), stack]));\nconst labelByTitle = new Map((Array.isArray(board.labels) ? board.labels : []).map(label => [normalizeKey(label.title), label]));\nconst missingTitles = Array.from(new Set(tasks.map(task => task.stack_title).filter(title => !stackByTitle.has(normalizeKey(title)))));\nif (missingTitles.length) throw new Error(`These Deck stacks were not found after creation: ${missingTitles.join(', ')}`);\n\nconst orderCounters = new Map();\nconst out = [];\nfor (const task of tasks) {\n const stack = stackByTitle.get(normalizeKey(task.stack_title));\n const currentOrder = (orderCounters.get(stack.id) || 0) + 1000;\n orderCounters.set(stack.id, currentOrder);\n out.push({\n json: {\n ...task,\n stack_id: stack.id,\n description: task.description || task.description_base || '',\n order: currentOrder,\n label_ids: (Array.isArray(task.deck_labels) ? task.deck_labels : [])\n .map(label => labelByTitle.get(normalizeKey(label.title))?.id)\n .filter(Boolean),\n label_titles: (Array.isArray(task.deck_labels) ? task.deck_labels : []).map(label => label.title),\n }\n });\n}\nreturn out;"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "e222f2e2-eebe-4169-bf9e-3b6c058bc7ed",
"name": "Deck - Create Card",
"type": "n8n-nodes-base.httpRequest",
"notes": "Creates one Deck card per prepared parent task.\n\nCurrent mapping:\n- title\n- description\n- order\n- due date\n- card type = `plain`\n\nNotes:\n- only parent tasks become cards\n- subtasks remain embedded inside the description\n- `continueOnFail=true` allows the workflow to continue into summary/reporting even if some card creates fail",
"position": [
4304,
464
],
"parameters": {
"url": "={{ $('Set Config').first().json.nextcloud_base_url.replace(/\\/$/, '') + '/index.php/apps/deck/api/v1.0/boards/' + $('Set Config').first().json.nextcloud_deck_board_id + '/stacks/' + $json.stack_id + '/cards' }}",
"body": "={{ JSON.stringify({ title: $json.title, type: 'plain', order: Number($json.order || 999), description: $json.description || '', duedate: $json.duedate || null }) }}",
"method": "POST",
"options": {
"batching": {
"batch": {
"batchSize": 1,
"batchInterval": 100
}
}
},
"sendBody": true,
"contentType": "raw",
"sendHeaders": true,
"authentication": "genericCredentialType",
"rawContentType": "application/json",
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "OCS-APIRequest",
"value": "true"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"notesInFlow": false,
"typeVersion": 4.2,
"continueOnFail": true
},
{
"id": "9b7e54b8-e8bb-4ad4-a517-90ba4454fd74",
"name": "Result Summary",
"type": "n8n-nodes-base.code",
"notes": "Produces a human-readable migration summary.\n\nReported metrics include:\n- parent tasks attempted\n- cards created and failed\n- done-mark attempts and failures\n- label assignment attempts and failures\n- target board ID\n- original ClickUp source ID\n\nUse this node first when validating a run or troubleshooting partial imports.",
"position": [
5024,
464
],
"parameters": {
"jsCode": "const createdCardItems = $('Deck - Create Card').all();\nconst preparedCardItems = $('Append Comments To Task').all();\nconst labelAssignItems = $('Deck - Assign Label').all();\nconst attemptedCards = preparedCardItems.length;\nconst failedCards = createdCardItems.filter(item => item.json?.error || item.error || !item.json?.id).length;\nconst createdCards = createdCardItems.filter(item => item.json?.id).length;\nconst attemptedLabelAssignments = $('Prepare Label Assignments').all().length;\nconst failedLabelAssignments = labelAssignItems.filter(item => item.json?.error || item.error).length;\nconst successfulLabelAssignments = attemptedLabelAssignments - failedLabelAssignments;\n\nlet attemptedDoneMarks = 0;\nlet failedDoneMarks = 0;\nlet successfulDoneMarks = 0;\ntry {\n attemptedDoneMarks = $('Prepare Completed Card Updates').all().length;\n const doneItems = $('Deck - Mark Card Done').all();\n failedDoneMarks = doneItems.filter(item => item.json?.error || item.error).length;\n successfulDoneMarks = attemptedDoneMarks - failedDoneMarks;\n} catch (e) {}\n\nreturn [{\n json: {\n message: `Deck import finished. Parent tasks attempted ${attemptedCards}. Cards created ${createdCards}. Card failures ${failedCards}. Cards marked done attempted ${attemptedDoneMarks}. Cards marked done succeeded ${successfulDoneMarks}. Done-mark failures ${failedDoneMarks}. Label assignments attempted ${attemptedLabelAssignments}. Label assignments succeeded ${successfulLabelAssignments}. Label assignment failures ${failedLabelAssignments}.`,\n attempted_cards: attemptedCards,\n created_cards: createdCards,\n failed_cards: failedCards,\n attempted_done_marks: attemptedDoneMarks,\n successful_done_marks: successfulDoneMarks,\n failed_done_marks: failedDoneMarks,\n attempted_label_assignments: attemptedLabelAssignments,\n successful_label_assignments: successfulLabelAssignments,\n failed_label_assignments: failedLabelAssignments,\n board_id: $('Set Config').first().json.nextcloud_deck_board_id,\n clickup_source_id: $('Set Config').first().json.clickup_list_id,\n }\n}];"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "cdb88ee9-b32f-4b1d-b76a-a2bca4779554",
"name": "Resolve ClickUp Home List ID",
"type": "n8n-nodes-base.code",
"notes": "Extracts the real ClickUp list ID from the view results.\n\nOutput:\n- `clickup_actual_list_id`\n- `clickup_actual_list_name`\n- `visible_task_count`\n- original view ID for traceability\n\nFallback behavior:\n- if tasks do not reveal a list ID, the config value is used only when it already looks like a numeric list ID\n- otherwise the workflow throws an error",
"position": [
528,
384
],
"parameters": {
"jsCode": "const cfg = $('Set Config').first().json;\nconst inputItems = $input.all();\nfunction extractTasks(itemJson) {\n if (!itemJson) return [];\n if (Array.isArray(itemJson)) return itemJson;\n const candidates = [itemJson.tasks, itemJson.data?.tasks, itemJson.data, itemJson.items, itemJson.results];\n return candidates.find(v => Array.isArray(v)) || [];\n}\nlet actualListId = null;\nlet actualListName = null;\nlet visibleTaskCount = 0;\nfor (const item of inputItems) {\n const tasks = extractTasks(item.json ?? {});\n visibleTaskCount += tasks.length;\n for (const task of tasks) {\n const listId = task?.list?.id || task?.list_id || null;\n const listName = task?.list?.name || task?.list_name || null;\n if (listId) {\n actualListId = String(listId);\n actualListName = listName ? String(listName) : null;\n break;\n }\n }\n if (actualListId) break;\n}\nif (!actualListId) {\n const fallback = String(cfg.clickup_list_id || '').trim();\n if (/^\\d+$/.test(fallback)) actualListId = fallback;\n else throw new Error('Could not resolve the real ClickUp home list id from the view results.');\n}\nreturn [{ json: { clickup_view_id: String(cfg.clickup_list_id || ''), clickup_actual_list_id: actualListId, clickup_actual_list_name: actualListName, visible_task_count: visibleTaskCount } }];"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "06b2a66c-6a3d-42d6-8018-e5f797f24ab6",
"name": "Build List Page Numbers",
"type": "n8n-nodes-base.code",
"notes": "Creates one item per ClickUp list page for view mode.\n\nUses:\n- `clickup_actual_list_id`\n- `max_pages`\n\nOutput shape:\n- page number\n- actual list ID/name\n- original view ID\n\nThis is the real pagination fan-out for the shared import pipeline.",
"position": [
768,
384
],
"parameters": {
"jsCode": "const cfg = $('Set Config').first().json;\nconst src = $input.first().json ?? {};\nconst maxPages = Math.max(1, Number(cfg.max_pages || 10));\nconst listId = String(src.clickup_actual_list_id || '').trim();\nif (!listId) throw new Error('clickup_actual_list_id is missing.');\nreturn Array.from({ length: maxPages }, (_, i) => ({\n json: {\n page: i,\n clickup_actual_list_id: listId,\n clickup_actual_list_name: src.clickup_actual_list_name || null,\n clickup_view_id: src.clickup_view_id || cfg.clickup_list_id || null,\n }\n}));"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "3de10cd3-c39b-45b1-9d20-19911e835915",
"name": "ClickUp - Get List Tasks Page",
"type": "n8n-nodes-base.httpRequest",
"notes": "Fetches paged tasks from the resolved ClickUp list.\n\nRequest options:\n- `subtasks=true`\n- `include_closed=true`\n- `include_timl=true`\n\nPurpose:\n- pull the list contents that will be normalized into parent Deck cards\n- multiple pages are requested based on `max_pages`",
"position": [
1008,
384
],
"parameters": {
"url": "={{ $('Set Config').first().json.clickup_base_url.replace(/\\/$/, '') + '/api/v2/list/' + $json.clickup_actual_list_id + '/task' }}",
"options": {},
"sendQuery": true,
"sendHeaders": true,
"queryParameters": {
"parameters": [
{
"name": "page",
"value": "={{ $json.page }}"
},
{
"name": "subtasks",
"value": "true"
},
{
"name": "include_closed",
"value": "true"
},
{
"name": "include_timl",
"value": "true"
}
]
},
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ $('Set Config').first().json.clickup_api_token }}"
},
{
"name": "Accept",
"value": "application/json"
}
]
}
},
"notesInFlow": false,
"typeVersion": 4.2
},
{
"id": "dcc46523-411d-4a45-8bcd-f39d1dd1bfc1",
"name": "ClickUp - Get Task Comments",
"type": "n8n-nodes-base.httpRequest",
"notes": "Fetches comments for each normalized parent task.\n\nImportant settings:\n- batched one at a time\n- 800 ms interval to be gentle with ClickUp\n- `continueOnFail=true` so comment failures do not block card creation\n\nComments are appended into the Deck card description in the next node.",
"position": [
3824,
464
],
"parameters": {
"url": "={{ $('Set Config').first().json.clickup_base_url.replace(/\\/$/, '') + '/api/v2/task/' + $json.task_id + '/comment' }}",
"options": {
"batching": {
"batch": {
"batchSize": 1,
"batchInterval": 800
}
}
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ $('Set Config').first().json.clickup_api_token }}"
},
{
"name": "Accept",
"value": "application/json"
}
]
}
},
"notesInFlow": false,
"typeVersion": 4.2,
"continueOnFail": true
},
{
"id": "29fb5c0d-445a-472a-a6ba-4850fe612e66",
"name": "Build Label Items",
"type": "n8n-nodes-base.code",
"notes": "Collects unique Deck labels from normalized tasks.\n\nLabels currently come from:\n- OKR custom field\n- Progress custom field\n- Priority\n- Finished\n\nIt also normalizes colors to 6-digit hex values accepted by Deck.",
"position": [
2384,
464
],
"parameters": {
"jsCode": "const tasks = $('Normalize ClickUp Parent Tasks').all().map(item => item.json ?? {});\nconst byTitle = new Map();\nfunction normalizeColor(value, fallback = '808080') {\n let color = String(value || fallback).trim().replace(/^#/, '').toUpperCase();\n if (/^[0-9A-F]{8}$/.test(color)) color = color.slice(2);\n if (!/^[0-9A-F]{6}$/.test(color)) color = fallback;\n return color;\n}\nfor (const task of tasks) {\n for (const label of Array.isArray(task.deck_labels) ? task.deck_labels : []) {\n const title = String(label?.title || '').trim();\n if (!title) continue;\n if (!byTitle.has(title)) {\n byTitle.set(title, { title, color: normalizeColor(label?.color, '808080') });\n }\n }\n}\nreturn Array.from(byTitle.values()).map(label => ({ json: label }));"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "4f0ad243-60d3-449b-8aaa-b6f2f7612dc9",
"name": "Deck - Create Label",
"type": "n8n-nodes-base.httpRequest",
"notes": "Creates each label in the target Deck board.\n\nImplementation details:\n- one request per label\n- `continueOnFail=true` so reruns or duplicate labels do not halt the workflow\n- actual label IDs are retrieved later by re-reading the board",
"position": [
2624,
464
],
"parameters": {
"url": "={{ $('Set Config').first().json.nextcloud_base_url.replace(/\\/$/, '') + '/index.php/apps/deck/api/v1.0/boards/' + $('Set Config').first().json.nextcloud_deck_board_id + '/labels' }}",
"body": "={{ JSON.stringify({ title: $json.title, color: $json.color || '808080' }) }}",
"method": "POST",
"options": {
"batching": {
"batch": {
"batchSize": 1,
"batchInterval": 100
}
}
},
"sendBody": true,
"contentType": "raw",
"sendHeaders": true,
"authentication": "genericCredentialType",
"rawContentType": "application/json",
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "OCS-APIRequest",
"value": "true"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"notesInFlow": false,
"typeVersion": 4.2,
"continueOnFail": true
},
{
"id": "561e5564-7037-4ad9-9225-4d193ccc7de7",
"name": "After Label Creation - Emit Once",
"type": "n8n-nodes-base.code",
"notes": "Collapses label-create results to one item.\n\nPurpose:\n- continue the workflow once after the full label batch\n- avoid repeating downstream read/preparation work for each label",
"position": [
2864,
464
],
"parameters": {
"jsCode": "return [{ json: { label_create_attempts: $input.all().length } }];"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "5b55512a-41a9-4c15-a821-1a72861e5393",
"name": "Deck - Get Board After Labels",
"type": "n8n-nodes-base.httpRequest",
"notes": "Reads the board again after label creation.\n\nWhy this matters:\n- Deck label IDs are needed for assignment later\n- the board response is treated as the authoritative label list after any create/duplicate behavior",
"position": [
3104,
464
],
"parameters": {
"url": "={{ $('Set Config').first().json.nextcloud_base_url.replace(/\\/$/, '') + '/index.php/apps/deck/api/v1.0/boards/' + $('Set Config').first().json.nextcloud_deck_board_id }}",
"options": {},
"sendHeaders": true,
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "OCS-APIRequest",
"value": "true"
},
{
"name": "Accept",
"value": "application/json"
}
]
}
},
"notesInFlow": false,
"typeVersion": 4.2
},
{
"id": "6e0a1544-be12-4b78-b27e-6a45b32d5c96",
"name": "Prepare Label Assignments",
"type": "n8n-nodes-base.code",
"notes": "Builds one output item per `(card, label)` assignment.\n\nInput assumptions:\n- the card was successfully created\n- label IDs were already resolved from the board\n\nThis fan-out happens after card creation because Deck labels are attached to existing cards, not sent inline during create.",
"position": [
4544,
464
],
"parameters": {
"jsCode": "const createdCards = $input.all();\nconst preparedCards = $('Append Comments To Task').all();\nconst board = $('Deck - Get Board After Labels').first().json ?? {};\n\nfunction normalizeKey(value) {\n return String(value || '').trim().toLowerCase().replace(/\\s+/g, ' ');\n}\n\nconst labelById = new Map((Array.isArray(board.labels) ? board.labels : []).map(label => [Number(label.id), label]));\n\nconst out = [];\nfor (const created of createdCards) {\n const card = created.json ?? {};\n if (card?.error || !card?.id) continue;\n\n const pairedIndex = created.pairedItem?.item ?? null;\n const source = (pairedIndex !== null && preparedCards[pairedIndex]) ? (preparedCards[pairedIndex].json ?? {}) : {};\n const labelIds = Array.isArray(source.label_ids) ? source.label_ids : [];\n const stackId = card.stackId || source.stack_id || null;\n\n for (const labelId of labelIds) {\n if (!stackId) continue;\n const label = labelById.get(Number(labelId));\n out.push({\n json: {\n task_id: source.task_id || null,\n card_id: card.id,\n stack_id: stackId,\n label_id: Number(labelId),\n label_title: label?.title || null,\n }\n });\n }\n}\nreturn out;"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "b1ce5c62-0084-4279-8147-15a0a8ceca0e",
"name": "Deck - Assign Label",
"type": "n8n-nodes-base.httpRequest",
"notes": "Assigns one label at a time to a created Deck card.\n\nImplementation details:\n- uses board ID, stack ID, card ID, and label ID\n- `continueOnFail=true` so a single label problem does not stop the summary\n- success/failure counts are reported at the end",
"position": [
4784,
464
],
"parameters": {
"url": "={{ $('Set Config').first().json.nextcloud_base_url.replace(/\\/$/, '') + '/index.php/apps/deck/api/v1.0/boards/' + $('Set Config').first().json.nextcloud_deck_board_id + '/stacks/' + $json.stack_id + '/cards/' + $json.card_id + '/assignLabel' }}",
"body": "={{ JSON.stringify({ labelId: Number($json.label_id) }) }}",
"method": "PUT",
"options": {
"batching": {
"batch": {
"batchSize": 1,
"batchInterval": 100
}
}
},
"sendBody": true,
"contentType": "raw",
"sendHeaders": true,
"authentication": "genericCredentialType",
"rawContentType": "application/json",
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "OCS-APIRequest",
"value": "true"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"notesInFlow": false,
"typeVersion": 4.2,
"continueOnFail": true
},
{
"id": "8e0d1695-b328-475e-90b4-e020bb245815",
"name": "Prepare Completed Card Updates",
"type": "n8n-nodes-base.code",
"notes": "Builds updates for cards that should be marked done.\n\nOnly tasks recognized as complete in ClickUp are included.\nThe payload carries:\n- board/stack/card IDs\n- title/description/order\n- due date\n- `done` timestamp\n- owner fallback information\n\nCommunity note:\n- this logic references `nextcloud_username` as a fallback owner, but that field is not defined in the current config nodes. It usually still works if Deck returns an owner on card creation; if not, add that config field before sharing widely.",
"position": [
4544,
688
],
"parameters": {
"jsCode": "const createdCards = $input.all();\nconst sourceItems = $('Append Comments To Task').all();\nconst fallbackOwner = $('Set Config').first().json.nextcloud_username;\n\nconst out = [];\n\nfor (const created of createdCards) {\n const card = created.json ?? {};\n if (card?.error || !card?.id) continue;\n\n const pairedIndex = created.pairedItem?.item ?? null;\n const source = (pairedIndex !== null && sourceItems[pairedIndex])\n ? (sourceItems[pairedIndex].json ?? {})\n : {};\n\n if (!source.clickup_is_complete) continue;\n\n const owner =\n card.owner ||\n source.owner ||\n fallbackOwner;\n\n out.push({\n json: {\n board_id: Number($('Set Config').first().json.nextcloud_deck_board_id),\n stack_id: Number(card.stackId || source.stack_id),\n card_id: Number(card.id),\n title: source.title || card.title || '',\n description: source.description || '',\n order: Number(source.order || card.order || 999),\n duedate: source.duedate || null,\n done: source.clickup_done_at || new Date().toISOString(),\n owner,\n task_id: source.task_id || null,\n }\n });\n}\n\nreturn out;"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "54792a24-e7d8-42e6-9d98-d1f39c439778",
"name": "Deck - Mark Card Done",
"type": "n8n-nodes-base.httpRequest",
"notes": "Updates already-created Deck cards to mark them done.\n\nWhy this is a separate step:\n- cards are first created normally\n- completed ClickUp tasks are then patched with a `done` timestamp\n\nThe request also re-sends title/description/order (and due date when present) to keep the update payload complete.",
"position": [
4784,
688
],
"parameters": {
"url": "={{ $('Set Config').first().json.nextcloud_base_url.replace(/\\/$/, '') + '/index.php/apps/deck/api/v1.0/boards/' + $('Set Config').first().json.nextcloud_deck_board_id + '/stacks/' + $json.stack_id + '/cards/' + $json.card_id }}",
"body": "={{ (() => {\n const payload = {\n title: $json.title || '',\n description: $json.description || '',\n type: 'plain',\n order: Number($json.order || 999),\n owner: $json.owner || $('Set Config').first().json.nextcloud_username,\n done: $json.done,\n };\n\n if ($json.duedate) {\n payload.duedate = $json.duedate;\n }\n\n return JSON.stringify(payload);\n})() }}",
"method": "PUT",
"options": {
"batching": {
"batch": {
"batchSize": 1,
"batchInterval": 100
}
}
},
"sendBody": true,
"contentType": "raw",
"sendHeaders": true,
"authentication": "genericCredentialType",
"rawContentType": "application/json",
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "OCS-APIRequest",
"value": "true"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"notesInFlow": false,
"typeVersion": 4.2,
"continueOnFail": true
},
{
"id": "c00ce336-398c-49d2-8a45-00c68af881fb",
"name": "Set Config - View",
"type": "n8n-nodes-base.set",
"notes": "Configuration for **view mode**.\n\nFill these placeholders before running:\n- `clickup_list_id` = **ClickUp view ID** in this mode (the field name is legacy)\n- `clickup_api_token` = ClickUp personal/team API token\n- `nextcloud_base_url` = your Nextcloud base URL without extra path\n- `nextcloud_deck_board_id` = target Deck board ID\n- `max_pages` = maximum number of ClickUp list pages to read\n- `status_stack_map_json` = mapping from ClickUp status to Deck stack title/order\n- `subtask_title_prefix` = prefix shown before subtask names in the card description\n\nImportant:\n- `source_mode` must stay `view`\n- this node contains placeholders, not real secrets\n- downstream nodes expect `status_stack_map_json` to be valid JSON",
"position": [
-944,
544
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "548d43a2-d8b4-4150-91ee-56cc49bd29b6",
"name": "clickup_base_url",
"type": "string",
"value": "https://api.clickup.com"
},
{
"id": "94250d4a-f257-4e07-aa7b-24a3e913884a",
"name": "clickup_list_id",
"type": "string",
"value": "INSERT-LIST-ID-HERE"
},
{
"id": "3ffa7913-4920-4a59-a3a2-92c1846fe1d8",
"name": "clickup_api_token",
"type": "string",
"value": "INSERT-API-KEY-HERE"
},
{
"id": "b75d855a-03fb-4a90-84b6-79f1ca4510bc",
"name": "nextcloud_base_url",
"type": "string",
"value": "INSERT-BASE-URL-HERE"
},
{
"id": "d789fa71-3b70-4fc6-989e-2ef583da8fa0",
"name": "nextcloud_deck_board_id",
"type": "string",
"value": "INSERT-DECK-ID-HERE"
},
{
"id": "0c9b7f45-2241-4e1e-a35c-085d3a32a040",
"name": "max_pages",
"type": "string",
"value": "10"
},
{
"id": "28598936-b7c3-42c1-adaf-bb51e2407a6d",
"name": "status_stack_map_json",
"type": "string",
"value": "[{\"status_key\": \"to do\", \"stack_title\": \"To do\", \"order\": 100}, {\"status_key\": \"in progress\", \"stack_title\": \"In progress\", \"order\": 200}, {\"status_key\": \"complete\", \"stack_title\": \"Complete\", \"order\": 300}]"
},
{
"id": "69fcb569-c377-48d0-8df0-477509c62e8a",
"name": "subtask_title_prefix",
"type": "string",
"value": "\u21b3 "
},
{
"id": "36d3033f-d3f7-4821-ae5c-a9ab1ccac993",
"name": "source_mode",
"type": "string",
"value": "view"
}
]
}
},
"notesInFlow": false,
"typeVersion": 3.4
},
{
"id": "1d39725f-7407-4845-ac15-5075b0ec6262",
"name": "Normalize ClickUp Parent Tasks - View",
"type": "n8n-nodes-base.code",
"notes": "Transforms the full list payload from view mode into the workflow's shared task schema.\n\nMain jobs:\n- deduplicate tasks across pages\n- keep only parent tasks as card candidates\n- map ClickUp statuses to Deck stacks\n- build markdown description with metadata, description, checklists, and subtasks\n- derive Deck labels from OKR, Progress, Priority, and completion state\n- convert due/completion dates to ISO format\n\nOutput fields are designed for downstream stack/label/card creation.",
"position": [
1248,
384
],
"parameters": {
"jsCode": "const cfg = $('Set Config - View').first().json;\nconst inputItems = $input.all();\n\nfunction asArray(value) {\n return Array.isArray(value) ? value : [];\n}\nfunction pick(obj, keys, fallback = null) {\n for (const key of keys) {\n if (obj && obj[key] !== undefined && obj[key] !== null && obj[key] !== '') return obj[key];\n }\n return fallback;\n}\nfunction firstNonEmpty(...values) {\n for (const value of values) {\n if (value !== undefined && value !== null && String(value).trim() !== '') return value;\n }\n return null;\n}\nfunction normalizeStatusKey(value) {\n return String(value || '').trim().toLowerCase().replace(/\\s+/g, ' ');\n}\nfunction titleCase(value) {\n return String(value || '')\n .split(' ')\n .filter(Boolean)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1))\n .join(' ') || 'Other';\n}\nfunction truncate(value, maxLength) {\n const str = String(value || '');\n return str.length <= maxLength ? str : str.slice(0, Math.max(0, maxLength - 1)).trimEnd() + '\u2026';\n}\nfunction toMs(value) {\n if (value === null || value === undefined || value === '') return null;\n const n = Number(value);\n if (!Number.isFinite(n) || n <= 0) return null;\n return n;\n}\nfunction toIso(value) {\n const ms = toMs(value);\n if (!ms) return null;\n const unixMs = ms > 9999999999 ? ms : ms * 1000;\n try {\n return new Date(unixMs).toISOString();\n } catch {\n return null;\n }\n}\nfunction namesFromUsers(users) {\n return asArray(users)\n .map(user => user?.username || user?.email || user?.initials || user?.id)\n .filter(Boolean)\n .join(', ');\n}\nfunction namesFromTags(tags) {\n return asArray(tags)\n .map(tag => tag?.name || tag?.tag || tag)\n .filter(Boolean)\n .join(', ');\n}\nfunction priorityText(priority) {\n if (!priority) return null;\n if (typeof priority === 'string') return priority;\n return priority.priority || priority.name || priority.orderindex || null;\n}\nfunction normalizeColor(value, fallback = '808080') {\n let color = String(value || fallback).trim().replace(/^#/, '').toUpperCase();\n if (/^[0-9A-F]{8}$/.test(color)) color = color.slice(2);\n if (!/^[0-9A-F]{6}$/.test(color)) color = fallback;\n return color;\n}\nfunction checklistMarkdown(checklists) {\n const groups = [];\n for (const checklist of asArray(checklists)) {\n const title = checklist?.name || checklist?.title || 'Checklist';\n const items = asArray(checklist?.items).map(item => {\n const label = item?.name || item?.label || item?.text || 'Item';\n const checked = item?.resolved || item?.checked || item?.completed || false;\n return `- [${checked ? 'x' : ' '}] ${label}`;\n });\n if (items.length) groups.push(`### ${title}\\n` + items.join('\\n'));\n }\n return groups.join('\\n\\n');\n}\nfunction getCustomField(task, fieldName) {\n return asArray(task.custom_fields).find(field => String(field?.name || '').trim().toLowerCase() === String(fieldName).trim().toLowerCase()) || null;\n}\nfunction getDropdownFieldInfo(field) {\n if (!field) return null;\n const raw = field.value;\n const options = asArray(field.type_config?.options);\n if (raw === null || raw === undefined || raw === '') return null;\n const match = options.find(option =>\n String(option?.id) === String(raw) ||\n String(option?.orderindex) === String(raw) ||\n Number(option?.orderindex) === Number(raw)\n );\n if (!match) return null;\n return {\n name: String(match.name || '').trim(),\n color: normalizeColor(match.color, '808080'),\n };\n}\nfunction getProgressToOkrText(field) {\n if (!field || !field.value) return null;\n const value = field.value;\n if (typeof value.percent_completed === 'number') return `${Math.round(value.percent_completed * 100)}%`;\n if (value.current !== null && value.current !== undefined && value.current !== '') return `${value.current}%`;\n return null;\n}\nfunction extractTasks(itemJson) {\n if (!itemJson) return [];\n if (Array.isArray(itemJson)) return itemJson;\n const candidates = [itemJson.tasks, itemJson.data?.tasks, itemJson.data, itemJson.items, itemJson.results];\n return candidates.find(v => Array.isArray(v)) || [];\n}\n\nlet statusMap = [];\ntry {\n statusMap = JSON.parse(cfg.status_stack_map_json || '[]');\n} catch (error) {\n throw new Error('status_stack_map_json is not valid JSON.');\n}\nconst statusMapping = new Map(\n statusMap.map(item => [\n normalizeStatusKey(item.status_key),\n {\n stack_title: item.stack_title || titleCase(item.status_key),\n stack_order: Number(item.order || 999),\n }\n ])\n);\n\nconst byId = new Map();\nfor (const item of inputItems) {\n const tasks = extractTasks(item.json ?? {});\n for (const task of tasks) {\n const id = String(task?.id || task?.task_id || '').trim();\n if (!id) continue;\n if (!byId.has(id)) byId.set(id, task);\n }\n}\n\nconst allTasks = Array.from(byId.values());\nconst parentTasks = allTasks.filter(task => {\n const parentId = firstNonEmpty(task.parent, task.parent_id);\n return !(parentId && String(parentId).trim() !== '');\n});\n\nfunction buildSubtaskMarkdown(subtasks) {\n if (!subtasks.length) return null;\n const prefix = String(cfg.subtask_title_prefix || '\u21b3 ');\n return subtasks.map(subtask => {\n const statusText = firstNonEmpty(subtask?.status?.status, subtask?.status?.name, subtask?.status, 'other');\n const priority = priorityText(subtask.priority);\n const dueDateIso = toIso(firstNonEmpty(subtask.due_date, subtask.duedate));\n const isDone = normalizeStatusKey(statusText) === 'complete' || subtask?.status?.type === 'closed' || !!firstNonEmpty(subtask.date_closed, subtask.done);\n const details = [\n statusText ? `status: ${statusText}` : null,\n priority ? `priority: ${priority}` : null,\n dueDateIso ? `due: ${dueDateIso}` : null\n ].filter(Boolean).join(' | ');\n return `- [${isDone ? 'x' : ' '}] ${prefix}${subtask.name || subtask.id}${details ? ` \u2014 ${details}` : ''}`;\n }).join('\\n');\n}\n\nconst out = [];\nfor (const task of parentTasks) {\n const taskId = String(task.id || task.task_id);\n const taskName = String(firstNonEmpty(task.name, task.task_name, `Task ${taskId}`)).trim();\n const rawStatus = firstNonEmpty(task?.status?.status, task?.status?.name, task?.status, 'other');\n const statusKey = normalizeStatusKey(rawStatus);\n const mappedStatus = statusMapping.get(statusKey) || {\n stack_title: titleCase(statusKey),\n stack_order: 900,\n };\n const createdAtMs = toMs(firstNonEmpty(task.date_created, task.created_at));\n const dueDateIso = toIso(firstNonEmpty(task.due_date, task.duedate));\n const startDateIso = toIso(firstNonEmpty(task.start_date));\n const doneAtIso = toIso(firstNonEmpty(task.date_closed, task.done));\n const isComplete = task?.status?.type === 'closed' || statusKey === 'complete' || !!doneAtIso;\n const nativeTags = namesFromTags(task.tags);\n const assignees = namesFromUsers(task.assignees);\n const priority = priorityText(task.priority);\n const checklistMd = checklistMarkdown(task.checklists);\n const descriptionMd = String(firstNonEmpty(task.markdown_description, task.description, task.text_content, '') || '');\n const clickupUrl = firstNonEmpty(task.url, `https://app.clickup.com/t/${taskId}`);\n const timeEstimateMs = toMs(firstNonEmpty(task.time_estimate));\n const timeSpentMs = toMs(firstNonEmpty(task.time_spent));\n const timeEstimateHours = timeEstimateMs ? (timeEstimateMs / 3600000).toFixed(2) : null;\n const timeSpentHours = timeSpentMs ? (timeSpentMs / 3600000).toFixed(2) : null;\n const okrInfo = getDropdownFieldInfo(getCustomField(task, 'OKR'));\n const progressInfo = getDropdownFieldInfo(getCustomField(task, 'Progress'));\n const progressToOkrText = getProgressToOkrText(getCustomField(task, 'Progress to OKR'));\n const deckLabels = [\n okrInfo?.name ? { title: `OKR: ${okrInfo.name}`, color: okrInfo.color } : null,\n progressInfo?.name ? { title: `Progress: ${progressInfo.name}`, color: progressInfo.color } : null,\n priority ? { title: `Priority: ${String(priority).trim().toLowerCase()}`, color: normalizeColor(task?.priority?.color, '808080') } : null,\n isComplete ? { title: 'Finished', color: '31CC7C' } : null,\n ].filter(Boolean);\n\n const subtasks = allTasks\n .filter(candidate => String(firstNonEmpty(candidate.parent, candidate.parent_id) || '') === taskId)\n .sort((a, b) => {\n const aMs = toMs(firstNonEmpty(a.date_created, a.created_at)) || 0;\n const bMs = toMs(firstNonEmpty(b.date_created, b.created_at)) || 0;\n if (aMs !== bMs) return aMs - bMs;\n return String(a.name || a.id).localeCompare(String(b.name || b.id));\n });\n const subtasksMd = buildSubtaskMarkdown(subtasks);\n\n const metaLines = [\n `- **ClickUp task ID:** \\`${taskId}\\``,\n `- **Status:** ${mappedStatus.stack_title}`,\n isComplete ? `- **Completed in ClickUp:** Yes` : null,\n doneAtIso ? `- **Completed at:** ${doneAtIso}` : null,\n priority ? `- **Priority:** ${priority}` : null,\n okrInfo?.name ? `- **OKR:** ${okrInfo.name}` : null,\n progressInfo?.name ? `- **Progress:** ${progressInfo.name}` : null,\n progressToOkrText ? `- **Progress to OKR:** ${progressToOkrText}` : null,\n assignees ? `- **Assignees:** ${assignees}` : null,\n nativeTags ? `- **Tags:** ${nativeTags}` : null,\n startDateIso ? `- **Start date:** ${startDateIso}` : null,\n dueDateIso ? `- **Due date:** ${dueDateIso}` : null,\n timeEstimateHours ? `- **Time estimate (h):** ${timeEstimateHours}` : null,\n timeSpentHours ? `- **Time spent (h):** ${timeSpentHours}` : null,\n clickupUrl ? `- **ClickUp URL:** ${clickupUrl}` : null,\n ].filter(Boolean);\n\n const sections = [\n `Imported once from ClickUp source \\`${cfg.clickup_list_id}\\`.`,\n '',\n ...metaLines,\n '',\n descriptionMd ? `## Description\\n\\n${descriptionMd}` : null,\n checklistMd ? `## Checklists\\n\\n${checklistMd}` : null,\n subtasksMd ? `## Subtasks\\n\\n${subtasksMd}` : null,\n ].filter(section => section !== null);\n\n const description = sections.join('\\n');\n\n out.push({\n json: {\n task_id: taskId,\n task_name: taskName,\n stack_key: statusKey,\n stack_title: mappedStatus.stack_title,\n stack_order: mappedStatus.stack_order,\n title: truncate(taskName, 255),\n description,\n description_base: description,\n duedate: dueDateIso,\n created_at_ms: createdAtMs || 0,\n deck_labels: deckLabels,\n clickup_is_complete: isComplete,\n clickup_done_at: doneAtIso,\n }\n });\n}\n\nout.sort((a, b) => {\n const aj = a.json;\n const bj = b.json;\n if ((aj.stack_order || 999) !== (bj.stack_order || 999)) return (aj.stack_order || 999) - (bj.stack_order || 999);\n if ((aj.created_at_ms || 0) !== (bj.created_at_ms || 0)) return (aj.created_at_ms || 0) - (bj.created_at_ms || 0);\n return String(aj.title || '').localeCompare(String(bj.title || ''));\n});\n\nif (!out.length) {\n throw new Error('No parent tasks were produced from ClickUp list pages. Check the list endpoint output.');\n}\nreturn out;"
},
"notesInFlow": false,
"typeVersion": 2
},
{
"id": "773e4122-8f19-4f7e-ae20-117f1ddfbe8e",
"name": "Set Config - Task Root",
"type": "n8n-nodes-base.set",
"notes": "Configuration for **task_root mode**.\n\nFill these placeholders before running:\n- `clickup_task_id` = the ClickUp root task ID whose full subtree should be imported\n- `clickup_api_token` = ClickUp personal/team API token\n- `nextcloud_base_url` = your Nextcloud base URL\n- `nextcloud_deck_board_id` = target Deck board ID\n- `max_pages` = how many pages of the home list to scan while finding descendants\n- `status_stack_map_json` = status-to-stack mapping\n- `subtask_title_prefix` = prefix added before subtask names\n\nImportant:\n- `source_mode` must stay `task_root`\n- this path is useful when one list is huge but you only want one project/task tree\n- this node contains placeholders, not real secrets",
"position": [
-944,
768
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "e3d5b9f4-5f86-41b2-ae3e-d30cdc3e1e5f",
"name": "clickup_base_url",
"type": "string",
"value": "https://api.clickup.com"
},
{
"id": "bcd206f3-80e4-405f-bf52-fb3be523cdc8",
"name": "clickup_api_token",
"type": "string",
"value": "INSERT-API-KEY-HERE"
},
{
"id": "43d5f028-ebb2-404a-8bcf-71bdf6e6c54e",
"name": "nextcloud_base_url",
"type": "string",
"value": "INSERT-URL-HERE"
},
{
"id": "a928aca4-ddee-4aee-b786-37ce6f2841dc",
"name": "nextcloud_deck_board_id",
"type": "string",
"value": "INSERT-BOARD-ID-HERE"
},
{
"id": "67eba8a4-e439-430a-82fc-50cb998b2241",
"name": "max_pages",
"type": "string",
"value": "10"
},
{
"id": "e40cc21d-de9a-414c-86bc-91f290f133c0",
"name": "status_stack_map_json",
"type": "string",
"value": "[{\"status_key\": \"to do\", \"stack_title\": \"To do\", \"order\": 100}, {\"status_key\": \"in progress\", \"stack_title\": \"In progress\", \"order\": 200}, {\"status_key\": \"complete\", \"stack_title\": \"Complete\", \"order\": 300}]"
},
{
"id": "6c558d06-0c1b-47cd-9be6-cc768192bf69",
"name": "subtask_title_prefix",
"type": "string",
"value": "\u21b3 "
},
{
"id": "9d36b548-a05d-42b6-88d9-c94d353ec8ed",
"name": "clickup_task_id",
"type": "string",
"value": "INSERT-TASK-ID-HERE"
},
{
"id": "ca4b3c36-547c-4276-8db0-3b4337bdddcf",
"name": "source_mode",
"type": "string",
"value": "task_root"
}
]
}
},
"notesInFlow": false,
"typeVersion": 3.4
},
{
"id": "82738d9c-445a-443d-bc56-04284080d2ca",
"name": "Set Config",
"type": "n8n-nodes-base.code",
"notes": "Pass-through normalizer for the chosen config node.\n\nPurpose:\n- whichever config node runs upstream, this code node copies its JSON forward unchanged\n- downstream nodes always read s
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
httpBasicAuth
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Move your ClickUp List or Task Tree to Nextcloud Deck
Source: https://n8n.io/workflows/14040/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow allows you to import any workflow from a file or another n8n instance and map the credentials easily. A multi-form setup guides you through the entire process At the beginning you have t
[n8n] Advanced URL Parsing and Shortening Workflow - Switchy.io Integration. Uses splitInBatches, stickyNote, httpRequest, html. Event-driven trigger; 56 nodes.
[](https://youtu.be/c7yCZhmMjtI)
This automation organizes your n8n workflows files into categorizes (Active, Template, Done, Archived) and uploads them directly to a categorized Google Drive folders. It is designed to help users man
Create Animated Stories using GPT-4o-mini, Midjourney, Kling and Creatomate API. Uses httpRequest. Event-driven trigger; 51 nodes.