feat(Code Node): create Code node (#3965)

* Introduce node deprecation (#3930)

 Introduce node deprecation

* 🚧 Scaffold out Code node

* 👕 Fix lint

* 📘 Create types file

* 🚚 Rename theme

* 🔥 Remove unneeded prop

*  Override keybindings

*  Expand lintings

*  Create editor content getter

* 🚚 Ensure all helpers use `$`

*  Add autocompletion

*  Filter out welcome note node

*  Convey error line number

*  Highlight error line

*  Restore logging from node

*  More autocompletions

*  Streamline completions

* ✏️ Update placeholders

*  Update linter to new methods

* 🔥 Remove `$nodeItem` completions

*  Re-update placeholders

* 🎨 Fix formatting

* 📦 Update `package-lock.json`

*  Refresh with multi-line empty string

*  Account for syntax errors

* 🔥 Remove unneeded variant

*  Minor improvements

*  Add more autocompletions

* 🚚 Rename extension

* 🔥 Remove outdated comments

* 🚚 Rename field

*  More autocompletions

*  Fix up error display when empty text

* 🔥 Remove logging

*  More error validation

* 🐛 Fix `pairedItem` to `pairedItem()`

*  Add item to validation info

* 📦 Update `package-lock.json`

*  Leftover fixes

*  Set `insertNewlineAndIndent`

* 📦 Update `package-lock.json`

* 📦 Re-update `package-lock.json`

* 👕 Add lint exception

* 📘 Add type to mixin type

* Clean up comment

*  Refactor completion per new requirements

*  Adjust placeholders

*  Add `json` autocompletions for `$input`

* 🎨 Set border

*  Restore local completion source

*  Implement autocompletion for imports

*  Add `.*` to follow user typing on autocompletion

* 📘 Fix typings in autocompletions

* 👕 Add linting for use of `item()`

* 📦 Update `package-lock.json`

* 🐛 Fix for `$items(nodeName)[0]`

*  Filter down built-in modules list

*  Refactor error handling

*  Linter and validation improvements

*  Apply review feedback

* ♻️ More general refactorings

*  Add dot notation utility

* Customize input handler

*  Support `.json.` completions

*  Adjust placeholder

*  Sort imports

* 🔥 Remove blank rows addition

*  Add more error validation

* 📦 Update `package-lock.json`

*  Make date logging consistent

* 🔧 Adjust linting highlight range

*  Add line numbers to each item mode errors

*  Allow for links in error descriptions

*  More input validation

*  Expand linting to loops

*  Deprecate Function and Function Item nodes

* 🐛 Fix placeholder syntax

* 📘 Narrow down type

* 🚚 Rename using kebab-case

* 🔥 Remove `mapGetters`

* ✏️ Fix casing

*  Adjust import for type

* ✏️ Fix quotes

* 🐛 Fix `activeNode` reference

*  Use constant

* 🔥 Remove logging

* ✏️ Fix typo

*  Add missing `notice`

* ✏️ Add tags

* ✏️ Fix alias

* ✏️ Update copy

* 🔥 Remove wrong linting

* ✏️ Update copy

*  Add validation for `null`

*  Add validation for non-object and non-array

*  Add validation for non-array with json

* ✏️ Intentionally use wrong spelling

*  More validation

* ✏️ More copy updates

* ✏️ Placeholder updates

*  Restore spelling

*  Fix var name

* ✏️ More copy updates

*  Add luxon autocompletions

*  Make scrollable

*  Fix comma from merge conflict resolution

* 📦 Update `package-lock.json`

* 👕 Fix lint detail

* 🎨 Set font family

*  Bring in expressions fix

* ♻️ Address feedback

*  Exclude codemirror packages from render chunks

* 🐛 Fix placeholder not showing on first load

* feat(editor-ui): Replace `lezer` with `esprima` in client linter (#4192)

* 🔥 Remove addition from misresolved conflict

*  Replace `lezer` with `esprima` in client linter

*  Add missing key

* 📦 Update `package-lock.json`

*  Match dependencies

* 📦 Update `package-lock.json`

* 📦 Re-update `package-lock.json`

*  Match whitespace

* 🐛 Fix selection

*  Expand validation

* 🔥 Remove validation

* ✏️ Update copy

* 🚚 Move to constants

*  More `null` validation

*  Support `all()` with index to access item

*  Gloss over n8n syntax error

* 🎨 Re-style diagnostic button

* 🔥 Remove `item` as `itemAlias`

*  Add linting for `item.json` in single item mode

*  Refactor to add label info descriptions

*  More autocompletions

* 👕 Fix lint

*  Simplify typings

* feat(nodes-base): Multiline autocompletion for `code-node-editor` (#4220)

*  Simplify typings

*  Consolidate helpers in utils

*  Multiline autocompletion for standalone vars

* 🔥 Remove unneeded mixins

* ✏️ Update copy

* ✏️ Prep TODOs

*  Multiline completion for `$input.method` + `$input.item`

* 🔥 Remove unused method

* 🔥 Remove another unused method

* 🚚 Move luxon strings to helpers

*  Multiline autocompletion for methods output

*  Refactor to use optional chaining

* 👕 Fix lint

* ✏️ Update TODOs

*  Multiline autocompletion for `json` fields

* 📘 Add typings

*  De-duplicate callback to forEach

* 🐛 Fix autocompletions not working with leading whitespace

* 🌐 Apply i18n

* 👕 Fix lint

* :constructor: Second-period var usage completions

* 👕 Fix lint

* 👕 Add exception

*  Add completion telemetry

* 📘 Add typing

*  Major refactoring to organize

* 🐛 Fix multiline `.all()[index]`

* 🐛 Do not autoclose square brackets prior to `.json`

* 🐛 Fix accessor for multiline `jsonField` completions

*  Add completions for half-assignments

* 🐛 Fix `jsonField` completions for `x.json`

* ✏️ Improve comments

* 🐛 Fix `.json[field]` for multiline matches

*  Cleanup

* 📦 Update `package-lock.json`

* 👕 Fix lint

* 🐛 Rely on original value for custom matcher

*  Create `customMatcherJsonFieldCompletions` to simplify setup

* 🐛 Include selector in `customMatcherJsonField` completions

* ✏️ Make naming consistent

* ✏️ Add docline

*  Finish self-review cleanup

* 🔥 Remove outdated comment

* 📌 Pin luxon to major-minor

* ✏️ Fix typo

* 📦 Update `package-lock.json`

* 📦 Update `package-lock.json`

* 📦 Re-update `package-lock.json`

*  Add `luxon` for Gmail node

* 📦 Update `package-lock.json`

*  Replace Function with Code in suggested nodes

* 🐛 Fix `$prevNode` completions

* ✏️ Update `$execution.mode` copy

*  Separate luxon getters from methods

*  Adjusting linter to tolerate `.binary`

*  Adjust top-level item keys check

*  Anticipate user expecting `item` to pre-exist

*  Add linting for legacy item access

*  Add hint for attempted `items` access

*  Add keybinding for toggling comments

* ✏️ Update copy of `all`, `first`, `last` and `itemMatching`

* 🐛 Make `input.all()` etc act on copies

* 📦 Update `package-lock.json`

* 🐛 Fix guard in `$input.last()`

* ♻️ Address Jan's feedback

* ⬆️ Upgrade `eslint-plugin-n8n-nodes-base`

* 📦 Update `package-lock.json`

* 🔥 Remove unneeded exceptions

*  Restore placeholder logic

*  Add placeholders to client

*  Account for shadow item

* ✏️ More completion info labels

* 👕 Fix lint

* ✏️ Update copy

* ✏️ Update copy

* ✏️ More copy updates

* 📦 Update `package-lock.json`

*  Add more validation

*  Add placheolder on first load

* Replace `Cmd` with `Mod`

* 📦 Update `package-lock.json`
This commit is contained in:
Iván Ovejero 2022-10-13 14:28:02 +02:00 committed by GitHub
parent 12e821528b
commit 1db4fa2bf8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 5127 additions and 1400 deletions

3081
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -518,6 +518,10 @@ export interface IN8nUISettings {
type: string;
};
isNpmAvailable: boolean;
allowedModules: {
builtIn?: string;
external?: string;
};
enterprise: {
sharing: boolean;
workflowSharing: boolean;

View file

@ -328,6 +328,10 @@ class App {
type: config.getEnv('deployment.type'),
},
isNpmAvailable: false,
allowedModules: {
builtIn: process.env.NODE_FUNCTION_ALLOW_BUILTIN,
external: process.env.NODE_FUNCTION_ALLOW_EXTERNAL,
},
enterprise: {
sharing: false,
workflowSharing: false,

View file

@ -1046,7 +1046,7 @@ export function sendMessageToUI(source: string, messages: any[]) {
pushInstance.send(
'sendConsoleMessage',
{
source: `Node: "${source}"`,
source: `[Node: "${source}"]`,
messages,
},
this.sessionId,

View file

@ -1345,8 +1345,10 @@ export function constructExecutionMetaData(
export function normalizeItems(
executionData: INodeExecutionData | INodeExecutionData[],
): INodeExecutionData[] {
if (typeof executionData === 'object' && !Array.isArray(executionData))
executionData = [{ json: executionData as IDataObject }];
if (typeof executionData === 'object' && !Array.isArray(executionData)) {
executionData = executionData.json ? [executionData] : [{ json: executionData as IDataObject }];
}
if (executionData.every((item) => typeof item === 'object' && 'json' in item))
return executionData;
@ -2297,6 +2299,17 @@ export function getExecuteFunctions(
}
try {
if (additionalData.sendMessageToUI) {
args = args.map((arg) => {
// prevent invalid dates from being logged as null
if (arg.isLuxonDateTime && arg.invalidReason) return { ...arg };
// log valid dates in human readable format, as in browser
if (arg.isLuxonDateTime) return new Date(arg.ts).toString();
if (arg instanceof Date) return arg.toString();
return arg;
});
additionalData.sendMessageToUI(node.name, args);
}
} catch (error) {

View file

@ -25,6 +25,13 @@
"test:dev": "vitest"
},
"dependencies": {
"@codemirror/autocomplete": "^6.1.0",
"@codemirror/commands": "^6.1.0",
"@codemirror/lang-javascript": "^6.0.2",
"@codemirror/language": "^6.2.1",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.1.1",
"@codemirror/view": "^6.2.1",
"@fontsource/open-sans": "^4.5.0",
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
@ -32,6 +39,7 @@
"@fortawesome/vue-fontawesome": "^2.0.2",
"axios": "^0.21.1",
"dateformat": "^3.0.3",
"esprima": "^4.0.1",
"fast-json-stable-stringify": "^2.1.0",
"file-saver": "^2.0.2",
"flatted": "^3.2.4",
@ -44,7 +52,7 @@
"lodash.get": "^4.4.2",
"lodash.orderby": "^4.6.0",
"lodash.set": "^4.3.2",
"luxon": "^2.3.0",
"luxon": "~2.3.0",
"monaco-editor": "^0.30.1",
"n8n-design-system": "~0.37.0",
"n8n-workflow": "~0.119.0",
@ -76,6 +84,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/vue": "^6.6.1",
"@types/dateformat": "^3.0.0",
"@types/esprima": "^4.0.3",
"@types/express": "^4.17.6",
"@types/file-saver": "^2.0.1",
"@types/jsonpath": "^0.2.0",

View file

@ -726,6 +726,10 @@ export interface IN8nUISettings {
path: string;
};
onboardingCallPromptEnabled: boolean;
allowedModules: {
builtIn?: string[];
external?: string[];
};
enterprise: Record<string, boolean>;
deployment?: {
type: string;

View file

@ -0,0 +1,174 @@
<template>
<div ref="codeNodeEditor" class="ph-no-capture" />
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, ViewUpdate } from '@codemirror/view';
import { javascript } from '@codemirror/lang-javascript';
import { baseExtensions } from './baseExtensions';
import { linterExtension } from './linter';
import { completerExtension } from './completer';
import { CODE_NODE_EDITOR_THEME } from './theme';
import { workflowHelpers } from '../mixins/workflowHelpers'; // for json field completions
import { codeNodeEditorEventBus } from '@/event-bus/code-node-editor-event-bus';
import { CODE_NODE_TYPE } from '@/constants';
import { ALL_ITEMS_PLACEHOLDER, EACH_ITEM_PLACEHOLDER } from './constants';
export default mixins(linterExtension, completerExtension, workflowHelpers).extend({
name: 'code-node-editor',
props: {
mode: {
type: String,
validator: (value: string): boolean =>
['runOnceForAllItems', 'runOnceForEachItem'].includes(value),
},
isReadOnly: {
type: Boolean,
default: false,
},
jsCode: {
type: String,
},
},
data() {
return {
editor: null as EditorView | null,
linterCompartment: new Compartment(),
};
},
watch: {
mode() {
this.reloadLinter();
this.refreshPlaceholder();
},
},
computed: {
content(): string {
if (!this.editor) return '';
return this.editor.state.doc.toString();
},
placeholder(): string {
return {
runOnceForAllItems: ALL_ITEMS_PLACEHOLDER,
runOnceForEachItem: EACH_ITEM_PLACEHOLDER,
}[this.mode];
},
previousPlaceholder(): string {
return {
runOnceForAllItems: EACH_ITEM_PLACEHOLDER,
runOnceForEachItem: ALL_ITEMS_PLACEHOLDER,
}[this.mode];
},
},
methods: {
reloadLinter() {
if (!this.editor) return;
this.editor.dispatch({
effects: this.linterCompartment.reconfigure(this.linterExtension()),
});
},
refreshPlaceholder() {
if (!this.editor) return;
if (!this.content.trim() || this.content.trim() === this.previousPlaceholder) {
this.editor.dispatch({
changes: { from: 0, to: this.content.length, insert: this.placeholder },
});
}
},
highlightLine(line: number | 'final') {
if (!this.editor) return;
if (line === 'final') {
this.editor.dispatch({
selection: { anchor: this.content.trim().length },
});
return;
}
this.editor.dispatch({
selection: { anchor: this.editor.state.doc.line(line).from },
});
},
trackCompletion(viewUpdate: ViewUpdate) {
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
if (!completionTx) return;
try {
// @ts-ignore - undocumented fields
const { fromA, toB } = viewUpdate?.changedRanges[0];
const full = this.content.slice(fromA, toB);
const lastDotIndex = full.lastIndexOf('.');
let context = null;
let insertedText = null;
if (lastDotIndex === -1) {
context = '';
insertedText = full;
} else {
context = full.slice(0, lastDotIndex);
insertedText = full.slice(lastDotIndex + 1);
}
this.$telemetry.track('User autocompleted code', {
instance_id: this.$store.getters.instanceId,
node_type: CODE_NODE_TYPE,
field_name: this.mode === 'runOnceForAllItems' ? 'jsCodeAllItems' : 'jsCodeEachItem',
field_type: 'code',
context,
inserted_text: insertedText,
});
} catch (_) {}
},
},
destroyed() {
codeNodeEditorEventBus.$off('error-line-number', this.highlightLine);
},
mounted() {
codeNodeEditorEventBus.$on('error-line-number', this.highlightLine);
const stateBasedExtensions = [
this.linterCompartment.of(this.linterExtension()),
EditorState.readOnly.of(this.isReadOnly),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!viewUpdate.docChanged) return;
this.trackCompletion(viewUpdate);
this.$emit('valueChanged', this.content);
}),
];
// empty on first load, default param value
if (this.jsCode === '') {
this.$emit('valueChanged', this.placeholder);
}
const state = EditorState.create({
doc: this.jsCode === '' ? this.placeholder : this.jsCode,
extensions: [
...baseExtensions,
...stateBasedExtensions,
CODE_NODE_EDITOR_THEME,
javascript(),
this.autocompletionExtension(),
],
});
this.editor = new EditorView({
parent: this.$refs.codeNodeEditor as HTMLDivElement,
state,
});
},
});
</script>
<style lang="scss" scoped></style>

View file

@ -0,0 +1,40 @@
import {
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
} from '@codemirror/view';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { acceptCompletion, closeBrackets } from '@codemirror/autocomplete';
import { history, indentWithTab, insertNewlineAndIndent, toggleComment } from '@codemirror/commands';
import { lintGutter } from '@codemirror/lint';
import type { Extension } from '@codemirror/state';
import { customInputHandler } from './inputHandler';
const [_, bracketState] = closeBrackets() as readonly Extension[];
export const baseExtensions = [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter(),
lintGutter(),
[customInputHandler, bracketState],
dropCursor(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
keymap.of([
{ key: 'Enter', run: insertNewlineAndIndent },
{ key: 'Tab', run: acceptCompletion },
{ key: 'Enter', run: acceptCompletion },
{ key: 'Mod-/', run: toggleComment },
indentWithTab,
]),
EditorView.lineWrapping,
];

View file

@ -0,0 +1,274 @@
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import { autocompletion } from '@codemirror/autocomplete';
import { localCompletionSource } from '@codemirror/lang-javascript';
import { baseCompletions } from './completions/base.completions';
import { jsSnippets } from './completions/js.snippets';
import { requireCompletions } from './completions/require.completions';
import { executionCompletions } from './completions/execution.completions';
import { workflowCompletions } from './completions/workflow.completions';
import { prevNodeCompletions } from './completions/prevNode.completions';
import { luxonCompletions } from './completions/luxon.completions';
import { itemIndexCompletions } from './completions/itemIndex.completions';
import { itemFieldCompletions } from './completions/itemField.completions';
import { jsonFieldCompletions } from './completions/jsonField.completions';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { Extension } from '@codemirror/state';
import type { CodeNodeEditorMixin } from './types';
export const completerExtension = mixins(
Vue as CodeNodeEditorMixin,
baseCompletions,
requireCompletions,
executionCompletions,
workflowCompletions,
prevNodeCompletions,
luxonCompletions,
itemIndexCompletions,
itemFieldCompletions,
jsonFieldCompletions,
).extend({
methods: {
autocompletionExtension(): Extension {
return autocompletion({
compareCompletions: (a: Completion, b: Completion) => {
if (/\.json$|id$|id['"]\]$/.test(a.label)) return 0;
return a.label.localeCompare(b.label);
},
override: [
jsSnippets,
localCompletionSource,
// core
this.baseCompletions,
this.requireCompletions,
this.nodeSelectorCompletions,
this.prevNodeCompletions,
this.workflowCompletions,
this.executionCompletions,
// luxon
this.todayCompletions,
this.nowCompletions,
this.dateTimeCompltions,
// item index
this.inputCompletions,
this.selectorCompletions,
// item field
this.inputMethodCompletions,
this.selectorMethodCompletions,
// item json field
this.inputJsonFieldCompletions,
this.selectorJsonFieldCompletions,
// multiline
this.multilineCompletions,
],
});
},
/**
* Complete uses of variables to any of the supported completions.
*/
multilineCompletions(context: CompletionContext): CompletionResult | null {
if (!this.editor) return null;
let variablesToValues: Record<string, string> = {};
try {
variablesToValues = this.variablesToValues();
} catch (_) {
return null;
}
if (Object.keys(variablesToValues).length === 0) return null;
/**
* Complete uses of extended variables, i.e. variables having
* one or more dotted segments already.
*
* const x = $input;
* x.first(). -> .json
* x.first().json. -> .field
*/
const docLines = this.editor.state.doc.toString().split('\n');
const varNames = Object.keys(variablesToValues);
const uses = this.extendedUses(docLines, varNames);
for (const use of uses.itemField) {
const matcher = use.replace(/\.$/, '');
const completions = this.matcherItemFieldCompletions(context, matcher, variablesToValues);
if (completions) return completions;
}
for (const use of uses.jsonField) {
const matcher = use.replace(/(\.|\[)$/, '');
const completions = this.matcherJsonFieldCompletions(context, matcher, variablesToValues);
if (completions) return completions;
}
/**
* Complete uses of unextended variables, i.e. variables having
* no dotted segment already.
*
* const x = $input;
* x. -> .first()
*
* const x = $input.first();
* x. -> .json
*
* const x = $input.first().json;
* x. -> .field
*/
const SELECTOR_REGEX = /^\$\((?<quotedNodeName>['"][\w\s]+['"])\)$/; // $('nodeName')
const INPUT_METHOD_REGEXES = Object.values({
first: /\$input\.first\(\)$/,
last: /\$input\.last\(\)$/,
item: /\$input\.item$/,
all: /\$input\.all\(\)\[(?<index>\w+)\]$/,
});
const SELECTOR_METHOD_REGEXES = Object.values({
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)$/,
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)$/,
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item$/,
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]$/,
});
const INPUT_JSON_REGEXES = Object.values({
first: /\$input\.first\(\)\.json$/,
last: /\$input\.last\(\)\.json$/,
item: /\$input\.item\.json$/,
all: /\$input\.all\(\)\[(?<index>\w+)\]\.json$/,
});
const SELECTOR_JSON_REGEXES = Object.values({
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\.json$/,
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\.json$/,
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\.json$/,
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]\.json$/,
});
for (const [variable, value] of Object.entries(variablesToValues)) {
// core
if (value === '$execution') return this.executionCompletions(context, variable);
if (value === '$workflow') return this.workflowCompletions(context, variable);
if (value === '$prevNode') return this.prevNodeCompletions(context, variable);
// luxon
if (value === '$now') return this.nowCompletions(context, variable);
if (value === '$today') return this.todayCompletions(context, variable);
if (value === 'DateTime') return this.dateTimeCompltions(context, variable);
// item index
if (value === '$input') return this.inputCompletions(context, variable);
if (SELECTOR_REGEX.test(value)) return this.selectorCompletions(context, variable);
// json field
const inputJsonMatched = INPUT_JSON_REGEXES.some((regex) => regex.test(value));
const selectorJsonMatched = SELECTOR_JSON_REGEXES.some((regex) => regex.test(value));
if (inputJsonMatched || selectorJsonMatched) {
return this.matcherJsonFieldCompletions(context, variable, variablesToValues);
}
// item field
const inputMethodMatched = INPUT_METHOD_REGEXES.some((regex) => regex.test(value));
const selectorMethodMatched = SELECTOR_METHOD_REGEXES.some((regex) => regex.test(value));
if (inputMethodMatched || selectorMethodMatched) {
return this.matcherItemFieldCompletions(context, variable, variablesToValues);
}
}
return null;
},
// ----------------------------------
// helpers
// ----------------------------------
/**
* Create a map of variables and the values they point to.
*/
variablesToValues() {
return this.variableDeclarationLines().reduce<Record<string, string>>((acc, line) => {
const [left, right] = line.split('=');
const varName = left.replace(/(var|let|const)/, '').trim();
const varValue = right.replace(/;/, '').trim();
acc[varName] = varValue;
return acc;
}, {});
},
variableDeclarationLines() {
if (!this.editor) return [];
const docLines = this.editor.state.doc.toString().split('\n');
const isVariableDeclarationLine = (line: string) =>
['var', 'const', 'let'].some((varType) => line.startsWith(varType));
return docLines.filter(isVariableDeclarationLine);
},
/**
* Collect uses of variables pointing to n8n syntax if they have been extended.
*
* x.first().
* x.first().json.
* x.json.
*/
extendedUses(docLines: string[], varNames: string[]) {
return docLines.reduce<{ itemField: string[]; jsonField: string[] }>(
(acc, cur) => {
varNames.forEach((varName) => {
const accessorPattern = `(${varName}.first\\(\\)|${varName}.last\\(\\)|${varName}.item|${varName}.all\\(\\)\\[\\w+\\]).*`;
const methodMatch = cur.match(new RegExp(accessorPattern));
if (methodMatch) {
if (/json(\.|\[)$/.test(methodMatch[0])) {
acc.jsonField.push(methodMatch[0]);
} else {
acc.itemField.push(methodMatch[0]);
}
}
const jsonPattern = `^${varName}\\.json(\\.|\\[)$`;
const jsonMatch = cur.match(new RegExp(jsonPattern));
if (jsonMatch) {
acc.jsonField.push(jsonMatch[0]);
}
});
return acc;
},
{ itemField: [], jsonField: [] },
);
},
},
});

View file

@ -0,0 +1,117 @@
import Vue from 'vue';
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '../constants';
import { addVarType } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { INodeUi } from '@/Interface';
import type { CodeNodeEditorMixin } from '../types';
function getAutocompletableNodeNames(nodes: INodeUi[]) {
return nodes
.filter((node: INodeUi) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type))
.map((node: INodeUi) => node.name);
}
export const baseCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* - Complete `$` to `$execution $input $prevNode $runIndex $workflow $now $today
* $jmespath $('nodeName')` in both modes.
* - Complete `$` to `$json $binary $itemIndex` in single-item mode.
*/
baseCompletions(context: CompletionContext): CompletionResult | null {
const preCursor = context.matchBefore(/\$\w*/);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES: Completion[] = [
{
label: '$execution',
info: this.$locale.baseText('codeNodeEditor.completer.$execution'),
},
{ label: '$input', info: this.$locale.baseText('codeNodeEditor.completer.$input') },
{
label: '$prevNode',
info: this.$locale.baseText('codeNodeEditor.completer.$prevNode'),
},
{
label: '$workflow',
info: this.$locale.baseText('codeNodeEditor.completer.$workflow'),
},
{
label: '$now',
info: this.$locale.baseText('codeNodeEditor.completer.$now'),
},
{
label: '$today',
info: this.$locale.baseText('codeNodeEditor.completer.$today'),
},
{
label: '$jmespath()',
info: this.$locale.baseText('codeNodeEditor.completer.$jmespath'),
},
{
label: '$runIndex',
info: this.$locale.baseText('codeNodeEditor.completer.$runIndex'),
},
];
const options: Completion[] = TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES.map(addVarType);
options.push(
...getAutocompletableNodeNames(this.$store.getters.allNodes).map((nodeName) => {
return {
label: `$('${nodeName}')`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.$()', {
interpolate: { nodeName },
}),
};
}),
);
if (this.mode === 'runOnceForEachItem') {
const TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE = [
{ label: '$json' },
{ label: '$binary' },
{
label: '$itemIndex',
info: this.$locale.baseText('codeNodeEditor.completer.$itemIndex'),
},
];
options.push(...TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE.map(addVarType));
}
return {
from: preCursor.from,
options,
};
},
/**
* Complete `$(` to `$('nodeName')`.
*/
nodeSelectorCompletions(context: CompletionContext): CompletionResult | null {
const preCursor = context.matchBefore(/\$\(.*/);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = getAutocompletableNodeNames(this.$store.getters.allNodes).map(
(nodeName) => {
return {
label: `$('${nodeName}')`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.$()', {
interpolate: { nodeName },
}),
};
},
);
return {
from: preCursor.from,
options,
};
},
},
});

View file

@ -0,0 +1,42 @@
import Vue from 'vue';
import { addVarType, escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const executionCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `$execution.` to `.id .mode .resumeUrl`
*/
executionCompletions(
context: CompletionContext,
matcher = '$execution',
): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = [
{
label: `${matcher}.id`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.id'),
},
{
label: `${matcher}.mode`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.mode'),
},
{
label: `${matcher}.resumeUrl`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
},
},
});

View file

@ -0,0 +1,169 @@
import Vue from 'vue';
import { addVarType, escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const itemFieldCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* - Complete `x.first().` to `.json .binary`
* - Complete `x.last().` to `.json .binary`
* - Complete `x.all()[index].` to `.json .binary`
* - Complete `x.item.` to `.json .binary`.
*/
matcherItemFieldCompletions(
context: CompletionContext,
matcher: string,
variablesToValues: Record<string, string>,
) {
const preCursor = context.matchBefore(new RegExp(`${escape(matcher)}\..*`));
if (!preCursor) return null;
const [varName] = preCursor.text.split('.');
const originalValue = variablesToValues[varName];
if (!originalValue) return null;
const options: Completion[] = [
{
label: `${matcher}.json`,
info: this.$locale.baseText('codeNodeEditor.completer.json'),
},
{
label: `${matcher}.binary`,
info: this.$locale.baseText('codeNodeEditor.completer.binary'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
},
/**
* - Complete `$input.first().` to `.json .binary`.
* - Complete `$input.last().` to `.json .binary`.
* - Complete `$input.all()[index].` to `.json .binary`.
* - Complete `$input.item.` to `.json .binary`.
*/
inputMethodCompletions(context: CompletionContext): CompletionResult | null {
const patterns = {
first: /\$input\.first\(\)\..*/,
last: /\$input\.last\(\)\..*/,
item: /\$input\.item\..*/,
all: /\$input\.all\(\)\[(?<index>\w+)\]\..*/,
};
for (const [name, regex] of Object.entries(patterns)) {
const preCursor = context.matchBefore(regex);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue;
let replacementBase = '';
if (name === 'item') replacementBase = '$input.item';
if (name === 'first') replacementBase = '$input.first()';
if (name === 'last') replacementBase = '$input.last()';
if (name === 'all') {
const match = preCursor.text.match(regex);
if (!match?.groups?.index) continue;
const { index } = match.groups;
replacementBase = `$input.all()[${index}]`;
}
const options: Completion[] = [
{
label: `${replacementBase}.json`,
info: this.$locale.baseText('codeNodeEditor.completer.json'),
},
{
label: `${replacementBase}.binary`,
info: this.$locale.baseText('codeNodeEditor.completer.binary'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
}
return null;
},
/**
* - Complete `$('nodeName').first().` to `.json .binary`.
* - Complete `$('nodeName').last().` to `.json .binary`.
* - Complete `$('nodeName').all()[index].` to `.json .binary`.
* - Complete `$('nodeName').item.` to `.json .binary`.
*/
selectorMethodCompletions(
context: CompletionContext,
matcher: string | null = null,
): CompletionResult | null {
const patterns = {
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\..*/,
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\..*/,
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\..*/,
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]\..*/,
};
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);
let start = '';
if (!matcher && match?.groups?.quotedNodeName) {
start = `$(${match.groups.quotedNodeName})`;
}
let replacementBase = '';
if (name === 'item') replacementBase = `${start}.item`;
if (name === 'first') replacementBase = `${start}.first()`;
if (name === 'last') replacementBase = `${start}.last()`;
if (name === 'all') {
const match = preCursor.text.match(regex);
if (!match?.groups?.index) continue;
replacementBase = `${start}.all()[${match.groups.index}]`;
}
const options: Completion[] = [
{
label: `${replacementBase}.json`,
info: this.$locale.baseText('codeNodeEditor.completer.json'),
},
{
label: `${replacementBase}.binary`,
info: this.$locale.baseText('codeNodeEditor.completer.binary'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
}
return null;
},
},
});

View file

@ -0,0 +1,137 @@
import Vue from 'vue';
import { escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const itemIndexCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* - Complete `$input.` to `.first() .last() .all() .itemMatching()` in all-items mode.
* - Complete `$input.` to `.item` in single-item mode.
*/
inputCompletions(context: CompletionContext, matcher = '$input'): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = [];
if (this.mode === 'runOnceForAllItems') {
options.push(
{
label: `${matcher}.first()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.first'),
},
{
label: `${matcher}.last()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.last'),
},
{
label: `${matcher}.all()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.all'),
},
{
label: `${matcher}.itemMatching()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.itemMatching'),
},
);
}
if (this.mode === 'runOnceForEachItem') {
options.push({
label: `${matcher}.item`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.$input.item'),
});
}
return {
from: preCursor.from,
options,
};
},
/**
* - Complete `$('nodeName').` to `.params .context` in both modes.
* - Complete `$('nodeName').` to `.first() .last() .all() .itemMatching()` in all-items mode.
* - Complete `$('nodeName').` to `.item` in single-item mode.
*/
selectorCompletions(context: CompletionContext, matcher: string | null = null) {
const pattern =
matcher === null
? /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\..*/ // $('nodeName').
: new RegExp(`${matcher}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const match = preCursor.text.match(pattern);
let replacementBase = '';
if (matcher === null && match?.groups?.quotedNodeName) {
replacementBase = `$(${match.groups.quotedNodeName})`;
} else if (matcher) {
replacementBase = matcher;
}
const options: Completion[] = [
{
label: `${replacementBase}.params`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.selector.params'),
},
{
label: `${replacementBase}.context`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.selector.context'),
},
];
if (this.mode === 'runOnceForAllItems') {
options.push(
{
label: `${replacementBase}.first()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.first'),
},
{
label: `${replacementBase}.last()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.last'),
},
{
label: `${replacementBase}.all()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.all'),
},
{
label: `${replacementBase}.itemMatching()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.selector.itemMatching'),
},
);
}
if (this.mode === 'runOnceForEachItem') {
options.push({
label: `${replacementBase}.item`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.selector.item'),
});
}
return {
from: preCursor.from,
options,
};
},
},
});

View file

@ -0,0 +1,13 @@
import { snippets } from '@codemirror/lang-javascript';
import { completeFromList, snippetCompletion } from "@codemirror/autocomplete";
/**
* https://github.com/codemirror/lang-javascript/blob/main/src/snippets.ts
*/
export const jsSnippets = completeFromList([
...snippets.filter((snippet) => snippet.label !== 'class'),
snippetCompletion('console.log(${arg})', { label: 'console.log()' }),
snippetCompletion('DateTime', { label: 'DateTime' }),
snippetCompletion('Interval', { label: 'Interval' }),
snippetCompletion('Duration', { label: 'Duration' }),
]);

View file

@ -0,0 +1,302 @@
import Vue from 'vue';
import { isAllowedInDotNotation, escape, toVariableOption } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { IDataObject, IPinData, IRunData } from 'n8n-workflow';
import type { CodeNodeEditorMixin } from '../types';
export const jsonFieldCompletions = (Vue as CodeNodeEditorMixin).extend({
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<string, string>,
): 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\(\)\[(?<index>.+)\]$/);
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\(\)\[(?<index>\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: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\.json(\[|\.).*/,
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\.json(\[|\.).*/,
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\.json(\[|\.).*/,
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\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.$store.getters.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<ReturnType<CompletionContext['matchBefore']>>,
jsonOutput: IDataObject,
matcher: string, // e.g. `$input.first().json` or `x` (user-defined variable)
) {
if (preCursor.text.endsWith('.json[') || preCursor.text.endsWith(`${matcher}[`)) {
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 (preCursor.text.endsWith('.json.') || preCursor.text.endsWith(`${matcher}.`)) {
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 }) {
const nodeName = quotedNodeName.replace(/['"]/g, '');
const pinData: IPinData | undefined = this.$store.getters.pinData;
const nodePinData = pinData && 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.$store.getters.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;
}
},
},
});

View file

@ -0,0 +1,284 @@
import Vue from 'vue';
import { escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const luxonCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `$today.` with luxon `DateTime` instance methods.
*/
todayCompletions(context: CompletionContext, matcher = '$today'): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = this.luxonInstanceGetters().map(([getter, description]) => {
return {
label: `${matcher}.${getter}`,
type: 'function',
info: description,
};
});
options.push(...this.luxonInstanceMethods().map(([method, description]) => {
return {
label: `${matcher}.${method}()`,
type: 'function',
info: description,
};
}));
return {
from: preCursor.from,
options,
};
},
/**
* Complete `$now.` with luxon `DateTime` instance methods.
*/
nowCompletions(context: CompletionContext, matcher = '$now'): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = this.luxonInstanceGetters().map(([getter, description]) => {
return {
label: `${matcher}.${getter}`,
type: 'function',
info: description,
};
});
options.push(...this.luxonInstanceMethods().map(([method, description]) => {
return {
label: `${matcher}.${method}()`,
type: 'function',
info: description,
};
}));
return {
from: preCursor.from,
options,
};
},
/**
* Complete `DateTime` with luxon `DateTime` static methods.
*/
dateTimeCompltions(context: CompletionContext, matcher = 'DateTime'): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = this.luxonDateTimeStaticMethods().map(
([method, description]) => {
return {
label: `DateTime.${method}()`,
type: 'function',
info: description,
};
},
);
return {
from: preCursor.from,
options,
};
},
luxonDateTimeStaticMethods() {
return Object.entries({
now: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.now'),
local: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.local'),
utc: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc'),
fromJSDate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromJSDate',
),
fromMillis: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis',
),
fromSeconds: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds',
),
fromObject: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromObject',
),
fromISO: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO',
),
fromRFC2822: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromRFC2822',
),
fromHTTP: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromHTTP',
),
fromFormat: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormat',
),
fromSQL: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSQL',
),
invalid: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.invalid',
),
isDateTime: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime',
),
});
},
luxonInstanceGetters() {
return Object.entries({
isValid: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.isValid'),
invalidReason: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.invalidReason',
),
invalidExplanation: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.invalidExplanation',
),
locale: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.locale'),
numberingSystem: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.numberingSystem',
),
outputCalendar: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.outputCalendar',
),
zone: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.zone'),
zoneName: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.zoneName'),
year: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'),
quarter: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.quarter'),
month: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'),
day: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'),
hour: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'),
minute: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'),
second: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'),
millisecond: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.millisecond',
),
weekYear: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekYear'),
weekNumber: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weekNumber',
),
weekday: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekday'),
ordinal: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.ordinal'),
monthShort: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.monthShort',
),
monthLong: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.monthLong',
),
weekdayShort: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weekdayShort',
),
weekdayLong: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weekdayLong',
),
offset: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.offset'),
offsetNumber: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNumber',
),
offsetNameShort: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNameShort',
),
offsetNameLong: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNameLong',
),
isOffsetFixed: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.isOffsetFixed',
),
isInDST: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInDST'),
isInLeapYear: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear',
),
daysInMonth: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.daysInMonth',
),
daysInYear: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.daysInYear',
),
weeksInWeekYear: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weeksInWeekYear',
),
});
},
luxonInstanceMethods() {
return Object.entries({
toUTC: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUTC'),
toLocal: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocal'),
setZone: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.setZone'),
setLocale: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.setLocale',
),
set: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.set'),
plus: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.plus'),
minus: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.minus'),
startOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.startOf'),
endOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.endOf'),
toFormat: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toFormat'),
toLocaleString: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toLocaleString',
),
toLocaleParts: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toLocaleParts',
),
toISO: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISO'),
toISODate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toISODate',
),
toISOWeekDate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toISOWeekDate',
),
toISOTime: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toISOTime',
),
toRFC2822: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRFC2822',
),
toHTTP: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toHTTP'),
toSQLDate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toSQLDate',
),
toSQLTime: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toSQLTime',
),
toSQL: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQL'),
toString: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toString'),
valueOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.valueOf'),
toMillis: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toMillis'),
toSeconds: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toSeconds',
),
toUnixInteger: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toUnixInteger',
),
toJSON: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJSON'),
toBSON: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toBSON'),
toObject: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toObject'),
toJsDate: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJsDate'),
diff: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.diff'),
diffNow: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.diffNow'),
until: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.until'),
hasSame: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.hasSame'),
equals: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.equals'),
toRelative: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRelative',
),
toRelativeCalendar: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRelativeCalendar',
),
min: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.min'),
max: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.max'),
});
},
},
});

View file

@ -0,0 +1,46 @@
import Vue from 'vue';
import { addVarType } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
const DEFAULT_MATCHER = '$prevNode';
const escape = (str: string) => str.replace('$', '\\$');
export const prevNodeCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `$prevNode.` to `.name .outputIndex .runIndex`.
*/
prevNodeCompletions(
context: CompletionContext,
matcher = DEFAULT_MATCHER,
): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = [
{
label: `${matcher}.name`,
info: this.$locale.baseText('codeNodeEditor.completer.$prevNode.name'),
},
{
label: `${matcher}.outputIndex`,
info: this.$locale.baseText('codeNodeEditor.completer.$prevNode.outputIndex'),
},
{
label: `${matcher}.runIndex`,
info: this.$locale.baseText('codeNodeEditor.completer.$prevNode.runIndex'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
},
},
});

View file

@ -0,0 +1,41 @@
import Vue from 'vue';
import { AUTOCOMPLETABLE_BUILT_IN_MODULES } from '../constants';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const requireCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `req` to `require('moduleName')` based on modules available in context.
*/
requireCompletions(context: CompletionContext): CompletionResult | null {
const preCursor = context.matchBefore(/req.*/);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = [];
const allowedModules = this.$store.getters['settings/allowedModules'];
const toOption = (moduleName: string) => ({
label: `require('${moduleName}');`,
type: 'variable',
});
if (allowedModules?.builtIn?.includes('*')) {
options.push(...AUTOCOMPLETABLE_BUILT_IN_MODULES.map(toOption));
} else if (allowedModules?.builtIn?.length > 0) {
options.push(...allowedModules.builtIn.map(toOption));
}
if (allowedModules?.external?.length > 0) {
options.push(...allowedModules.external.map(toOption));
}
return {
from: preCursor.from,
options,
};
},
},
});

View file

@ -0,0 +1,44 @@
import Vue from 'vue';
import { addVarType } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
const escape = (str: string) => str.replace('$', '\\$');
export const workflowCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `$workflow.` to `.id .name .active`.
*/
workflowCompletions(
context: CompletionContext,
matcher = '$workflow',
): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = [
{
label: `${matcher}.id`,
info: this.$locale.baseText('codeNodeEditor.completer.$workflow.id'),
},
{
label: `${matcher}.name`,
info: this.$locale.baseText('codeNodeEditor.completer.$workflow.name'),
},
{
label: `${matcher}.active`,
info: this.$locale.baseText('codeNodeEditor.completer.$workflow.active'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
},
},
});

View file

@ -0,0 +1,53 @@
import { STICKY_NODE_TYPE } from '@/constants';
import type { Diagnostic } from '@codemirror/lint';
export const NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION = [STICKY_NODE_TYPE];
export const AUTOCOMPLETABLE_BUILT_IN_MODULES = [
'console',
'constants',
'crypto',
'dns',
'dns/promises',
'fs',
'fs/promises',
'http',
'http2',
'https',
'inspector',
'module',
'os',
'path',
'process',
'readline',
'url',
'util',
'zlib',
];
export const DEFAULT_LINTER_SEVERITY: Diagnostic['severity'] = 'error';
export const DEFAULT_LINTER_DELAY_IN_MS = 300;
/**
* Length of the start of the script wrapper, used as offset for the linter to find a location in source text.
*/
export const OFFSET_FOR_SCRIPT_WRAPPER = 'module.exports = async function() {'.length;
export const ALL_ITEMS_PLACEHOLDER = `
// Loop over input items and add a new field
// called 'myNewField' to the JSON of each one
for (const item of $input.all()) {
item.json.myNewField = 1;
}
return $input.all();
`.trim();
export const EACH_ITEM_PLACEHOLDER = `
// Add a new field called 'myNewField' to the
// JSON of the item
$input.item.json.myNewField = 1;
return $input.item;
`.trim();

View file

@ -0,0 +1,42 @@
import { completionStatus, insertBracket } from '@codemirror/autocomplete';
import { codePointAt, codePointSize } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
/**
* Customized input handler to prevent token autoclosing in certain cases.
*
* Based on: https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79
*/
export const customInputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
if (view.composing || view.state.readOnly) return false;
// customization: do not autoclose tokens during autocompletion
if (completionStatus(view.state) !== null) return false;
const selection = view.state.selection.main;
// customization: do not autoclose square brackets prior to `.json`
if (
insert === '[' &&
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
) {
return false;
}
if (
insert.length > 2 ||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
from !== selection.from ||
to !== selection.to
) {
return false;
}
const transaction = insertBracket(view.state, insert);
if (!transaction) return false;
view.dispatch(transaction);
return true;
});

View file

@ -0,0 +1,577 @@
import Vue from 'vue';
import { Diagnostic, linter as createLinter } from '@codemirror/lint';
import * as esprima from 'esprima';
import {
DEFAULT_LINTER_DELAY_IN_MS,
DEFAULT_LINTER_SEVERITY,
OFFSET_FOR_SCRIPT_WRAPPER,
} from './constants';
import { walk } from './utils';
import type { EditorView } from '@codemirror/view';
import type { Node } from 'estree';
import type { CodeNodeEditorMixin, RangeNode } from './types';
export const linterExtension = (Vue as CodeNodeEditorMixin).extend({
methods: {
linterExtension() {
return createLinter(this.lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
},
lintSource(editorView: EditorView): Diagnostic[] {
const doc = editorView.state.doc.toString();
const script = `module.exports = async function() {${doc}\n}()`;
let ast: esprima.Program | null = null;
try {
ast = esprima.parseScript(script, { range: true });
} catch (syntaxError) {
let line;
try {
line = editorView.state.doc.line(syntaxError.lineNumber);
return [
{
from: line.from,
to: line.to,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.bothModes.syntaxError'),
},
];
} catch (error) {
/**
* For invalid (e.g. half-written) n8n syntax, esprima errors with an off-by-one line number for the final line. In future, we should add full linting for n8n syntax before parsing JS.
*/
return [];
}
}
const lintings: Diagnostic[] = [];
/**
* Lint for incorrect `.item()` instead of `.item` in `runOnceForEachItem` mode
*
* $input.item() -> $input.item
*/
if (this.mode === 'runOnceForEachItem') {
const isItemCall = (node: Node) =>
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'item';
walk(ast, isItemCall).forEach((node) => {
const [start, end] = this.getRange(node);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.allItems.itemCall'),
actions: [
{
name: 'Fix',
apply(view, _, to) {
view.dispatch({ changes: { from: end - '()'.length, to } });
},
},
],
});
});
}
/**
* Lint for `$json`, `$binary` and `$itemIndex` unavailable in `runOnceForAllItems` mode
*
* $json -> <removed>
*/
if (this.mode === 'runOnceForAllItems') {
const isUnavailableVarInAllItems = (node: Node) =>
node.type === 'Identifier' && ['$json', '$binary', '$itemIndex'].includes(node.name);
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
const [start, end] = this.getRange(node);
const varName = this.getText(node);
if (!varName) return;
const message = [
`\`${varName}\``,
this.$locale.baseText('codeNodeEditor.linter.allItems.unavailableVar'),
].join(' ');
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message,
actions: [
{
name: 'Remove',
apply(view, from, to) {
view.dispatch({ changes: { from, to } });
},
},
],
});
});
}
/**
* Lint for `.item` unavailable in `runOnceForAllItems` mode
*
* $input.all().item -> <removed>
*/
if (this.mode === 'runOnceForAllItems') {
type TargetNode = RangeNode & { property: RangeNode };
const isUnavailablePropertyinAllItems = (node: Node) =>
node.type === 'MemberExpression' &&
node.computed === false &&
node.property.type === 'Identifier' &&
node.property.name === 'item';
walk<TargetNode>(ast, isUnavailablePropertyinAllItems).forEach((node) => {
const [start, end] = this.getRange(node.property);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.allItems.unavailableProperty'),
actions: [
{
name: 'Remove',
apply(view) {
view.dispatch({ changes: { from: start - '.'.length, to: end } });
},
},
],
});
});
}
/**
* Lint for `item` (legacy var from Function Item node) being accessed
* in `runOnceForEachItem` mode, unless user-defined `item`.
*
* item. -> $input.item.json.
*/
if (this.mode === 'runOnceForEachItem' && !/(let|const|var) item =/.test(script)) {
type TargetNode = RangeNode & { object: RangeNode & { name: string } };
const isItemAccess = (node: Node) =>
node.type === 'MemberExpression' &&
node.computed === false &&
node.object.type === 'Identifier' &&
node.object.name === 'item';
walk<TargetNode>(ast, isItemAccess).forEach((node) => {
const [start, end] = this.getRange(node.object);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.eachItem.legacyItemAccess'),
actions: [
{
name: 'Fix',
apply(view, from, to) {
// prevent second insertion of unknown origin
if (view.state.doc.toString().slice(from, to).includes('$input.item.json')) {
return;
}
view.dispatch({ changes: { from: start, to: end } });
view.dispatch({ changes: { from, insert: '$input.item.json' } });
},
},
],
});
});
}
/**
* Lint for `.first()`, `.last()`, `.all()` and `.itemMatching()`
* unavailable in `runOnceForEachItem` mode
*
* $input.first()
* $input.last()
* $input.all()
* $input.itemMatching()
*/
if (this.mode === 'runOnceForEachItem') {
type TargetNode = RangeNode & { property: RangeNode & { name: string } };
const isUnavailableMethodinEachItem = (node: Node) =>
node.type === 'MemberExpression' &&
node.computed === false &&
node.property.type === 'Identifier' &&
['first', 'last', 'all', 'itemMatching'].includes(node.property.name);
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
const [start, end] = this.getRange(node.property);
const message = [
`\`.${node.property.name}()\``,
this.$locale.baseText('codeNodeEditor.linter.eachItem.unavailableMethod'),
].join(' ');
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message,
});
});
}
/**
* Lint for `.itemMatching()` called with no argument in `runOnceForAllItems` mode
*
* $input.itemMatching()
*/
if (this.mode === 'runOnceForAllItems') {
type TargetNode = RangeNode & { callee: RangeNode & { property: RangeNode } };
const isItemMatchingCallWithoutArg = (node: Node) =>
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'itemMatching' &&
node.arguments.length === 0;
walk<TargetNode>(ast, isItemMatchingCallWithoutArg).forEach((node) => {
const [start, end] = this.getRange(node.callee.property);
lintings.push({
from: start,
to: end + '()'.length,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.allItems.itemMatchingNoArg'),
});
});
}
/**
* Lint for `.all()` called with argument in `runOnceForAllItems` mode
*
* $input.itemMatching()
*/
if (this.mode === 'runOnceForAllItems') {
type TargetNode = RangeNode & { callee: RangeNode & { property: RangeNode } };
const isItemMatchingCallWithoutArg = (node: Node) =>
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'all' &&
node.arguments.length !== 0;
walk<TargetNode>(ast, isItemMatchingCallWithoutArg).forEach((node) => {
const [start, end] = this.getRange(node.callee.property);
lintings.push({
from: start,
to: end + '()'.length,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.allItems.allCalledWithArg'),
});
});
}
/**
* Lint for `.first()` or `.last()` called with argument in `runOnceForAllItems` mode
*
* $input.itemMatching()
*/
if (this.mode === 'runOnceForAllItems') {
type TargetNode = RangeNode & {
callee: RangeNode & { property: { name: string } & RangeNode };
};
const isItemMatchingCallWithoutArg = (node: Node) =>
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
['first', 'last'].includes(node.callee.property.name) &&
node.arguments.length !== 0;
walk<TargetNode>(ast, isItemMatchingCallWithoutArg).forEach((node) => {
const [start, end] = this.getRange(node.callee.property);
const message = [
`\`.${node.callee.property.name}()\``,
this.$locale.baseText('codeNodeEditor.linter.allItems.firstOrLastCalledWithArg'),
].join(' ');
lintings.push({
from: start,
to: end + '()'.length,
severity: DEFAULT_LINTER_SEVERITY,
message,
});
});
}
/**
* Lint for empty (i.e. no value) return
*
* return -> <no autofix>
*/
const isEmptyReturn = (node: Node) =>
node.type === 'ReturnStatement' && node.argument === null;
const emptyReturnMessage =
this.mode === 'runOnceForAllItems'
? this.$locale.baseText('codeNodeEditor.linter.allItems.emptyReturn')
: this.$locale.baseText('codeNodeEditor.linter.eachItem.emptyReturn');
walk<RangeNode>(ast, isEmptyReturn).forEach((node) => {
const [start, end] = node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: emptyReturnMessage,
});
});
/**
* Lint for array return in `runOnceForEachItem` mode
*
* return [] -> <no autofix>
*/
if (this.mode === 'runOnceForEachItem') {
const isArrayReturn = (node: Node) =>
node.type === 'ReturnStatement' &&
node.argument !== null &&
node.argument !== undefined &&
node.argument.type === 'ArrayExpression';
walk<RangeNode>(ast, isArrayReturn).forEach((node) => {
const [start, end] = this.getRange(node);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.eachItem.returnArray'),
});
});
}
/**
* Lint for direct access to item property (i.e. not using `json`)
* in `runOnceForAllItems` mode
*
* item.myField = 123 -> item.json.myField = 123;
* const a = item.myField -> const a = item.json.myField;
*/
if (this.mode === 'runOnceForAllItems') {
type TargetNode = RangeNode & {
left: { declarations: Array<{ id: { type: string; name: string } }> };
};
const isForOfStatement = (node: Node) =>
node.type === 'ForOfStatement' &&
node.left.type === 'VariableDeclaration' &&
node.left.declarations.length === 1 &&
node.left.declarations[0].type === 'VariableDeclarator' &&
node.left.declarations[0].id.type === 'Identifier';
const found = walk<TargetNode>(ast, isForOfStatement);
if (found.length === 1) {
const itemAlias = found[0].left.declarations[0].id.name;
/**
* for (const item of $input.all()) {
* const item = {}; // shadow item
* }
*/
const isShadowItemVar = (node: Node) =>
node.type === 'VariableDeclarator' &&
node.id.type === 'Identifier' &&
node.id.name === 'item' &&
node.init !== null;
const shadowFound = walk(ast, isShadowItemVar);
let shadowStart: undefined | number;
if (shadowFound.length > 0) {
const [shadow] = shadowFound;
const [_shadowStart] = this.getRange(shadow);
shadowStart = _shadowStart;
}
const isDirectAccessToItem = (node: Node) =>
node.type === 'MemberExpression' &&
node.object.type === 'Identifier' &&
node.object.name === itemAlias &&
node.property.type === 'Identifier' &&
!['json', 'binary'].includes(node.property.name);
walk(ast, isDirectAccessToItem).forEach((node) => {
const [start, end] = this.getRange(node);
if (shadowStart && start > shadowStart) return; // skip shadow item
const varName = this.getText(node);
if (!varName) return;
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText(
'codeNodeEditor.linter.bothModes.directAccess.itemProperty',
),
actions: [
{
name: 'Fix',
apply(view, from, to) {
// prevent second insertion of unknown origin
if (view.state.doc.toString().slice(from, to).includes('.json')) return;
view.dispatch({ changes: { from: from + itemAlias.length, insert: '.json' } });
},
},
],
});
});
}
}
/**
* Lint for direct access to item property (i.e. not using `json`)
* in `runOnceForEachItem` mode
*
* $input.item.myField = 123 -> $input.item.json.myField = 123;
* const a = $input.item.myField -> const a = $input.item.json.myField;
*/
if (this.mode === 'runOnceForEachItem') {
type TargetNode = RangeNode & { object: { property: RangeNode } };
const isDirectAccessToItemSubproperty = (node: Node) =>
node.type === 'MemberExpression' &&
node.object.type === 'MemberExpression' &&
node.object.property.type === 'Identifier' &&
node.object.property.name === 'item' &&
node.property.type === 'Identifier' &&
!['json', 'binary'].includes(node.property.name);
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
const varName = this.getText(node);
if (!varName) return;
const [start, end] = this.getRange(node);
const [_, fixEnd] = this.getRange(node.object.property);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText(
'codeNodeEditor.linter.bothModes.directAccess.itemProperty',
),
actions: [
{
name: 'Fix',
apply(view, from, to) {
// prevent second insertion of unknown origin
if (view.state.doc.toString().slice(from, to).includes('.json')) return;
view.dispatch({ changes: { from: fixEnd, insert: '.json' } });
},
},
],
});
});
}
/**
* Lint for direct access to `first()` or `last()` output (i.e. not using `json`)
*
* $input.first().myField -> $input.first().json.myField
*/
type TargetNode = RangeNode & { object: RangeNode };
const isDirectAccessToFirstOrLastCall = (node: Node) =>
node.type === 'MemberExpression' &&
node.property.type === 'Identifier' &&
!['json', 'binary'].includes(node.property.name) &&
node.object.type === 'CallExpression' &&
node.object.arguments.length === 0 &&
node.object.callee.type === 'MemberExpression' &&
node.object.callee.property.type === 'Identifier' &&
['first', 'last'].includes(node.object.callee.property.name);
walk<TargetNode>(ast, isDirectAccessToFirstOrLastCall).forEach((node) => {
const [start, end] = this.getRange(node);
const [_, fixEnd] = this.getRange(node.object);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText(
'codeNodeEditor.linter.bothModes.directAccess.firstOrLastCall',
),
actions: [
{
name: 'Fix',
apply(view, from, to) {
// prevent second insertion of unknown origin
if (view.state.doc.toString().slice(from, to).includes('.json')) return;
view.dispatch({ changes: { from: fixEnd, insert: '.json' } });
},
},
],
});
});
return lintings;
},
// ----------------------------------
// helpers
// ----------------------------------
getText(node: RangeNode) {
if (!this.editor) return null;
const [start, end] = this.getRange(node);
return this.editor.state.doc.toString().slice(start, end);
},
getRange(node: RangeNode) {
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
},
},
});

