import { defineComponent } from 'vue'; import { escape, toVariableOption } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { IDataObject, IPinData, IRunData } from 'n8n-workflow'; import { mapStores } from 'pinia'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useNDVStore } from '@/stores/ndv.store'; import { isAllowedInDotNotation } from '@/plugins/codemirror/completions/utils'; export const jsonFieldCompletions = defineComponent({ computed: { ...mapStores(useNDVStore, useWorkflowsStore), }, methods: { /** * - Complete `x.first().json.` to `.field`. * - Complete `x.last().json.` to `.field`. * - Complete `x.all()[index].json.` to `.field`. * - Complete `x.item.json.` to `.field`. * * - Complete `x.first().json[` to `['field']`. * - Complete `x.last().json[` to `['field']`. * - Complete `x.all()[index].json[` to `['field']`. * - Complete `x.item.json[` to `['field']`. */ matcherJsonFieldCompletions( context: CompletionContext, matcher: string, variablesToValues: Record, ): CompletionResult | null { const pattern = new RegExp(`(${escape(matcher)})\..*`); const preCursor = context.matchBefore(pattern); if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; const inputNodeName = this.getInputNodeName(); if (!inputNodeName) return null; const [varName] = preCursor.text.split('.'); const originalValue = variablesToValues[varName]; if (!originalValue) return null; for (const accessor of ['first', 'last', 'item']) { /** * const x = $input.first(); // accessor in original value * x.json * * const x = $input; * x.first().json // accessor in preCursor.text */ if (originalValue.includes(accessor) || preCursor.text.includes(accessor)) { const jsonOutput = this.getJsonOutput(inputNodeName, { accessor }); if (!jsonOutput) return null; return this.toJsonFieldCompletions(preCursor, jsonOutput, matcher); } } if (originalValue.includes('all')) { const match = originalValue.match(/\$(input|\(.*\))\.all\(\)\[(?.+)\]$/); if (!match?.groups?.index) return null; const { index } = match.groups; const jsonOutput = this.getJsonOutput(inputNodeName, { index: Number(index) }); if (!jsonOutput) return null; return this.toJsonFieldCompletions(preCursor, jsonOutput, matcher); } return null; }, /** * - Complete `$input.first().json.` to `.field`. * - Complete `$input.last().json.` to `.field`. * - Complete `$input.all()[index].json.` to `.field`. * - Complete `$input.item.json.` to `.field`. * * - Complete `$input.first().json[` to `['field']`. * - Complete `$input.last().json[` to `['field']`. * - Complete `$input.all()[index].json[` to `['field']`. * - Complete `$input.item.json[` to `['field']`. */ inputJsonFieldCompletions(context: CompletionContext): CompletionResult | null { const patterns = { first: /\$input\.first\(\)\.json(\[|\.).*/, last: /\$input\.last\(\)\.json(\[|\.).*/, item: /\$input\.item\.json(\[|\.).*/, all: /\$input\.all\(\)\[(?\w+)\]\.json(\[|\.).*/, }; for (const [name, regex] of Object.entries(patterns)) { const preCursor = context.matchBefore(regex); if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue; const inputNodeName = this.getInputNodeName(); if (!inputNodeName) continue; if (name === 'first' || name === 'last') { const jsonOutput = this.getJsonOutput(inputNodeName, { accessor: name }); if (!jsonOutput) continue; return this.toJsonFieldCompletions(preCursor, jsonOutput, `$input.${name}().json`); } if (name === 'item') { const jsonOutput = this.getJsonOutput(inputNodeName, { accessor: 'item' }); if (!jsonOutput) continue; return this.toJsonFieldCompletions(preCursor, jsonOutput, '$input.item.json'); } if (name === 'all') { const match = preCursor.text.match(regex); if (!match?.groups?.index) continue; const { index } = match.groups; const jsonOutput = this.getJsonOutput(inputNodeName, { index: Number(index) }); if (!jsonOutput) continue; return this.toJsonFieldCompletions(preCursor, jsonOutput, `$input.all()[${index}].json`); } } return null; }, /** * Complete `$('nodeName').first().json.` to `.field`. * Complete `$('nodeName').last().json.` to `.field`. * Complete `$('nodeName').all()[index].json.` to `.field`. * Complete `$('nodeName').item.json.` to `.field`. * * Complete `$('nodeName').first().json[` to `['field']`. * Complete `$('nodeName').last().json[` to `['field']`. * Complete `$('nodeName').all()[index].json[` to `['field']`. * Complete `$('nodeName').item.json[` to `['field']`. */ selectorJsonFieldCompletions(context: CompletionContext): CompletionResult | null { const patterns = { first: /\$\((?['"][\w\s]+['"])\)\.first\(\)\.json(\[|\.).*/, last: /\$\((?['"][\w\s]+['"])\)\.last\(\)\.json(\[|\.).*/, item: /\$\((?['"][\w\s]+['"])\)\.item\.json(\[|\.).*/, all: /\$\((?['"][\w\s]+['"])\)\.all\(\)\[(?\w+)\]\.json(\[|\.).*/, }; for (const [name, regex] of Object.entries(patterns)) { const preCursor = context.matchBefore(regex); if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue; const match = preCursor.text.match(regex); if (!match?.groups?.quotedNodeName) continue; const { quotedNodeName } = match.groups; const selector = `$(${match.groups.quotedNodeName})`; if (name === 'first' || name === 'last') { const jsonOutput = this.getJsonOutput(quotedNodeName, { accessor: name }); if (!jsonOutput) continue; return this.toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.${name}().json`); } if (name === 'item') { const jsonOutput = this.getJsonOutput(quotedNodeName, { accessor: 'item' }); if (!jsonOutput) continue; return this.toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.item.json`); } if (name === 'all') { const match = preCursor.text.match(regex); if (!match?.groups?.index) continue; const { index } = match.groups; const jsonOutput = this.getJsonOutput(quotedNodeName, { index: Number(index) }); if (!jsonOutput) continue; return this.toJsonFieldCompletions( preCursor, jsonOutput, `${selector}.all()[${index}].json`, ); } } return null; }, getInputNodeName() { try { const activeNode = this.ndvStore.activeNode; if (activeNode) { const workflow = this.getCurrentWorkflow(); const input = workflow.connectionsByDestinationNode[activeNode.name]; return input.main[0][0].node; } } catch { return null; } }, /** * .json -> .json['field'] * .json -> .json.field */ toJsonFieldCompletions( preCursor: NonNullable>, jsonOutput: IDataObject, matcher: string, // e.g. `$input.first().json` or `x` (user-defined variable) ) { if ( /\.json\[/.test(preCursor.text) || new RegExp(`(${escape(matcher)})\\[`).test(preCursor.text) ) { const options: Completion[] = Object.keys(jsonOutput) .map((field) => `${matcher}['${field}']`) .map((label) => ({ label, info: this.$locale.baseText('codeNodeEditor.completer.json'), })); return { from: preCursor.from, options, }; } if ( /\.json\./.test(preCursor.text) || new RegExp(`(${escape(matcher)})\.`).test(preCursor.text) ) { const options: Completion[] = Object.keys(jsonOutput) .filter(isAllowedInDotNotation) .map((field) => `${matcher}.${field}`) .map(toVariableOption); return { from: preCursor.from, options, }; } return null; }, /** * Get the `json` output of a node from `runData` or `pinData`. * * `accessor` is the method or property used to find the item index. * `index` is only passed for `all()`. */ getJsonOutput(quotedNodeName: string, options?: { accessor?: string; index?: number }) { let nodeName = quotedNodeName; const isSingleQuoteWrapped = quotedNodeName.startsWith("'") && quotedNodeName.endsWith("'"); const isDoubleQuoteWrapped = quotedNodeName.startsWith('"') && quotedNodeName.endsWith('"'); if (isSingleQuoteWrapped) { nodeName = quotedNodeName.replace(/^'/, '').replace(/'$/, ''); } else if (isDoubleQuoteWrapped) { nodeName = quotedNodeName.replace(/^"/, '').replace(/"$/, ''); } const pinData: IPinData | undefined = this.workflowsStore.getPinData; const nodePinData = pinData?.[nodeName]; if (nodePinData) { try { let itemIndex = options?.index ?? 0; if (options?.accessor === 'last') { itemIndex = nodePinData.length - 1; } return nodePinData[itemIndex].json; } catch {} } const runData: IRunData | null = this.workflowsStore.getWorkflowRunData; const nodeRunData = runData && runData[nodeName]; if (!nodeRunData) return null; try { let itemIndex = options?.index ?? 0; if (options?.accessor === 'last') { const inputItems = nodeRunData[0].data!.main[0]!; itemIndex = inputItems.length - 1; } return nodeRunData[0].data!.main[0]![itemIndex].json; } catch { return null; } }, }, });