diff --git a/n8n-workflow.json b/n8n-workflow.json index 19da9fb..c45e75a 100644 --- a/n8n-workflow.json +++ b/n8n-workflow.json @@ -755,7 +755,7 @@ }, { "parameters": { - "jsCode": "// Parse callback data from button click\n// Supports: select:{name}, list:{page}, action:{action}:{name}, confirm:{action}:{name}:{timestamp}, cancel:{name}, bstop:*, bexec:*, and legacy JSON format\nconst callback = $json.callback_query;\nconst rawData = callback.data;\nconst queryId = callback.id;\nconst chatId = callback.message.chat.id;\nconst messageId = callback.message.message_id;\n\n// Check for new colon-separated format\nif (rawData.startsWith('select:')) {\n // Container selection: select:{containerName}\n const containerName = rawData.substring(7); // Remove 'select:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isSelect: true,\n containerName,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('list:')) {\n // Pagination: list:{page}\n const page = parseInt(rawData.substring(5)) || 0; // Remove 'list:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isList: true,\n page,\n isSelect: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('action:')) {\n // Action execution: action:{action}:{containerName}\n const parts = rawData.substring(7).split(':'); // Remove 'action:'\n const action = parts[0];\n const containerName = parts.slice(1).join(':'); // Handle names with colons\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isAction: true,\n action,\n containerName,\n isSelect: false,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('confirm:')) {\n // Confirmation: confirm:{action}:{containerName}:{timestamp}\n const parts = rawData.substring(8).split(':'); // Remove 'confirm:'\n const action = parts[0]; // stop or update\n const timestamp = parseInt(parts[parts.length - 1]); // Last part is timestamp\n const containerName = parts.slice(1, -1).join(':'); // Everything between action and timestamp\n \n // Check 30-second timeout\n const currentTime = Math.floor(Date.now() / 1000);\n const elapsed = currentTime - timestamp;\n const isExpired = elapsed > 30;\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isConfirm: true,\n confirmAction: action,\n containerName,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false\n }\n };\n}\n\nif (rawData.startsWith('cancel:')) {\n // Cancel confirmation: cancel:{containerName}\n const containerName = rawData.substring(7); // Remove 'cancel:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isCancelConfirm: true,\n containerName,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\n// Batch stop confirmation: bstop:confirm:{names}:{timestamp} or bstop:cancel\nif (rawData.startsWith('bstop:')) {\n const rest = rawData.substring(6); // Remove 'bstop:'\n if (rest === 'cancel') {\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchStopCancel: true,\n isCancel: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n expired: false\n }\n };\n }\n // bstop:confirm:{names}:{timestamp}\n const parts = rest.split(':'); // confirm, names, timestamp\n if (parts[0] === 'confirm' && parts.length >= 3) {\n const timestamp = parseInt(parts[parts.length - 1]);\n const namesStr = parts.slice(1, -1).join(':'); // Handle container names with colons\n const containerNames = namesStr.split(',');\n \n // Check 30-second timeout\n const currentTime = Math.floor(Date.now() / 1000);\n const elapsed = currentTime - timestamp;\n const isExpired = elapsed > 30;\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchStopConfirm: true,\n containerNames,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false\n }\n };\n }\n}\n\n// Batch execution: bexec:{action}:{names}:{timestamp}\nif (rawData.startsWith('bexec:')) {\n const parts = rawData.substring(6).split(':'); // action, names, timestamp\n const action = parts[0];\n const timestamp = parseInt(parts[parts.length - 1]);\n const namesStr = parts.slice(1, -1).join(':');\n const containerNames = namesStr.split(',');\n \n // Check 30-second timeout\n const currentTime = Math.floor(Date.now() / 1000);\n const elapsed = currentTime - timestamp;\n const isExpired = elapsed > 30;\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchExec: true,\n batchAction: action,\n containerNames,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: true\n }\n };\n}\n\nif (rawData === 'noop') {\n // No-op button (like page indicator)\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isNoop: true,\n isSelect: false,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\n\n\nif (rawData === 'batch:mode' || rawData.startsWith('batch:mode:')) {\n // Enter batch selection mode with optional page: batch:mode or batch:mode:{page}\n let page = 0;\n if (rawData.startsWith('batch:mode:')) {\n page = parseInt(rawData.substring(11)) || 0;\n }\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchMode: true,\n batchPage: page,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('batch:toggle:')) {\n // Toggle container selection: batch:toggle:{page}:{selected_csv}:{container_name}\n const parts = rawData.substring(13).split(':'); // Remove 'batch:toggle:'\n const page = parseInt(parts[0]) || 0; // Current page\n const selectedCsv = parts[1] || ''; // Currently selected (comma-separated)\n const toggleName = parts.slice(2).join(':'); // Container being toggled\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchToggle: true,\n batchPage: page,\n selectedCsv: selectedCsv === '' ? '' : selectedCsv,\n toggleName: toggleName,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('batch:exec:')) {\n // Execute batch action: batch:exec:{action}:{selected_csv}\n const parts = rawData.substring(11).split(':'); // Remove 'batch:exec:'\n const action = parts[0]; // update/start/stop/restart\n const selectedCsv = parts.slice(1).join(':'); // Comma-separated names\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchExec: true,\n action: action,\n selectedCsv: selectedCsv,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData === 'batch:clear') {\n // Clear selection\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchClear: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData === 'batch:cancel') {\n // Cancel batch mode\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchCancel: true,\n isCancel: false,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('uall:confirm:')) {\n // Update all confirmation: uall:confirm:{timestamp}\n const parts = rawData.substring(13).split(':'); // Remove 'uall:confirm:'\n const timestamp = parseInt(parts[0]);\n \n // Check 30-second timeout\n const currentTime = Math.floor(Date.now() / 1000);\n const elapsed = currentTime - timestamp;\n const isExpired = elapsed > 30;\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isUpdateAll: true,\n confirmAction: 'updateall',\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n isConfirm: false\n }\n };\n}\n\nif (rawData === 'uall:cancel') {\n // Update all cancel\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isUpdateAllCancel: true,\n isCancel: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n isConfirm: false,\n expired: false\n }\n };\n}\n\n// Legacy JSON format for backward compatibility\nlet data;\ntry {\n data = JSON.parse(rawData);\n} catch (e) {\n data = { a: 'x' }; // Treat parse error as cancel\n}\n\n// Check 2-minute timeout (120000ms)\nconst TWO_MINUTES = 120000;\nconst isExpired = data.t && (Date.now() - data.t > TWO_MINUTES);\n\n// Decode action: s=start, t=stop, r=restart, x=cancel\nconst actionMap = { s: 'start', t: 'stop', r: 'restart', x: 'cancel' };\nconst action = actionMap[data.a] || 'cancel';\n\n// Detect batch (c is array) vs single suggestion (c is string)\nconst isBatch = Array.isArray(data.c);\nconst containerIds = isBatch ? data.c : (data.c ? [data.c] : []);\n\nreturn {\n json: {\n queryId,\n chatId,\n messageId,\n action,\n containerIds,\n containerId: containerIds[0] || null,\n expired: isExpired,\n isBatch,\n isCancel: action === 'cancel',\n isSelect: false,\n isList: false,\n isAction: false\n }\n};" + "jsCode": "// Parse callback data from button click\n// Supports: select:{name}, list:{page}, action:{action}:{name}, confirm:{action}:{name}:{timestamp}, cancel:{name}, bstop:*, bexec:*, and legacy JSON format\nconst callback = $json.callback_query;\nconst rawData = callback.data;\nconst queryId = callback.id;\nconst chatId = callback.message.chat.id;\nconst messageId = callback.message.message_id;\n\n// Check for new colon-separated format\nif (rawData.startsWith('select:')) {\n // Container selection: select:{containerName}\n const containerName = rawData.substring(7); // Remove 'select:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isSelect: true,\n containerName,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('list:')) {\n // Pagination: list:{page}\n const page = parseInt(rawData.substring(5)) || 0; // Remove 'list:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isList: true,\n page,\n isSelect: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('action:')) {\n // Action execution: action:{action}:{containerName}\n const parts = rawData.substring(7).split(':'); // Remove 'action:'\n const action = parts[0];\n const containerName = parts.slice(1).join(':'); // Handle names with colons\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isAction: true,\n action,\n containerName,\n isSelect: false,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('confirm:')) {\n // Confirmation: confirm:{action}:{containerName}:{timestamp}\n const parts = rawData.substring(8).split(':'); // Remove 'confirm:'\n const action = parts[0]; // stop or update\n const timestamp = parseInt(parts[parts.length - 1]); // Last part is timestamp\n const containerName = parts.slice(1, -1).join(':'); // Everything between action and timestamp\n \n // Check 30-second timeout\n const currentTime = Math.floor(Date.now() / 1000);\n const elapsed = currentTime - timestamp;\n const isExpired = elapsed > 30;\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isConfirm: true,\n confirmAction: action,\n containerName,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false\n }\n };\n}\n\nif (rawData.startsWith('cancel:')) {\n // Cancel confirmation: cancel:{containerName}\n const containerName = rawData.substring(7); // Remove 'cancel:'\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isCancelConfirm: true,\n containerName,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\n// Batch stop confirmation: bstop:confirm:{names}:{timestamp} or bstop:cancel\nif (rawData.startsWith('bstop:')) {\n const rest = rawData.substring(6); // Remove 'bstop:'\n if (rest === 'cancel') {\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchStopCancel: true,\n isCancel: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n expired: false\n }\n };\n }\n // bstop:confirm:{names}:{timestamp}\n const parts = rest.split(':'); // confirm, names, timestamp\n if (parts[0] === 'confirm' && parts.length >= 3) {\n const timestamp = parseInt(parts[parts.length - 1]);\n const namesStr = parts.slice(1, -1).join(':'); // Handle container names with colons\n const containerNames = namesStr.split(',');\n \n // Check 30-second timeout\n const currentTime = Math.floor(Date.now() / 1000);\n const elapsed = currentTime - timestamp;\n const isExpired = elapsed > 30;\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchStopConfirm: true,\n containerNames,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false\n }\n };\n }\n}\n\n// Batch execution: bexec:{action}:{names}:{timestamp}\nif (rawData.startsWith('bexec:')) {\n const parts = rawData.substring(6).split(':'); // action, names, timestamp\n const action = parts[0];\n const timestamp = parseInt(parts[parts.length - 1]);\n const namesStr = parts.slice(1, -1).join(':');\n const containerNames = namesStr.split(',');\n \n // Check 30-second timeout\n const currentTime = Math.floor(Date.now() / 1000);\n const elapsed = currentTime - timestamp;\n const isExpired = elapsed > 30;\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchExec: true,\n batchAction: action,\n containerNames,\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: true\n }\n };\n}\n\nif (rawData === 'noop') {\n // No-op button (like page indicator)\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isNoop: true,\n isSelect: false,\n isList: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\n\n\nif (rawData === 'batch:mode' || rawData.startsWith('batch:mode:')) {\n // Enter batch selection mode with optional page: batch:mode or batch:mode:{page}\n let page = 0;\n if (rawData.startsWith('batch:mode:')) {\n page = parseInt(rawData.substring(11)) || 0;\n }\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchMode: true,\n batchPage: page,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('batch:nav:')) {\n // Navigate batch selection with preserved selection: batch:nav:{page}:{selected_csv}\n const parts = rawData.substring(10).split(':'); // Remove 'batch:nav:'\n const page = parseInt(parts[0]) || 0;\n const selectedCsv = parts.slice(1).join(':') || '';\n const selectedCount = selectedCsv ? selectedCsv.split(',').length : 0;\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchNav: true,\n batchPage: page,\n selectedCsv: selectedCsv,\n selectedCount: selectedCount,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('batch:toggle:')) {\n // Toggle container selection: batch:toggle:{page}:{selected_csv}:{container_name}\n const parts = rawData.substring(13).split(':'); // Remove 'batch:toggle:'\n const page = parseInt(parts[0]) || 0; // Current page\n const selectedCsv = parts[1] || ''; // Currently selected (comma-separated)\n const toggleName = parts.slice(2).join(':'); // Container being toggled\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchToggle: true,\n batchPage: page,\n selectedCsv: selectedCsv === '' ? '' : selectedCsv,\n toggleName: toggleName,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('batch:exec:')) {\n // Execute batch action: batch:exec:{action}:{selected_csv}\n const parts = rawData.substring(11).split(':'); // Remove 'batch:exec:'\n const action = parts[0]; // update/start/stop/restart\n const selectedCsv = parts.slice(1).join(':'); // Comma-separated names\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchExec: true,\n action: action,\n selectedCsv: selectedCsv,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData === 'batch:clear') {\n // Clear selection\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchClear: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData === 'batch:cancel') {\n // Cancel batch mode\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isBatchCancel: true,\n isCancel: false,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n expired: false\n }\n };\n}\n\nif (rawData.startsWith('uall:confirm:')) {\n // Update all confirmation: uall:confirm:{timestamp}\n const parts = rawData.substring(13).split(':'); // Remove 'uall:confirm:'\n const timestamp = parseInt(parts[0]);\n \n // Check 30-second timeout\n const currentTime = Math.floor(Date.now() / 1000);\n const elapsed = currentTime - timestamp;\n const isExpired = elapsed > 30;\n \n return {\n json: {\n queryId,\n chatId,\n messageId,\n isUpdateAll: true,\n confirmAction: 'updateall',\n timestamp,\n expired: isExpired,\n isSelect: false,\n isList: false,\n isAction: false,\n isCancel: false,\n isBatch: false,\n isConfirm: false\n }\n };\n}\n\nif (rawData === 'uall:cancel') {\n // Update all cancel\n return {\n json: {\n queryId,\n chatId,\n messageId,\n isUpdateAllCancel: true,\n isCancel: true,\n isSelect: false,\n isList: false,\n isAction: false,\n isBatch: false,\n isConfirm: false,\n expired: false\n }\n };\n}\n\n// Legacy JSON format for backward compatibility\nlet data;\ntry {\n data = JSON.parse(rawData);\n} catch (e) {\n data = { a: 'x' }; // Treat parse error as cancel\n}\n\n// Check 2-minute timeout (120000ms)\nconst TWO_MINUTES = 120000;\nconst isExpired = data.t && (Date.now() - data.t > TWO_MINUTES);\n\n// Decode action: s=start, t=stop, r=restart, x=cancel\nconst actionMap = { s: 'start', t: 'stop', r: 'restart', x: 'cancel' };\nconst action = actionMap[data.a] || 'cancel';\n\n// Detect batch (c is array) vs single suggestion (c is string)\nconst isBatch = Array.isArray(data.c);\nconst containerIds = isBatch ? data.c : (data.c ? [data.c] : []);\n\nreturn {\n json: {\n queryId,\n chatId,\n messageId,\n action,\n containerIds,\n containerId: containerIds[0] || null,\n expired: isExpired,\n isBatch,\n isCancel: action === 'cancel',\n isSelect: false,\n isList: false,\n isAction: false\n }\n};" }, "id": "code-parse-callback", "name": "Parse Callback Data", @@ -1163,6 +1163,30 @@ "renameOutput": true, "outputKey": "batchtoggle" }, + { + "id": "is-batch-nav", + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "batch-nav-true", + "leftValue": "={{ $json.isBatchNav }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "batchnav" + }, { "id": "is-batch-exec", "conditions": { @@ -5514,7 +5538,7 @@ }, { "parameters": { - "jsCode": "// Rebuild batch selection keyboard with updated checkmarks and pagination\nconst containers = $input.all();\nconst toggleData = $(\"Handle Batch Toggle\").item.json;\nconst selectedCsv = toggleData.selectedCsv || '';\nconst selectedCount = toggleData.selectedCount || 0;\nconst chatId = toggleData.chatId;\nconst messageId = toggleData.messageId;\nconst queryId = toggleData.queryId;\nconst page = toggleData.batchPage || 0;\n\n// Parse selection\nconst selectedSet = new Set(selectedCsv ? selectedCsv.split(',') : []);\n\n// Extract container data\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort: running first, then alphabetically\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n });\n\n// Pagination: 6 containers per page\nconst containersPerPage = 6;\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst displayContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard with checkmarks\nconst keyboard = displayContainers.map(c => {\n const isSelected = selectedSet.has(c.name);\n const icon = c.state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n const checkmark = isSelected ? '\\u2713 ' : '';\n return [\n {\n text: `${checkmark}${icon} ${c.name}`,\n callback_data: `batch:toggle:${page}:${selectedCsv}:${c.name}`\n }\n ];\n});\n\n// Add navigation row if needed\nif (totalPages > 1) {\n const navRow = [];\n if (page > 0) {\n navRow.push({ text: '\\u25C0\\uFE0F Previous', callback_data: `batch:mode:${page - 1}` });\n }\n navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: 'noop' });\n if (page < totalPages - 1) {\n navRow.push({ text: 'Next \\u25B6\\uFE0F', callback_data: `batch:mode:${page + 1}` });\n }\n keyboard.push(navRow);\n}\n\n// Add action buttons if selection exists\nif (selectedCount > 0) {\n keyboard.push([\n { text: `Update (${selectedCount})`, callback_data: `batch:exec:update:${selectedCsv}` },\n { text: `Stop (${selectedCount})`, callback_data: `batch:exec:stop:${selectedCsv}` }\n ]);\n keyboard.push([\n { text: 'Clear', callback_data: 'batch:clear' },\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n} else {\n keyboard.push([\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n}\n\nconst totalCount = sortedContainers.length;\nlet message = selectedCount > 0 \n ? `Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}\\n(Tap to select/deselect)`\n : 'Select containers for batch action:\\n(Tap to select/deselect)';\nif (totalPages > 1) {\n message += `\\n\\nShowing ${start + 1}-${Math.min(start + containersPerPage, totalCount)} of ${totalCount}`;\n}\n\nreturn {\n json: {\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n message: message,\n keyboard: {\n inline_keyboard: keyboard\n },\n selectedCsv: selectedCsv\n }\n};" + "jsCode": "// Rebuild batch selection keyboard with updated checkmarks and pagination\nconst containers = $input.all();\n// Try to get data from Handle Batch Toggle first, fall back to Prepare Batch Nav\nlet toggleData;\ntry {\n toggleData = $(\"Handle Batch Toggle\").item.json;\n} catch (e) {\n toggleData = $(\"Prepare Batch Nav\").item.json;\n}\nconst selectedCsv = toggleData.selectedCsv || '';\nconst selectedCount = toggleData.selectedCount || 0;\nconst chatId = toggleData.chatId;\nconst messageId = toggleData.messageId;\nconst queryId = toggleData.queryId;\nconst page = toggleData.batchPage || 0;\n\n// Parse selection\nconst selectedSet = new Set(selectedCsv ? selectedCsv.split(',') : []);\n\n// Extract container data\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort: running first, then alphabetically\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n });\n\n// Pagination: 6 containers per page\nconst containersPerPage = 6;\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst displayContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard with checkmarks\nconst keyboard = displayContainers.map(c => {\n const isSelected = selectedSet.has(c.name);\n const icon = c.state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n const checkmark = isSelected ? '\\u2713 ' : '';\n return [\n {\n text: `${checkmark}${icon} ${c.name}`,\n callback_data: `batch:toggle:${page}:${selectedCsv}:${c.name}`\n }\n ];\n});\n\n// Add navigation row if needed - use batch:nav to preserve selection\nif (totalPages > 1) {\n const navRow = [];\n if (page > 0) {\n navRow.push({ text: '\\u25C0\\uFE0F Previous', callback_data: `batch:nav:${page - 1}:${selectedCsv}` });\n }\n navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: 'noop' });\n if (page < totalPages - 1) {\n navRow.push({ text: 'Next \\u25B6\\uFE0F', callback_data: `batch:nav:${page + 1}:${selectedCsv}` });\n }\n keyboard.push(navRow);\n}\n\n// Add action buttons if selection exists\nif (selectedCount > 0) {\n keyboard.push([\n { text: `Update (${selectedCount})`, callback_data: `batch:exec:update:${selectedCsv}` },\n { text: `Stop (${selectedCount})`, callback_data: `batch:exec:stop:${selectedCsv}` }\n ]);\n keyboard.push([\n { text: 'Clear', callback_data: 'batch:clear' },\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n} else {\n keyboard.push([\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n}\n\nconst totalCount = sortedContainers.length;\nlet message = selectedCount > 0 \n ? `Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}\\n(Tap to select/deselect)`\n : 'Select containers for batch action:\\n(Tap to select/deselect)';\nif (totalPages > 1) {\n message += `\\n\\nShowing ${start + 1}-${Math.min(start + containersPerPage, totalCount)} of ${totalCount}`;\n}\n\nreturn {\n json: {\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n message: message,\n keyboard: {\n inline_keyboard: keyboard\n },\n selectedCsv: selectedCsv\n }\n};" }, "id": "code-rebuild-batch-select-keyboard", "name": "Rebuild Batch Select Keyboard", @@ -5765,6 +5789,19 @@ 2200, 5000 ] + }, + { + "parameters": { + "jsCode": "// Prepare data for batch navigation with preserved selection\nconst data = $(\"Parse Callback Data\").item.json;\nreturn {\n json: {\n queryId: data.queryId,\n chatId: data.chatId,\n messageId: data.messageId,\n batchPage: data.batchPage,\n selectedCsv: data.selectedCsv || '',\n selectedCount: data.selectedCount || 0\n }\n};" + }, + "id": "code-prepare-batch-nav", + "name": "Prepare Batch Nav", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2000, + 4200 + ] } ], "connections": { @@ -5946,6 +5983,13 @@ "index": 0 } ], + [ + { + "node": "Prepare Batch Nav", + "type": "main", + "index": 0 + } + ], [ { "node": "Handle Batch Exec", @@ -8411,6 +8455,17 @@ } ] ] + }, + "Prepare Batch Nav": { + "main": [ + [ + { + "node": "Fetch Containers For Toggle Update", + "type": "main", + "index": 0 + } + ] + ] } }, "pinData": {},