View file

@ -0,0 +1,122 @@
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { EditorView } from '@codemirror/view';
import { tags } from '@lezer/highlight';
/**
* Based on Tomorrow theme by Chris Kempson
* https://github.com/vadimdemedes/thememirror/blob/main/source/themes/tomorrow.ts
*/
const BASE_STYLING = {
fontSize: '0.8em',
fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important",
background: '#FFFFFF',
foreground: '#4D4D4C',
caret: '#AEAFAD',
selection: '#D6D6D6',
gutterBackground: '#FFFFFF',
gutterForeground: '#4D4D4C80',
lineHighlight: '#EFEFEF',
maxHeight: '400px',
tooltip: {
maxWidth: '300px',
lineHeight: '1.3em',
},
diagnosticButton: {
backgroundColor: 'inherit',
lineHeight: '1em',
textDecoration: 'underline',
marginLeft: '0.2em',
cursor: 'pointer',
},
};
const HIGHLIGHT_STYLING = [
{
tag: tags.comment,
color: '#8E908C',
},
{
tag: [tags.variableName, tags.self, tags.propertyName, tags.attributeName, tags.regexp],
color: '#C82829',
},
{
tag: [tags.number, tags.bool, tags.null],
color: '#F5871F',
},
{
tag: [tags.className, tags.typeName, tags.definition(tags.typeName)],
color: '#C99E00',
},
{
tag: [tags.string, tags.special(tags.brace)],
color: '#718C00',
},
{
tag: tags.operator,
color: '#3E999F',
},
{
tag: [tags.definition(tags.propertyName), tags.function(tags.variableName)],
color: '#4271AE',
},
{
tag: tags.keyword,
color: '#8959A8',
},
{
tag: tags.derefOperator,
color: '#4D4D4C',
},
];
const cssStyleDeclaration = getComputedStyle(document.documentElement);
export const CODE_NODE_EDITOR_THEME = [
EditorView.theme({
'&': {
backgroundColor: BASE_STYLING.background,
color: BASE_STYLING.foreground,
'font-size': BASE_STYLING.fontSize,
border: cssStyleDeclaration.getPropertyValue('--border-base'),
borderRadius: cssStyleDeclaration.getPropertyValue('--border-radius-base'),
},
'.cm-content': {
fontFamily: BASE_STYLING.fontFamily,
caretColor: BASE_STYLING.caret,
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: BASE_STYLING.caret,
},
'.cm-tooltip': {
maxWidth: BASE_STYLING.tooltip.maxWidth,
lineHeight: BASE_STYLING.tooltip.lineHeight,
},
'&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: BASE_STYLING.selection,
},
'.cm-activeLine': {
backgroundColor: BASE_STYLING.lineHighlight,
},
'.cm-gutters': {
backgroundColor: BASE_STYLING.gutterBackground,
color: BASE_STYLING.gutterForeground,
},
'.cm-activeLineGutter': {
backgroundColor: BASE_STYLING.lineHighlight,
},
'.cm-scroller': {
overflow: 'auto',
maxHeight: BASE_STYLING.maxHeight,
},
'.cm-diagnosticAction': {
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
color: cssStyleDeclaration.getPropertyValue('--color-primary'),
lineHeight: BASE_STYLING.diagnosticButton.lineHeight,
textDecoration: BASE_STYLING.diagnosticButton.textDecoration,
marginLeft: BASE_STYLING.diagnosticButton.marginLeft,
cursor: BASE_STYLING.diagnosticButton.cursor,
},
}),
syntaxHighlighting(HighlightStyle.define(HIGHLIGHT_STYLING)),
];

