mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
320 lines
8.3 KiB
TypeScript
320 lines
8.3 KiB
TypeScript
/**
|
|
* String utility functions for workflow builder
|
|
*/
|
|
|
|
import { createHash } from 'crypto';
|
|
|
|
/**
|
|
* Common JavaScript methods that may appear after field paths in expressions.
|
|
* These should not be treated as part of the field path during validation.
|
|
* E.g., $json.output.includes("test") - "includes" is a method, not a field.
|
|
*/
|
|
export const JS_METHODS = new Set([
|
|
'includes',
|
|
'indexOf',
|
|
'slice',
|
|
'substring',
|
|
'toLowerCase',
|
|
'toUpperCase',
|
|
'trim',
|
|
'split',
|
|
'replace',
|
|
'match',
|
|
'startsWith',
|
|
'endsWith',
|
|
'filter',
|
|
'map',
|
|
'reduce',
|
|
'find',
|
|
'findIndex',
|
|
'some',
|
|
'every',
|
|
'forEach',
|
|
'join',
|
|
'sort',
|
|
'push',
|
|
'pop',
|
|
'length',
|
|
'toString',
|
|
]);
|
|
|
|
/**
|
|
* Remove trailing JS methods from field path.
|
|
* E.g., ["output", "includes"] -> ["output"]
|
|
*/
|
|
export function filterMethodsFromPath(fieldPath: string[]): string[] {
|
|
const result = [...fieldPath];
|
|
while (result.length > 0 && JS_METHODS.has(result[result.length - 1])) {
|
|
result.pop();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Parse version string to number
|
|
*/
|
|
export function parseVersion(version: string | number | undefined): number {
|
|
if (typeof version === 'number') return version;
|
|
if (!version) return 1;
|
|
const match = version.match(/v?(\d+(?:\.\d+)?)/);
|
|
return match ? parseFloat(match[1]) : 1;
|
|
}
|
|
|
|
/**
|
|
* Check if a value is a placeholder string
|
|
*/
|
|
export function isPlaceholderValue(value: unknown): boolean {
|
|
if (typeof value !== 'string') return false;
|
|
return value.startsWith('<__PLACEHOLDER_VALUE__') && value.endsWith('__>');
|
|
}
|
|
|
|
/**
|
|
* Check if an object looks like a resource locator value.
|
|
* Resource locators have a 'mode' property (typically 'list', 'id', 'url', or 'name')
|
|
* and a 'value' property.
|
|
*/
|
|
export function isResourceLocatorLike(obj: unknown): obj is Record<string, unknown> {
|
|
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
|
return false;
|
|
}
|
|
const record = obj as Record<string, unknown>;
|
|
// Must have 'mode' property - this is the key identifier
|
|
// Common modes: 'list', 'id', 'url', 'name'
|
|
if (!('mode' in record)) {
|
|
return false;
|
|
}
|
|
// Should have 'value' property as well (the actual selected value)
|
|
if (!('value' in record)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Recursively normalize resource locator values in parameters.
|
|
* Adds __rl: true to objects that look like resource locator values but are missing it.
|
|
*/
|
|
export function normalizeResourceLocators(params: unknown): unknown {
|
|
if (typeof params !== 'object' || params === null) {
|
|
return params;
|
|
}
|
|
|
|
if (Array.isArray(params)) {
|
|
return params.map((item) => normalizeResourceLocators(item));
|
|
}
|
|
|
|
const result: Record<string, unknown> = {};
|
|
const record = params as Record<string, unknown>;
|
|
|
|
for (const [key, value] of Object.entries(record)) {
|
|
if (isResourceLocatorLike(value)) {
|
|
const rlValue = value;
|
|
const normalizedInner = normalizeResourceLocators(rlValue) as Record<string, unknown>;
|
|
|
|
// Clear placeholder value when mode is 'list' - list mode requires user selection
|
|
if (rlValue.mode === 'list' && isPlaceholderValue(rlValue.value)) {
|
|
result[key] = {
|
|
__rl: true,
|
|
...normalizedInner,
|
|
value: '',
|
|
};
|
|
} else {
|
|
// Add __rl: true if missing
|
|
result[key] = {
|
|
__rl: true,
|
|
...normalizedInner,
|
|
};
|
|
}
|
|
} else if (typeof value === 'object' && value !== null) {
|
|
// Recursively process nested objects
|
|
result[key] = normalizeResourceLocators(value);
|
|
} else {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Check if a '/' at position i could be the start of a regex literal.
|
|
* Uses heuristic based on preceding non-whitespace character.
|
|
*/
|
|
function couldBeRegexStart(code: string, i: number): boolean {
|
|
// Find the previous non-whitespace character
|
|
let j = i - 1;
|
|
while (j >= 0 && /\s/.test(code[j])) {
|
|
j--;
|
|
}
|
|
if (j < 0) return true; // Start of string, likely regex
|
|
|
|
const prevChar = code[j];
|
|
// Characters that can precede a regex literal
|
|
// (not an identifier char or closing bracket/paren that would make it division)
|
|
const regexPreceders = '(,=:[!&|?;{}><%+-*/^~';
|
|
return regexPreceders.includes(prevChar);
|
|
}
|
|
|
|
/**
|
|
* Escape raw newlines inside double/single quoted strings within JavaScript code.
|
|
* Skip backtick template literals (they allow raw newlines).
|
|
* Skip regex literals (to avoid misinterpreting quotes inside them).
|
|
* Don't double-escape already escaped \n.
|
|
*/
|
|
export function escapeNewlinesInStringLiterals(code: string): string {
|
|
let result = '';
|
|
let i = 0;
|
|
|
|
while (i < code.length) {
|
|
const char = code[i];
|
|
|
|
// Check for template literal (backtick) - skip entirely
|
|
if (char === '`') {
|
|
const start = i;
|
|
i++; // skip opening backtick
|
|
while (i < code.length) {
|
|
if (code[i] === '\\' && i + 1 < code.length) {
|
|
i += 2; // skip escaped character
|
|
} else if (code[i] === '`') {
|
|
i++; // skip closing backtick
|
|
break;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
// Append entire template literal unchanged
|
|
result += code.slice(start, i);
|
|
continue;
|
|
}
|
|
|
|
// Check for regex literal - skip entirely to avoid misinterpreting quotes inside
|
|
if (char === '/' && couldBeRegexStart(code, i)) {
|
|
// Make sure it's not a comment (// or /*)
|
|
const next = code[i + 1];
|
|
if (next !== '/' && next !== '*') {
|
|
const start = i;
|
|
i++; // skip opening /
|
|
while (i < code.length) {
|
|
if (code[i] === '\\' && i + 1 < code.length) {
|
|
i += 2; // skip escaped character
|
|
} else if (code[i] === '/') {
|
|
i++; // skip closing /
|
|
// Skip regex flags (g, i, m, s, u, y)
|
|
while (i < code.length && /[gimsuy]/.test(code[i])) {
|
|
i++;
|
|
}
|
|
break;
|
|
} else if (code[i] === '\n') {
|
|
// Newline before closing / means it's not a regex (or malformed)
|
|
// Just break and let normal processing continue
|
|
break;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
// Append entire regex unchanged
|
|
result += code.slice(start, i);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Check for double or single quote - process string literal
|
|
if (char === '"' || char === "'") {
|
|
const quote = char;
|
|
result += char;
|
|
i++; // skip opening quote
|
|
|
|
while (i < code.length) {
|
|
const c = code[i];
|
|
|
|
if (c === '\\' && i + 1 < code.length) {
|
|
// Escaped character - pass through as-is
|
|
result += c + code[i + 1];
|
|
i += 2;
|
|
} else if (c === quote) {
|
|
// End of string
|
|
result += c;
|
|
i++;
|
|
break;
|
|
} else if (c === '\n') {
|
|
// Raw newline - escape it
|
|
result += '\\n';
|
|
i++;
|
|
} else {
|
|
result += c;
|
|
i++;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Any other character - pass through
|
|
result += char;
|
|
i++;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Escape raw newlines inside string literals within {{ }} expression blocks.
|
|
*
|
|
* Only processes strings starting with `=` (n8n expressions).
|
|
* Only escapes inside double/single quoted strings within {{ }}.
|
|
* Does NOT escape inside backtick template literals (they allow newlines).
|
|
* Does NOT double-escape already escaped \\n.
|
|
*/
|
|
export function escapeNewlinesInExpressionStrings(value: unknown): unknown {
|
|
if (typeof value === 'string') {
|
|
// Only process n8n expressions (start with =)
|
|
if (!value.startsWith('=')) {
|
|
return value;
|
|
}
|
|
|
|
// Find {{ }} blocks and process string literals within them
|
|
return value.replace(/\{\{([\s\S]*?)\}\}/g, (_match, inner: string) => {
|
|
const escaped = escapeNewlinesInStringLiterals(inner);
|
|
return `{{${escaped}}}`;
|
|
});
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.map(escapeNewlinesInExpressionStrings);
|
|
}
|
|
|
|
if (typeof value === 'object' && value !== null) {
|
|
const result: Record<string, unknown> = {};
|
|
for (const [key, val] of Object.entries(value)) {
|
|
result[key] = escapeNewlinesInExpressionStrings(val);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Generate a deterministic UUID based on workflow ID, node type, and node name.
|
|
* This ensures that the same workflow structure always produces the same node IDs,
|
|
* which is critical for the AI workflow builder where code may be re-parsed multiple times.
|
|
*/
|
|
export function generateDeterministicNodeId(
|
|
workflowId: string,
|
|
nodeType: string,
|
|
nodeName: string,
|
|
): string {
|
|
const hash = createHash('sha256')
|
|
.update(`${workflowId}:${nodeType}:${nodeName}`)
|
|
.digest('hex')
|
|
.slice(0, 32);
|
|
|
|
// Format as valid UUID v4 structure
|
|
return [
|
|
hash.slice(0, 8),
|
|
hash.slice(8, 12),
|
|
'4' + hash.slice(13, 16), // Version 4
|
|
((parseInt(hash[16], 16) & 0x3) | 0x8).toString(16) + hash.slice(17, 20), // Variant
|
|
hash.slice(20, 32),
|
|
].join('-');
|
|
}
|