View file

@ -0,0 +1,15 @@
import type { EditorView } from '@codemirror/view';
import type { I18nClass } from '@/plugins/i18n';
import type { Workflow } from 'n8n-workflow';
import type { Node } from 'estree';
export type CodeNodeEditorMixin = Vue.VueConstructor<
Vue & {
$locale: I18nClass;
editor: EditorView | null;
mode: 'runOnceForAllItems' | 'runOnceForEachItem';
getCurrentWorkflow(): Workflow;
}
>;
export type RangeNode = Node & { range: [number, number] };

View file

@ -0,0 +1,46 @@
import type { Completion } from '@codemirror/autocomplete';
import type { Node } from 'estree';
import type { RangeNode } from './types';
export function walk<T extends RangeNode>(
node: Node,
test: (node: Node) => boolean,
found: Node[] = [],
) {
if (test(node)) found.push(node);
for (const key in node) {
if (!(key in node)) continue;
// @ts-ignore
const child = node[key];
if (child === null || typeof child !== 'object') continue;
if (Array.isArray(child)) {
child.forEach((node) => walk(node, test, found));
} else {
walk(child, test, found);
}
}
return found as T[];
}
export const isAllowedInDotNotation = (str: string) => {
const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g;
return !DOT_NOTATION_BANNED_CHARS.test(str);
};
export const escape = (str: string) =>
str
.replace('$', '\\$')
.replace('(', '\\(')
.replace(')', '\\)')
.replace('[', '\\[')
.replace(']', '\\]');
export const toVariableOption = (label: string) => ({ label, type: 'variable' });
export const addVarType = (option: Completion) => ({ ...option, type: 'variable' });

View file

@ -1,7 +1,7 @@
<template>
<div>
<div class="error-header">
<div class="error-message">{{ $locale.baseText('nodeErrorView.error') + ': ' + getErrorMessage() }}</div>
<div class="error-message">{{ getErrorMessage() }}</div>
<div class="error-description" v-if="error.description" v-html="getErrorDescription()"></div>
</div>
<details>
@ -108,6 +108,7 @@ import {
INodePropertyOptions,
INodeTypeDescription,
} from 'n8n-workflow';
import { sanitizeHtml } from '@/utils';
export default mixins(
copyPaste,
@ -146,16 +147,22 @@ export default mixins(
},
getErrorDescription (): string {
if (!this.error.context || !this.error.context.descriptionTemplate) {
return this.error.description;
}
return this.replacePlaceholders(this.error.context.parameter, this.error.context.descriptionTemplate);
},
getErrorMessage (): string {
if (!this.error.context || !this.error.context.messageTemplate) {
return this.error.message;
return sanitizeHtml(this.error.description);
}
return this.replacePlaceholders(this.error.context.parameter, this.error.context.messageTemplate);
const parameterName = this.parameterDisplayName(this.error.context.parameter);
return sanitizeHtml(this.error.context.descriptionTemplate.replace(/%%PARAMETER%%/g, parameterName));
},
getErrorMessage (): string {
const baseErrorMessage = this.$locale.baseText('nodeErrorView.error') + ': ';
if (!this.error.context || !this.error.context.messageTemplate) {
return baseErrorMessage + this.error.message;
}
const parameterName = this.parameterDisplayName(this.error.context.parameter);
return baseErrorMessage + this.error.context.messageTemplate.replace(/%%PARAMETER%%/g, parameterName);
},
parameterDisplayName(path: string, fullPath = true) {
try {

View file

@ -410,6 +410,10 @@ export default Vue.extend({
visibility: hidden;
}
.double-width {
left: 90%;
}
.dragButtonContainer {
position: absolute;
top: -12px;

View file

@ -32,6 +32,7 @@
:hideInputAndOutput="activeNodeType === null"
:position="isTriggerNode && !showTriggerPanel ? 0 : undefined"
:isDraggable="!isTriggerNode"
:hasDoubleWidth="activeNodeType && activeNodeType.parameterPane === 'wide'"
:nodeType="activeNodeType"
@close="close"
@init="onPanelsInit"

View file

@ -818,7 +818,7 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers).extend({
.node-parameters-wrapper {
height: 100%;
overflow-y: auto;
padding: 0 20px 200px 20px;
padding: 0 20px;
}
&.dragging {

View file

@ -68,7 +68,15 @@
@valueChanged="expressionUpdated"
></text-edit>
<div v-if="isEditor === true" class="code-edit clickable ph-no-capture" @click="displayEditDialog()">
<code-node-editor
v-if="getArgument('editor') === 'codeNodeEditor' && isCodeNode(node)"
:mode="node.parameters.mode"
:jsCode="node.parameters.jsCode"
:isReadOnly="isReadOnly"
@valueChanged="valueChanged"
/>
<div v-else-if="isEditor === true" class="code-edit clickable ph-no-capture" @click="displayEditDialog()">
<prism-editor
v-if="!codeEditDialogVisible"
:lineNumbers="true"
@ -315,6 +323,7 @@ import ResourceLocator from '@/components/ResourceLocator/ResourceLocator.vue';
// @ts-ignore
import PrismEditor from 'vue-prism-editor';
import TextEdit from '@/components/TextEdit.vue';
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
@ -325,6 +334,7 @@ import { isResourceLocatorValue } from '@/typeGuards';
import mixins from 'vue-typed-mixins';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import { mapGetters } from 'vuex';
import { CODE_NODE_TYPE } from '@/constants';
import { PropType } from 'vue';
export default mixins(
@ -337,6 +347,7 @@ export default mixins(
name: 'parameter-input',
components: {
CodeEdit,
CodeNodeEditor,
ExpressionEdit,
NodeCredentials,
CredentialsSelect,
@ -889,6 +900,9 @@ export default mixins(
this.$emit('focus');
},
isCodeNode(node: INodeUi): boolean {
return node.type === CODE_NODE_TYPE;
},
rgbaToHex (value: string): string | null {
// Convert rgba to hex from: https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
const valueMatch = (value as string).match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*(\d+(\.\d+)?)\)$/);
@ -1148,7 +1162,6 @@ export default mixins(
}
.list-option {
max-width: 340px;
margin: 6px 0;
white-space: normal;
padding-right: 20px;

View file

@ -67,6 +67,14 @@ export default Vue.extend({
return false;
}
if (
this.parameter.typeOptions &&
this.parameter.typeOptions.editor &&
this.parameter.typeOptions.editor === 'codeNodeEditor'
) {
return false;
}
if (this.showOptions === true) {
return true;
}

View file

@ -20,6 +20,7 @@ import {
import mixins from 'vue-typed-mixins';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { getTriggerNodeServiceName } from '../helpers';
import { codeNodeEditorEventBus } from '@/event-bus/code-node-editor-event-bus';
export const pushConnection = mixins(
externalHooks,
@ -215,6 +216,14 @@ export const pushConnection = mixins(
const runDataExecutedErrorMessage = this.$getExecutionError(runDataExecuted.data);
const lineNumber = runDataExecuted &&
runDataExecuted.data &&
runDataExecuted.data.resultData &&
runDataExecuted.data.resultData.error &&
runDataExecuted.data.resultData.error.lineNumber;
codeNodeEditorEventBus.$emit('error-line-number', lineNumber || 'final');
const workflow = this.getCurrentWorkflow();
if (runDataExecuted.waitTill !== undefined) {
const {

View file

@ -74,6 +74,7 @@ export const CUSTOM_NODES_DOCS_URL = `https://docs.n8n.io/integrations/creating-
// node types
export const BAMBOO_HR_NODE_TYPE = 'n8n-nodes-base.bambooHr';
export const CALENDLY_TRIGGER_NODE_TYPE = 'n8n-nodes-base.calendlyTrigger';
export const CODE_NODE_TYPE = 'n8n-nodes-base.code';
export const CRON_NODE_TYPE = 'n8n-nodes-base.cron';
export const CLEARBIT_NODE_TYPE = 'n8n-nodes-base.clearbit';
export const FUNCTION_NODE_TYPE = 'n8n-nodes-base.function';

View file

@ -0,0 +1,3 @@
import Vue from 'vue';
export const codeNodeEditorEventBus = new Vue();

View file

@ -102,6 +102,9 @@ const module: Module<ISettingsState, IRootState> = {
isNpmAvailable: (state): boolean => {
return state.settings.isNpmAvailable;
},
allowedModules: (state): { builtIn?: string[]; external?: string[] } => {
return state.settings.allowedModules;
},
isQueueModeEnabled: (state): boolean => {
return state.settings.executionMode === 'queue';
},
@ -129,6 +132,12 @@ const module: Module<ISettingsState, IRootState> = {
setCommunityNodesFeatureEnabled(state: ISettingsState, isEnabled: boolean) {
state.settings.communityNodesEnabled = isEnabled;
},
setAllowedModules(state, allowedModules: { builtIn?: string, external?: string }) {
state.settings.allowedModules = {
...(allowedModules.builtIn && { builtIn: allowedModules.builtIn.split(',') }),
...(allowedModules.external && { external: allowedModules.external.split(',') }),
};
},
},
actions: {
async getSettings(context: ActionContext<ISettingsState, IRootState>) {
@ -154,6 +163,7 @@ const module: Module<ISettingsState, IRootState> = {
context.commit('setIsNpmAvailable', settings.isNpmAvailable, {root: true});
context.commit('versions/setVersionNotificationSettings', settings.versionNotifications, {root: true});
context.commit('setCommunityNodesFeatureEnabled', settings.communityNodesEnabled === true);
context.commit('settings/setAllowedModules', settings.allowedModules, {root: true});
},
async fetchPromptsData(context: ActionContext<ISettingsState, IRootState>) {
if (!context.getters.isTelemetryEnabled) {

View file

@ -1,5 +1,5 @@
import { CALENDLY_TRIGGER_NODE_TYPE, CLEARBIT_NODE_TYPE, COMPANY_SIZE_1000_OR_MORE, COMPANY_SIZE_500_999, CRON_NODE_TYPE, ELASTIC_SECURITY_NODE_TYPE, EMAIL_SEND_NODE_TYPE, EXECUTE_COMMAND_NODE_TYPE, FINANCE_WORK_AREA, FUNCTION_NODE_TYPE, GITHUB_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, IF_NODE_TYPE, ITEM_LISTS_NODE_TYPE, IT_ENGINEERING_WORK_AREA, JIRA_TRIGGER_NODE_TYPE, MICROSOFT_EXCEL_NODE_TYPE, MICROSOFT_TEAMS_NODE_TYPE, PAGERDUTY_NODE_TYPE, PRODUCT_WORK_AREA, QUICKBOOKS_NODE_TYPE, SALESFORCE_NODE_TYPE, SALES_BUSINESSDEV_WORK_AREA, SECURITY_WORK_AREA, SEGMENT_NODE_TYPE, SET_NODE_TYPE, SLACK_NODE_TYPE, SPREADSHEET_FILE_NODE_TYPE, SWITCH_NODE_TYPE, WEBHOOK_NODE_TYPE, XERO_NODE_TYPE, COMPANY_SIZE_KEY, WORK_AREA_KEY, CODING_SKILL_KEY, COMPANY_TYPE_KEY, ECOMMERCE_COMPANY_TYPE, MSP_COMPANY_TYPE, PERSONAL_COMPANY_TYPE, AUTOMATION_GOAL_KEY, OTHER_AUTOMATION_GOAL, NOT_SURE_YET_GOAL, CUSTOMER_INTEGRATIONS_GOAL, CUSTOMER_SUPPORT_GOAL, FINANCE_ACCOUNTING_GOAL, ZENDESK_TRIGGER_NODE_TYPE, WOOCOMMERCE_TRIGGER_NODE_TYPE, SALES_MARKETING_GOAL, HUBSPOT_TRIGGER_NODE_TYPE, HR_GOAL, WORKABLE_TRIGGER_NODE_TYPE, OPERATIONS_GOAL, PRODUCT_GOAL, NOTION_TRIGGER_NODE_TYPE, SECURITY_GOAL, THE_HIVE_TRIGGER_NODE_TYPE, ZENDESK_NODE_TYPE, SERVICENOW_NODE_TYPE, JIRA_NODE_TYPE, BAMBOO_HR_NODE_TYPE, GOOGLE_SHEETS_NODE_TYPE } from '@/constants';
import { CALENDLY_TRIGGER_NODE_TYPE, CLEARBIT_NODE_TYPE, COMPANY_SIZE_1000_OR_MORE, COMPANY_SIZE_500_999, CRON_NODE_TYPE, ELASTIC_SECURITY_NODE_TYPE, EMAIL_SEND_NODE_TYPE, EXECUTE_COMMAND_NODE_TYPE, FINANCE_WORK_AREA, GITHUB_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, IF_NODE_TYPE, ITEM_LISTS_NODE_TYPE, IT_ENGINEERING_WORK_AREA, JIRA_TRIGGER_NODE_TYPE, MICROSOFT_EXCEL_NODE_TYPE, MICROSOFT_TEAMS_NODE_TYPE, PAGERDUTY_NODE_TYPE, PRODUCT_WORK_AREA, QUICKBOOKS_NODE_TYPE, SALESFORCE_NODE_TYPE, SALES_BUSINESSDEV_WORK_AREA, SECURITY_WORK_AREA, SEGMENT_NODE_TYPE, SET_NODE_TYPE, SLACK_NODE_TYPE, SPREADSHEET_FILE_NODE_TYPE, SWITCH_NODE_TYPE, WEBHOOK_NODE_TYPE, XERO_NODE_TYPE, COMPANY_SIZE_KEY, WORK_AREA_KEY, CODING_SKILL_KEY, COMPANY_TYPE_KEY, ECOMMERCE_COMPANY_TYPE, MSP_COMPANY_TYPE, PERSONAL_COMPANY_TYPE, AUTOMATION_GOAL_KEY, OTHER_AUTOMATION_GOAL, NOT_SURE_YET_GOAL, CUSTOMER_INTEGRATIONS_GOAL, CUSTOMER_SUPPORT_GOAL, FINANCE_ACCOUNTING_GOAL, ZENDESK_TRIGGER_NODE_TYPE, WOOCOMMERCE_TRIGGER_NODE_TYPE, SALES_MARKETING_GOAL, HUBSPOT_TRIGGER_NODE_TYPE, HR_GOAL, WORKABLE_TRIGGER_NODE_TYPE, OPERATIONS_GOAL, PRODUCT_GOAL, NOTION_TRIGGER_NODE_TYPE, SECURITY_GOAL, THE_HIVE_TRIGGER_NODE_TYPE, ZENDESK_NODE_TYPE, SERVICENOW_NODE_TYPE, JIRA_NODE_TYPE, BAMBOO_HR_NODE_TYPE, GOOGLE_SHEETS_NODE_TYPE, CODE_NODE_TYPE } from '@/constants';
import { IPermissions, IPersonalizationSurveyAnswersV1, IPersonalizationSurveyAnswersV2, IPersonalizationSurveyAnswersV3, IPersonalizationSurveyVersions, IUser } from '@/Interface';
import { ILogInStatus, IRole, IUserPermissions } from "@/Interface";
@ -165,7 +165,7 @@ function getPersonalizationV2(answers: IPersonalizationSurveyAnswersV2 | IPerson
// slot 2 data transformation
if (codingSkill !== null && codingSkill >= 4) {
nodeTypes = nodeTypes.concat(FUNCTION_NODE_TYPE);
nodeTypes = nodeTypes.concat(CODE_NODE_TYPE);
} else {
nodeTypes = nodeTypes.concat(ITEM_LISTS_NODE_TYPE);
}
@ -272,7 +272,7 @@ function getPersonalizationV1(answers: IPersonalizationSurveyAnswersV1) {
}
if (codingSkill !== null && codingSkill >= 4) {
nodeTypes = nodeTypes.concat(FUNCTION_NODE_TYPE);
nodeTypes = nodeTypes.concat(CODE_NODE_TYPE);
}
else {
nodeTypes = nodeTypes.concat(ITEM_LISTS_NODE_TYPE);

View file

@ -1,5 +1,10 @@
{
"_reusableBaseText.cancel": "Cancel",
"_reusableBaseText.codeNodeEditor.linter.useJson": "Access the properties of an item under `.json`, e.g. `item.json`",
"_reusableBaseText.codeNodeEditor.completer.all": "Get all items",
"_reusableBaseText.codeNodeEditor.completer.first": "Get the first item",
"_reusableBaseText.codeNodeEditor.completer.last": "Get the last item",
"_reusableBaseText.codeNodeEditor.completer.itemMatching": "Get the item matching a specified input item",
"_reusableBaseText.name": "Name",
"_reusableBaseText.save": "Save",
"_reusableBaseText.dismiss": "Dismiss",
@ -93,6 +98,142 @@
"binaryDataDisplay.noDataFoundToDisplay": "No data found to display",
"binaryDataDisplay.yourBrowserDoesNotSupport": "Your browser does not support the video element. Kindly update it to latest version.",
"codeEdit.edit": "Edit",
"codeNodeEditor.completer.$()": "Output data of the {nodeName} node",
"codeNodeEditor.completer.$execution": "Information about the current execution",
"codeNodeEditor.completer.$execution.id": "The ID of the current execution",
"codeNodeEditor.completer.$execution.mode": "How the execution was triggered: 'test' or 'production'",
"codeNodeEditor.completer.$execution.resumeUrl": "Used when using the 'wait' node to wait for a webhook. The webhook to call to resume execution",
"codeNodeEditor.completer.$input": "This nodes input data",
"codeNodeEditor.completer.$input.all": "@:_reusableBaseText.codeNodeEditor.completer.all",
"codeNodeEditor.completer.$input.first": "@:_reusableBaseText.codeNodeEditor.completer.first",
"codeNodeEditor.completer.$input.item": "The item that generated the current one",
"codeNodeEditor.completer.$input.itemMatching": "@:_reusableBaseText.codeNodeEditor.completer.itemMatching",
"codeNodeEditor.completer.$input.last": "@:_reusableBaseText.codeNodeEditor.completer.last",
"codeNodeEditor.completer.$itemIndex": "The position of the current item in the list of items",
"codeNodeEditor.completer.$jmespath": "Evaluate a JMESPath expression",
"codeNodeEditor.completer.$now": "The current timestamp (as a Luxon object)",
"codeNodeEditor.completer.$prevNode": "The node providing the input data for this run",
"codeNodeEditor.completer.$prevNode.name": "The name of the node providing the input data for this run",
"codeNodeEditor.completer.$prevNode.outputIndex": "The output connector of the node providing input data for this run",
"codeNodeEditor.completer.$prevNode.runIndex": "The run of the node providing input data to the current one",
"codeNodeEditor.completer.$runIndex": "The index of the current run of this node",
"codeNodeEditor.completer.$today": "A timestamp representing the current day (at midnight, as a Luxon object)",
"codeNodeEditor.completer.$workflow": "Information about the workflow",
"codeNodeEditor.completer.$workflow.active": "Whether the workflow is active or not (boolean)",
"codeNodeEditor.completer.$workflow.id": "The ID of the workflow",
"codeNodeEditor.completer.$workflow.name": "The name of the workflow",
"codeNodeEditor.completer.binary": "The item's binary (file) data",
"codeNodeEditor.completer.json": "The item's JSON data. When in doubt, use this",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormat": "Create a DateTime from an input string and format string.",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromHTTP": "Create a DateTime from an HTTP header date",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO": "Create a DateTime from an ISO 8601 string",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromJSDate": "Create a DateTime from a JavaScript Date object. Uses the default zone",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis": "Create a DateTime from a number of milliseconds since the epoch (meaning since 1 January 1970 00:00:00 UTC). Uses the default zone",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromObject": "Create a DateTime from a JavaScript object with keys like 'year' and 'hour' with reasonable defaults",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromRFC2822": "Create a DateTime from an RFC 2822 string",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSQL": "Create a DateTime from a SQL date, time, or datetime",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds": "Create a DateTime from a number of seconds since the epoch (meaning since 1 January 1970 00:00:00 UTC). Uses the default zone",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.invalid": "Create an invalid DateTime.",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime": "Check if an object is a DateTime. Works across context boundaries",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.local": "Create a local DateTime",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.now": "Create a DateTime for the current instant, in the system's time zone",
"codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc": "Create a DateTime in UTC",
"codeNodeEditor.completer.luxon.instanceMethods.day": "Get the day of the month (1-30ish).",
"codeNodeEditor.completer.luxon.instanceMethods.daysInMonth": "Returns the number of days in this DateTime's month",
"codeNodeEditor.completer.luxon.instanceMethods.daysInYear": "Returns the number of days in this DateTime's year",
"codeNodeEditor.completer.luxon.instanceMethods.diff": "Return the difference between two DateTimes as a Duration.",
"codeNodeEditor.completer.luxon.instanceMethods.diffNow": "Return the difference between this DateTime and right now.",
"codeNodeEditor.completer.luxon.instanceMethods.endOf": "Set this DateTime to the end (meaning the last millisecond) of a unit of time",
"codeNodeEditor.completer.luxon.instanceMethods.equals": "Equality check",
"codeNodeEditor.completer.luxon.instanceMethods.hasSame": "Return whether this DateTime is in the same unit of time as another DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.hour": "Get the hour of the day (0-23).",
"codeNodeEditor.completer.luxon.instanceMethods.invalidExplanation": "Returns an explanation of why this DateTime became invalid, or null if the DateTime is valid",
"codeNodeEditor.completer.luxon.instanceMethods.invalidReason": "Returns an error code if this DateTime is invalid, or null if the DateTime is valid",
"codeNodeEditor.completer.luxon.instanceMethods.isInDST": "Get whether the DateTime is in a DST.",
"codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear": "Returns true if this DateTime is in a leap year, false otherwise",
"codeNodeEditor.completer.luxon.instanceMethods.isOffsetFixed": "Get whether this zone's offset ever changes, as in a DST.",
"codeNodeEditor.completer.luxon.instanceMethods.isValid": "Returns whether the DateTime is valid. Invalid DateTimes occur when The DateTime was created from invalid calendar information, such as the 13th month or February 30. The DateTime was created by an operation on another invalid date.",
"codeNodeEditor.completer.luxon.instanceMethods.locale": "Get the locale of a DateTime, such 'en-GB'. The locale is used when formatting the DateTime",
"codeNodeEditor.completer.luxon.instanceMethods.max": "Return the max of several date times",
"codeNodeEditor.completer.luxon.instanceMethods.millisecond": "Get the millisecond of the second (0-999).",
"codeNodeEditor.completer.luxon.instanceMethods.min": "Return the min of several date times",
"codeNodeEditor.completer.luxon.instanceMethods.minus": "Subtract hours, minutes, seconds, or milliseconds increases the timestamp by the right number of milliseconds.",
"codeNodeEditor.completer.luxon.instanceMethods.minute": "Get the minute of the hour (0-59).",
"codeNodeEditor.completer.luxon.instanceMethods.month": "Get the month (1-12).",
"codeNodeEditor.completer.luxon.instanceMethods.monthLong": "Get the human readable long month name, such as 'October'.",
"codeNodeEditor.completer.luxon.instanceMethods.monthShort": "Get the human readable short month name, such as 'Oct'.",
"codeNodeEditor.completer.luxon.instanceMethods.numberingSystem": "Get the numbering system of a DateTime, such 'beng'. The numbering system is used when formatting the DateTime",
"codeNodeEditor.completer.luxon.instanceMethods.offset": "Get the UTC offset of this DateTime in minutes",
"codeNodeEditor.completer.luxon.instanceMethods.offsetNameLong": "Get the long human name for the zone\\'s current offset, for example \"Eastern Standard Time\" or \" Eastern Daylight Time\".",
"codeNodeEditor.completer.luxon.instanceMethods.offsetNameShort": "Get the short human name for the zone\\'s current offset, for example \"EST\" or \"EDT\".'",
"codeNodeEditor.completer.luxon.instanceMethods.offsetNumber": "Get the short human name for the zone\\'s current offset, for example \"EST\" or \"EDT\".'",
"codeNodeEditor.completer.luxon.instanceMethods.ordinal": "Get the ordinal (meaning the day of the year)",
"codeNodeEditor.completer.luxon.instanceMethods.outputCalendar": "Get the output calendar of a DateTime, such 'islamic'. The output calendar is used when formatting the DateTime",
"codeNodeEditor.completer.luxon.instanceMethods.plus": "Add hours, minutes, seconds, or milliseconds increases the timestamp by the right number of milliseconds.",
"codeNodeEditor.completer.luxon.instanceMethods.quarter": "Get the quarter",
"codeNodeEditor.completer.luxon.instanceMethods.second": "Get the second of the minute (0-59).",
"codeNodeEditor.completer.luxon.instanceMethods.set": "Set the values of specified units. Returns a newly-constructed DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.setLocale": "Set the locale. Returns a newly-constructed DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.setZone": "Set the DateTime's zone to specified zone. Returns a newly-constructed DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.startOf": "Set this DateTime to the beginning of a unit of time.",
"codeNodeEditor.completer.luxon.instanceMethods.toBSON": "Returns a BSON serializable equivalent to this DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.toFormat": "Returns a string representation of this DateTime formatted according to the specified format string.",
"codeNodeEditor.completer.luxon.instanceMethods.toHTTP": "Returns a string representation of this DateTime appropriate for use in HTTP headers.",
"codeNodeEditor.completer.luxon.instanceMethods.toISO": "Returns an ISO 8601-compliant string representation of this DateTime",
"codeNodeEditor.completer.luxon.instanceMethods.toISODate": "Returns an ISO 8601-compliant string representation of this DateTime's date component",
"codeNodeEditor.completer.luxon.instanceMethods.toISOTime": "Returns an ISO 8601-compliant string representation of this DateTime's time component",
"codeNodeEditor.completer.luxon.instanceMethods.toISOWeekDate": "Returns an ISO 8601-compliant string representation of this DateTime's week date",
"codeNodeEditor.completer.luxon.instanceMethods.toJSON": "Returns an ISO 8601 representation of this DateTime appropriate for use in JSON.",
"codeNodeEditor.completer.luxon.instanceMethods.toJsDate": "Returns a JavaScript Date equivalent to this DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.toLocal": "Set the DateTime's zone to the host's local zone. Returns a newly-constructed DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.toLocaleParts": "Returns an array of format \"parts\", meaning individual tokens along with metadata.",
"codeNodeEditor.completer.luxon.instanceMethods.toLocaleString": "Returns a localized string representing this date. Accepts the same options as the Intl.DateTimeFormat constructor and any presets defined by Luxon.",
"codeNodeEditor.completer.luxon.instanceMethods.toMillis": "Returns the epoch milliseconds of this DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.toObject": "Returns a JavaScript object with this DateTime's year, month, day, and so on.",
"codeNodeEditor.completer.luxon.instanceMethods.toRFC2822": "Returns an RFC 2822-compatible string representation of this DateTime, always in UTC",
"codeNodeEditor.completer.luxon.instanceMethods.toRelative": "Returns a string representation of a this time relative to now, such as 'in two days'.",
"codeNodeEditor.completer.luxon.instanceMethods.toRelativeCalendar": "Returns a string representation of this date relative to today, such as '\"'yesterday' or 'next month'",
"codeNodeEditor.completer.luxon.instanceMethods.toSQL": "Returns a string representation of this DateTime appropriate for use in SQL DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.toSQLDate": "Returns a string representation of this DateTime appropriate for use in SQL Date",
"codeNodeEditor.completer.luxon.instanceMethods.toSQLTime": "Returns a string representation of this DateTime appropriate for use in SQL Time",
"codeNodeEditor.completer.luxon.instanceMethods.toSeconds": "Returns the epoch seconds of this DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.toString": "Returns a string representation of this DateTime appropriate for debugging",
"codeNodeEditor.completer.luxon.instanceMethods.toUTC": "Set the DateTime's zone to UTC. Returns a newly-constructed DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.toUnixInteger": "Returns the epoch seconds (as a whole number) of this DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.until": "Return an Interval spanning between this DateTime and another DateTime",
"codeNodeEditor.completer.luxon.instanceMethods.valueOf": "Returns the epoch milliseconds of this DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.weekNumber": "Get the week number of the week year (1-52ish).",
"codeNodeEditor.completer.luxon.instanceMethods.weekYear": "Get the week year",
"codeNodeEditor.completer.luxon.instanceMethods.weekday": "Get the day of the week. 1 is Monday and 7 is Sunday.",
"codeNodeEditor.completer.luxon.instanceMethods.weekdayLong": "Get the human readable long weekday, such as 'Monday'.",
"codeNodeEditor.completer.luxon.instanceMethods.weekdayShort": "Get the human readable short weekday, such as 'Mon'.",
"codeNodeEditor.completer.luxon.instanceMethods.weeksInWeekYear": "Returns the number of weeks in this DateTime's year",
"codeNodeEditor.completer.luxon.instanceMethods.year": "Get the year",
"codeNodeEditor.completer.luxon.instanceMethods.zone": "Get the time zone associated with this DateTime.",
"codeNodeEditor.completer.luxon.instanceMethods.zoneName": "Get the name of the time zone.",
"codeNodeEditor.completer.selector.all": "@:_reusableBaseText.codeNodeEditor.completer.all",
"codeNodeEditor.completer.selector.context": "Extra data about the node",
"codeNodeEditor.completer.selector.first": "@:_reusableBaseText.codeNodeEditor.completer.first",
"codeNodeEditor.completer.selector.item": "The item that generated the current one",
"codeNodeEditor.completer.selector.itemMatching": "@:_reusableBaseText.codeNodeEditor.completer.itemMatching",
"codeNodeEditor.completer.selector.last": "@:_reusableBaseText.codeNodeEditor.completer.last",
"codeNodeEditor.completer.selector.params": "The parameters of the node",
"codeNodeEditor.linter.allItems.firstOrLastCalledWithArg": "expects no argument.",
"codeNodeEditor.linter.allItems.allCalledWithArg": "`.all()` expects no argument. To access a specific item, use bracket notation: `.all()[index]`.",
"codeNodeEditor.linter.allItems.emptyReturn": "Code doesn't return items properly. Please return an array of objects, one for each item you would like to output.",
"codeNodeEditor.linter.allItems.itemCall": "`item` is a property to access, not a method to call. Did you mean `.item` without brackets?",
"codeNodeEditor.linter.allItems.itemMatchingNoArg": "`.itemMatching()` expects an item index to be passed in as its argument.",
"codeNodeEditor.linter.allItems.unavailableProperty": "`.item` is only available in the 'Run Once for Each Item' mode.",
"codeNodeEditor.linter.allItems.unavailableVar": "is only available in the 'Run Once for Each Item' mode.",
"codeNodeEditor.linter.bothModes.directAccess.firstOrLastCall": "@:_reusableBaseText.codeNodeEditor.linter.useJson",
"codeNodeEditor.linter.bothModes.directAccess.itemProperty": "@:_reusableBaseText.codeNodeEditor.linter.useJson",
"codeNodeEditor.linter.bothModes.varDeclaration.itemProperty": "@:_reusableBaseText.codeNodeEditor.linter.useJson",
"codeNodeEditor.linter.bothModes.varDeclaration.itemSubproperty": "@:_reusableBaseText.codeNodeEditor.linter.useJson",
"codeNodeEditor.linter.eachItem.emptyReturn": "Code doesn't return an object. Please return an object representing the output item",
"codeNodeEditor.linter.eachItem.legacyItemAccess": "`item` is not defined. Did you mean `$input.item.json`?",
"codeNodeEditor.linter.eachItem.returnArray": "Code doesn't return an object. Array found instead. Please return an object representing the output item",
"codeNodeEditor.linter.eachItem.unavailableMethod": "is only available in the 'Run Once for All Items' mode.",
"codeNodeEditor.linter.bothModes.syntaxError": "Syntax error",
"collectionParameter.choose": "Choose...",
"collectionParameter.noProperties": "No properties",
"credentialEdit.credentialConfig.accountConnected": "Account connected",

View file

@ -9,6 +9,8 @@ import packageJSON from './package.json';
const vendorChunks = ['vue', 'vue-router', 'vuex'];
const ignoreChunks = ['vue2-boring-avatars', 'vue-template-compiler', 'jquery', '@fontsource/open-sans'];
const isScopedPackageToIgnore = (str: string) => /@codemirror\//.test(str);
function renderChunks() {
const { dependencies } = packageJSON;
const chunks: Record<string, string[]> = {};
@ -18,6 +20,8 @@ function renderChunks() {
return;
}
if (isScopedPackageToIgnore(key)) return;
chunks[key] = [key];
});

View file

@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.code",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "The Code node allows you to execute JavaScript in your workflow.",
"categories": ["Development", "Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/nodes/n8n-nodes-base.code/"
}
]
},
"alias": ["cpde", "Javascript", "JS", "Script", "Custom Code", "Function"],
"subcategories": {
"Core Nodes": ["Data Transformation"]
}
}

View file

@ -0,0 +1,138 @@
import {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { getSandboxContext, Sandbox } from './Sandbox';
import { standardizeOutput } from './utils';
import type { CodeNodeMode } from './utils';
export class Code implements INodeType {
description: INodeTypeDescription = {
displayName: 'Code',
name: 'code',
icon: 'fa:code',
group: ['transform'],
version: 1,
description: 'Run custom JavaScript code',
defaults: {
name: 'Code',
color: '#FF9922',
},
inputs: ['main'],
outputs: ['main'],
parameterPane: 'wide',
properties: [
{
displayName: 'Mode',
name: 'mode',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Run Once for All Items',
value: 'runOnceForAllItems',
description: 'Run this code only once, no matter how many input items there are',
},
{
name: 'Run Once for Each Item',
value: 'runOnceForEachItem',
description: 'Run this code as many times as there are input items',
},
],
default: 'runOnceForAllItems',
},
{
displayName: 'JavaScript',
name: 'jsCode',
typeOptions: {
editor: 'codeNodeEditor',
},
type: 'string',
default: '', // set by component
description:
'JavaScript code to execute.<br><br>Tip: You can use luxon vars like <code>$today</code> for dates and <code>$jmespath</code> for querying JSON structures. <a href="https://docs.n8n.io/nodes/n8n-nodes-base.function">Learn more</a>.',
noDataExpression: true,
},
{
displayName:
'Type <code>$</code> for a list of <a target="_blank" href="https://docs.n8n.io/code-examples/methods-variables-reference/">special vars/methods</a>. Debug by using <code>console.log()</code> statements and viewing their output in the browser console.',
name: 'notice',
type: 'notice',
default: '',
},
],
};
async execute(this: IExecuteFunctions) {
let items = this.getInputData();
const nodeMode = this.getNodeParameter('mode', 0) as CodeNodeMode;
const workflowMode = this.getMode();
// ----------------------------------
// runOnceForAllItems
// ----------------------------------
if (nodeMode === 'runOnceForAllItems') {
const jsCodeAllItems = this.getNodeParameter('jsCode', 0) as string;
const context = getSandboxContext.call(this);
const sandbox = new Sandbox(context, workflowMode, nodeMode);
if (workflowMode === 'manual') {
sandbox.on('console.log', this.sendMessageToUI);
}
try {
items = await sandbox.runCode(jsCodeAllItems);
} catch (error) {
if (!this.continueOnFail()) return Promise.reject(error);
items = [{ json: { error: error.message } }];
}
for (const item of items) {
standardizeOutput(item.json);
}
return this.prepareOutputData(items);
}
// ----------------------------------
// runOnceForEachItem
// ----------------------------------
const returnData: INodeExecutionData[] = [];
for (let index = 0; index < items.length; index++) {
let item = items[index];
const jsCodeEachItem = this.getNodeParameter('jsCode', index) as string;
const context = getSandboxContext.call(this, index);
const sandbox = new Sandbox(context, workflowMode, nodeMode);
if (workflowMode === 'manual') {
sandbox.on('console.log', this.sendMessageToUI);
}
try {
item = await sandbox.runCode(jsCodeEachItem, index);
} catch (error) {
if (!this.continueOnFail()) return Promise.reject(error);
returnData.push({ json: { error: error.message } });
}
if (item) {
returnData.push({
json: standardizeOutput(item.json),
pairedItem: { item: index },
...(item.binary && { binary: item.binary }),
});
}
}
return this.prepareOutputData(returnData);
}
}

View file

@ -0,0 +1,78 @@
export class ExecutionError extends Error {
description: string | null = null;
itemIndex: number | undefined = undefined;
context: { itemIndex: number } | undefined = undefined;
stack = '';
lineNumber: number | undefined = undefined;
constructor(error: Error & { stack: string }, itemIndex?: number) {
super();
this.itemIndex = itemIndex;
if (this.itemIndex !== undefined) {
this.context = { itemIndex: this.itemIndex };
}
this.stack = error.stack;
this.populateFromStack();
}
/**
* Populate error `message` and `description` from error `stack`.
*/
private populateFromStack() {
const stackRows = this.stack.split('\n');
if (stackRows.length === 0) {
this.message = 'Unknown error';
}
const messageRow = stackRows.find((line) => line.includes('Error:'));
const lineNumberRow = stackRows.find((line) => line.includes('Code:'));
const lineNumberDisplay = this.toLineNumberDisplay(lineNumberRow);
if (!messageRow) {
this.message = `Unknown error ${lineNumberDisplay}`;
return;
}
const [errorDetails, errorType] = this.toErrorDetailsAndType(messageRow);
if (errorType) this.description = errorType;
if (!errorDetails) {
this.message = `Unknown error ${lineNumberDisplay}`;
return;
}
this.message = `${errorDetails} ${lineNumberDisplay}`;
}
private toLineNumberDisplay(lineNumberRow?: string) {
const errorLineNumberMatch = lineNumberRow?.match(/Code:(?<lineNumber>\d+)/);
if (!errorLineNumberMatch?.groups?.lineNumber) return null;
const lineNumber = errorLineNumberMatch.groups.lineNumber;
this.lineNumber = Number(lineNumber);
if (!lineNumber) return '';
return this.itemIndex === undefined
? `[line ${lineNumber}]`
: `[line ${lineNumber}, for item ${this.itemIndex}]`;
}
private toErrorDetailsAndType(messageRow?: string) {
if (!messageRow) return [null, null];
const [errorDetails, errorType] = messageRow
.split(':')
.reverse()
.map((i) => i.trim());
return [errorDetails, errorType === 'Error' ? null : errorType];
}
}

View file

@ -0,0 +1,268 @@
import { normalizeItems } from 'n8n-core';
import { NodeVM, NodeVMOptions } from 'vm2';
import { ValidationError } from './ValidationError';
import { ExecutionError } from './ExecutionError';
import { CodeNodeMode, isObject, SUPPORTED_ITEM_KEYS } from './utils';
import type { IExecuteFunctions, IWorkflowDataProxyData, WorkflowExecuteMode } from 'n8n-workflow';
export class Sandbox extends NodeVM {
private jsCode = '';
private itemIndex: number | undefined = undefined;
constructor(
context: ReturnType<typeof getSandboxContext>,
workflowMode: WorkflowExecuteMode,
private nodeMode: CodeNodeMode,
) {
super(Sandbox.getSandboxOptions(context, workflowMode));
}
static getSandboxOptions(
context: ReturnType<typeof getSandboxContext>,
workflowMode: WorkflowExecuteMode,
): NodeVMOptions {
const { NODE_FUNCTION_ALLOW_BUILTIN: builtIn, NODE_FUNCTION_ALLOW_EXTERNAL: external } =
process.env;
return {
console: workflowMode === 'manual' ? 'redirect' : 'inherit',
sandbox: context,
require: {
builtin: builtIn ? builtIn.split(',') : [],
external: external ? { modules: external.split(','), transitive: false } : false,
},
};
}
async runCode(jsCode: string, itemIndex?: number) {
this.jsCode = jsCode;
this.itemIndex = itemIndex;
return this.nodeMode === 'runOnceForAllItems' ? this.runCodeAllItems() : this.runCodeEachItem();
}
private async runCodeAllItems() {
const script = `module.exports = async function() {${this.jsCode}\n}()`;
const match = script.match(
/(?<disallowedSyntax>\)\.item(?!Matching)|\$input\.item(?!Matching)|\$json|\$binary|\$itemIndex)/,
); // disallow .item but tolerate .itemMatching
if (match?.groups?.disallowedSyntax) {
const { disallowedSyntax } = match.groups;
const lineNumber =
this.jsCode.split('\n').findIndex((line) => {
return line.includes(disallowedSyntax) && !line.startsWith('//') && !line.startsWith('*');
}) + 1;
const disallowedSyntaxFound = lineNumber !== 0;
if (disallowedSyntaxFound) {
throw new ValidationError({
message: `Can't use ${disallowedSyntax} here`,
description: "This is only available in 'Run Once for Each Item' mode",
itemIndex: this.itemIndex,
lineNumber,
});
}
}
let executionResult;
try {
executionResult = await this.run(script, __dirname);
} catch (error) {
// anticipate user expecting `items` to pre-exist as in Function Item node
if (error.message === 'items is not defined' && !/(let|const|var) items =/.test(script)) {
const quoted = error.message.replace('items', '`items`');
error.message = quoted + '. Did you mean `$input.all()`?';
}
throw new ExecutionError(error);
}
if (executionResult === null) return [];
if (executionResult === undefined || typeof executionResult !== 'object') {
throw new ValidationError({
message: "Code doesn't return items properly",
description:
'Please return an array of objects, one for each item you would like to output',
itemIndex: this.itemIndex,
});
}
if (Array.isArray(executionResult)) {
for (const item of executionResult) {
if (item.json !== undefined && !isObject(item.json)) {
throw new ValidationError({
message: "A 'json' property isn't an object",
description: "In the returned data, every key named 'json' must point to an object",
itemIndex: this.itemIndex,
});
}
// If at least one top-level key is a supported item key (`json`, `binary`, etc.),
// then validate all keys to be a supported item key, else allow user keys
// to be wrapped in `json` when normalizing items below.
if (
executionResult.some((item) =>
Object.keys(item).find((key) => SUPPORTED_ITEM_KEYS.has(key)),
)
) {
Object.keys(item).forEach((key) => {
if (SUPPORTED_ITEM_KEYS.has(key)) return;
throw new ValidationError({
message: `Unknown top-level item key: ${key}`,
description: 'Access the properties of an item under `.json`, e.g. `item.json`',
itemIndex: this.itemIndex,
});
});
}
if (item.binary !== undefined && !isObject(item.binary)) {
throw new ValidationError({
message: "A 'binary' property isn't an object",
description: "In the returned data, every key named 'binary must point to an object.",
itemIndex: this.itemIndex,
});
}
}
} else {
if (executionResult.json !== undefined && !isObject(executionResult.json)) {
throw new ValidationError({
message: "A 'json' property isn't an object",
description: "In the returned data, every key named 'json' must point to an object",
itemIndex: this.itemIndex,
});
}
if (executionResult.binary !== undefined && !isObject(executionResult.binary)) {
throw new ValidationError({
message: "A 'binary' property isn't an object",
description: "In the returned data, every key named 'binary must point to an object.",
itemIndex: this.itemIndex,
});
}
}
return normalizeItems(executionResult);
}
private async runCodeEachItem() {
const script = `module.exports = async function() {${this.jsCode}\n}()`;
const match = this.jsCode.match(/\$input\.(?<disallowedMethod>first|last|all|itemMatching)/);
if (match?.groups?.disallowedMethod) {
const { disallowedMethod } = match.groups;
const lineNumber =
this.jsCode.split('\n').findIndex((line) => {
return line.includes(disallowedMethod) && !line.startsWith('//') && !line.startsWith('*');
}) + 1;
const disallowedMethodFound = lineNumber !== 0;
if (disallowedMethodFound) {
throw new ValidationError({
message: `Can't use .${disallowedMethod}() here`,
description: "This is only available in 'Run Once for All Items' mode",
itemIndex: this.itemIndex,
lineNumber,
});
}
}
let executionResult;
try {
executionResult = await this.run(script, __dirname);
} catch (error) {
// anticipate user expecting `item` to pre-exist as in Function Item node
if (error.message === 'item is not defined' && !/(let|const|var) item =/.test(script)) {
const quoted = error.message.replace('item', '`item`');
error.message = quoted + '. Did you mean `$input.item.json`?';
}
throw new ExecutionError(error, this.itemIndex);
}
if (executionResult === null) return;
if (executionResult === undefined || typeof executionResult !== 'object') {
throw new ValidationError({
message: "Code doesn't return an object",
description: `Please return an object representing the output item. ('${executionResult}' was returned instead.)`,
itemIndex: this.itemIndex,
});
}
if (executionResult.json !== undefined && !isObject(executionResult.json)) {
throw new ValidationError({
message: "A 'json' property isn't an object",
description: "In the returned data, every key named 'json' must point to an object",
itemIndex: this.itemIndex,
});
}
if (executionResult.binary !== undefined && !isObject(executionResult.binary)) {
throw new ValidationError({
message: "A 'binary' property isn't an object",
description: "In the returned data, every key named 'binary must point to an object.",
itemIndex: this.itemIndex,
});
}
// If at least one top-level key is a supported item key (`json`, `binary`, etc.),
// and another top-level key is unrecognized, then the user mis-added a property
// directly on the item, when they intended to add it on the `json` property
Object.keys(executionResult).forEach((key) => {
if (SUPPORTED_ITEM_KEYS.has(key)) return;
throw new ValidationError({
message: `Unknown top-level item key: ${key}`,
description: 'Access the properties of an item under `.json`, e.g. `item.json`',
itemIndex: this.itemIndex,
});
});
if (Array.isArray(executionResult)) {
const firstSentence =
executionResult.length > 0
? `An array of ${typeof executionResult[0]}s was returned.`
: 'An empty array was returned.';
throw new ValidationError({
message: "Code doesn't return a single object",
description: `${firstSentence} If you need to output multiple items, please use the 'Run Once for All Items' mode instead`,
itemIndex: this.itemIndex,
});
}
return executionResult.json ? executionResult : { json: executionResult };
}
}
export function getSandboxContext(this: IExecuteFunctions, index?: number) {
const sandboxContext: Record<string, unknown> & { $item: (i: number) => IWorkflowDataProxyData } =
{
// from NodeExecuteFunctions
$getNodeParameter: this.getNodeParameter,
$getWorkflowStaticData: this.getWorkflowStaticData,
helpers: this.helpers,
// to bring in all $-prefixed vars and methods from WorkflowDataProxy
$item: this.getWorkflowDataProxy,
};
// $node, $items(), $parameter, $json, $env, etc.
Object.assign(sandboxContext, sandboxContext.$item(index ?? 0));
return sandboxContext;
}

View file

@ -0,0 +1,39 @@
export class ValidationError extends Error {
description = '';
itemIndex: number | undefined = undefined;
context: { itemIndex: number } | undefined = undefined;
lineNumber: number | undefined = undefined;
constructor({
message,
description,
itemIndex,
lineNumber,
}: {
message: string;
description: string;
itemIndex?: number;
lineNumber?: number;
}) {
super();
this.lineNumber = lineNumber;
this.itemIndex = itemIndex;
if (this.lineNumber !== undefined && this.itemIndex !== undefined) {
this.message = `${message} [line ${lineNumber}, for item ${itemIndex}]`;
} else if (this.lineNumber !== undefined) {
this.message = `${message} [line ${lineNumber}]`;
} else if (this.itemIndex !== undefined) {
this.message = `${message} [item ${itemIndex}]`;
} else {
this.message = message;
}
this.description = description;
if (this.itemIndex !== undefined) {
this.context = { itemIndex: this.itemIndex };
}
}
}

View file

@ -0,0 +1,29 @@
import type { IDataObject } from 'n8n-workflow';
/**
* Stringify any non-standard JS objects (e.g. `Date`, `RegExp`) inside output items at any depth.
*/
export function standardizeOutput(output: IDataObject) {
for (const [key, value] of Object.entries(output)) {
if (!isTraversable(value)) continue;
output[key] =
value.constructor.name !== 'Object'
? JSON.stringify(value) // Date, RegExp, etc.
: standardizeOutput(value);
}
return output;
}
export function isObject(maybe: unknown): maybe is { [key: string]: unknown } {
return typeof maybe === 'object' && maybe !== null && !Array.isArray(maybe);
}
function isTraversable(maybe: unknown): maybe is IDataObject {
return isObject(maybe) && Object.keys(maybe).length > 0;
}
export type CodeNodeMode = 'runOnceForAllItems' | 'runOnceForEachItem';
export const SUPPORTED_ITEM_KEYS = new Set(['json', 'binary', 'error', 'pairedItem', 'index']);

View file

@ -14,6 +14,7 @@ export class Function implements INodeType {
description: INodeTypeDescription = {
displayName: 'Function',
name: 'function',
hidden: true,
icon: 'fa:code',
group: ['transform'],
version: 1,
@ -26,6 +27,12 @@ export class Function implements INodeType {
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'A newer version of this node type is available, called the Code node',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'JavaScript Code',
name: 'functionCode',

View file

@ -14,6 +14,7 @@ export class FunctionItem implements INodeType {
description: INodeTypeDescription = {
displayName: 'Function Item',
name: 'functionItem',
hidden: true,
icon: 'fa:code',
group: ['transform'],
version: 1,
@ -25,6 +26,12 @@ export class FunctionItem implements INodeType {
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'A newer version of this node type is available, called the Code node',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'JavaScript Code',
name: 'functionCode',

View file

@ -399,6 +399,7 @@
"dist/nodes/Clockify/ClockifyTrigger.node.js",
"dist/nodes/Cockpit/Cockpit.node.js",
"dist/nodes/Coda/Coda.node.js",
"dist/nodes/Code/Code.node.js",
"dist/nodes/CoinGecko/CoinGecko.node.js",
"dist/nodes/Compression/Compression.node.js",
"dist/nodes/Contentful/Contentful.node.js",
@ -784,6 +785,7 @@
"lodash.set": "^4.3.2",
"lodash.unset": "^4.5.2",
"lossless-json": "^1.0.4",
"luxon": "~2.3.0",
"mailparser": "^3.2.0",
"moment": "~2.29.2",
"moment-timezone": "^0.5.28",

View file

@ -60,7 +60,7 @@
"lodash.isequal": "^4.5.0",
"lodash.merge": "^4.6.2",
"lodash.set": "^4.3.2",
"luxon": "^2.3.0",
"luxon": "~2.3.0",
"xml2js": "^0.4.23"
},
"jest": {

View file

@ -946,7 +946,7 @@ export type NodePropertyTypes =
export type CodeAutocompleteTypes = 'function' | 'functionItem';
export type EditorTypes = 'code' | 'json';
export type EditorTypes = 'code' | 'codeNodeEditor' | 'json';
export interface ILoadOptions {
routing?: {
@ -1212,6 +1212,7 @@ export interface INodeTypeBaseDescription {
subtitle?: string;
defaultVersion?: number;
codex?: CodexData;
parameterPane?: 'wide';
/**
* Whether the node must be hidden in the node creator panel,

View file

@ -65,6 +65,8 @@ export abstract class ExecutionBaseError extends Error {
context: IDataObject = {};
lineNumber: number | undefined;
constructor(error: Error | ExecutionBaseError | JsonObject) {
super();
this.name = this.constructor.name;
@ -211,6 +213,8 @@ abstract class NodeError extends ExecutionBaseError {
* Class for instantiating an operational error, e.g. an invalid credentials error.
*/
export class NodeOperationError extends NodeError {
lineNumber: number | undefined;
constructor(
node: INode,
error: Error | string,

View file

@ -28,6 +28,7 @@ import {
import * as NodeHelpers from './NodeHelpers';
import { ExpressionError } from './ExpressionError';
import type { Workflow } from './Workflow';
import { deepCopy } from './utils';
export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator {
return Boolean(
@ -35,6 +36,18 @@ export function isResourceLocatorValue(value: unknown): value is INodeParameterR
);
}
const SCRIPTING_NODE_TYPES = [
'n8n-nodes-base.function',
'n8n-nodes-base.functionItem',
'n8n-nodes-base.code',
];
const isScriptingNode = (nodeName: string, workflow: Workflow) => {
const node = workflow.getNode(nodeName);
return node && SCRIPTING_NODE_TYPES.includes(node.type);
};
export class WorkflowDataProxy {
private workflow: Workflow;
@ -79,13 +92,20 @@ export class WorkflowDataProxy {
defaultReturnRunIndex = -1,
selfData = {},
) {
this.activeNodeName = activeNodeName;
this.workflow = workflow;
this.runExecutionData = runExecutionData;
this.runExecutionData = isScriptingNode(activeNodeName, workflow)
? deepCopy(runExecutionData)
: runExecutionData;
this.connectionInputData = isScriptingNode(activeNodeName, workflow)
? deepCopy(connectionInputData)
: connectionInputData;
this.defaultReturnRunIndex = defaultReturnRunIndex;
this.runIndex = runIndex;
this.itemIndex = itemIndex;
this.activeNodeName = activeNodeName;
this.connectionInputData = connectionInputData;
this.siblingParameters = siblingParameters;
this.mode = mode;
this.defaultTimezone = defaultTimezone;
@ -587,11 +607,6 @@ export class WorkflowDataProxy {
return jmespath.search(data, query);
};
const isFunctionNode = (nodeName: string) => {
const node = that.workflow.getNode(nodeName);
return node && ['n8n-nodes-base.function', 'n8n-nodes-base.functionItem'].includes(node.type);
};
const createExpressionError = (
message: string,
context?: {
@ -611,7 +626,7 @@ export class WorkflowDataProxy {
type?: string;
},
) => {
if (isFunctionNode(that.activeNodeName) && context?.functionOverrides) {
if (isScriptingNode(that.activeNodeName, that.workflow) && context?.functionOverrides) {
// If the node in which the error is thrown is a function node,
// display a different error message in case there is one defined
message = context.functionOverrides.message || message;
@ -635,7 +650,7 @@ export class WorkflowDataProxy {
context.descriptionTemplate = `To fetch the data for the expression under '%%PARAMETER%%', you must unpin the node <strong>'${nodeName}'</strong> and execute the workflow again.`;
}
if (context.moreInfoLink && (pinData || isFunctionNode(nodeName))) {
if (context.moreInfoLink && (pinData || isScriptingNode(nodeName, that.workflow))) {
const moreInfoLink =
' <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-errors/">More info</a>';

View file

@ -8,6 +8,8 @@ export class WorkflowOperationError extends Error {
timestamp: number;
lineNumber: number | undefined;
constructor(message: string, node?: INode) {
super(message);
this.name = this.constructor.name;

View file

@ -0,0 +1 @@
export const deepCopy = <T>(toCopy: T) => JSON.parse(JSON.stringify(toCopy)) as T;