🔀 Merge branch 'master' into fix/http-request-node

This commit is contained in:
Jan Oberhauser 2021-11-19 10:19:48 +01:00
commit d0bde5e0c6
199 changed files with 8019 additions and 1929 deletions

1
.npmrc Normal file
View file

@ -0,0 +1 @@
legacy-peer-deps=true

View file

@ -6,6 +6,8 @@ if [ -d /root/.n8n ] ; then
ln -s /root/.n8n /home/node/
fi
chown -R node /home/node
if [ "$#" -gt 0 ]; then
# Got started with arguments
COMMAND=$1;

View file

@ -11,6 +11,7 @@ import {
CredentialTypes,
Db,
ExternalHooks,
InternalHooksManager,
IWorkflowBase,
IWorkflowExecutionDataProcess,
LoadNodesAndCredentials,
@ -123,6 +124,9 @@ export class Execute extends Command {
const externalHooks = ExternalHooks();
await externalHooks.init();
const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId);
// Add the found types to an instance other parts of the application can use
const nodeTypes = NodeTypes();
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);

View file

@ -28,6 +28,7 @@ import {
CredentialTypes,
Db,
ExternalHooks,
InternalHooksManager,
IWorkflowDb,
IWorkflowExecutionDataProcess,
LoadNodesAndCredentials,
@ -55,12 +56,12 @@ export class ExecuteBatch extends Command {
static executionTimeout = 3 * 60 * 1000;
static examples = [
`$ n8n executeAll`,
`$ n8n executeAll --concurrency=10 --skipList=/data/skipList.txt`,
`$ n8n executeAll --debug --output=/data/output.json`,
`$ n8n executeAll --ids=10,13,15 --shortOutput`,
`$ n8n executeAll --snapshot=/data/snapshots --shallow`,
`$ n8n executeAll --compare=/data/previousExecutionData --retries=2`,
`$ n8n executeBatch`,
`$ n8n executeBatch --concurrency=10 --skipList=/data/skipList.txt`,
`$ n8n executeBatch --debug --output=/data/output.json`,
`$ n8n executeBatch --ids=10,13,15 --shortOutput`,
`$ n8n executeBatch --snapshot=/data/snapshots --shallow`,
`$ n8n executeBatch --compare=/data/previousExecutionData --retries=2`,
];
static flags = {
@ -303,6 +304,9 @@ export class ExecuteBatch extends Command {
const externalHooks = ExternalHooks();
await externalHooks.init();
const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId);
// Add the found types to an instance other parts of the application can use
const nodeTypes = NodeTypes();
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);
@ -813,10 +817,22 @@ export class ExecuteBatch extends Command {
const changes = diff(JSON.parse(contents), data, { keysOnly: true });
if (changes !== undefined) {
// we have structural changes. Report them.
executionResult.error = `Workflow may contain breaking changes`;
executionResult.changes = changes;
executionResult.executionStatus = 'error';
// If we had only additions with no removals
// Then we treat as a warning and not an error.
// To find this, we convert the object to JSON
// and search for the `__deleted` string
const changesJson = JSON.stringify(changes);
if (changesJson.includes('__deleted')) {
// we have structural changes. Report them.
executionResult.error = 'Workflow may contain breaking changes';
executionResult.changes = changes;
executionResult.executionStatus = 'error';
} else {
executionResult.error =
'Workflow contains new data that previously did not exist.';
executionResult.changes = changes;
executionResult.executionStatus = 'warning';
}
} else {
executionResult.executionStatus = 'success';
}

View file

@ -12,7 +12,7 @@ import * as PCancelable from 'p-cancelable';
import { Command, flags } from '@oclif/command';
import { UserSettings, WorkflowExecute } from 'n8n-core';
import { INodeTypes, IRun, Workflow, LoggerProxy } from 'n8n-workflow';
import { IExecuteResponsePromiseData, INodeTypes, IRun, Workflow, LoggerProxy } from 'n8n-workflow';
import { FindOneOptions } from 'typeorm';
@ -25,11 +25,13 @@ import {
GenericHelpers,
IBullJobData,
IBullJobResponse,
IBullWebhookResponse,
IExecutionFlattedDb,
InternalHooksManager,
LoadNodesAndCredentials,
NodeTypes,
ResponseHelper,
WebhookHelpers,
WorkflowExecuteAdditionalData,
} from '../src';
@ -172,6 +174,16 @@ export class Worker extends Command {
currentExecutionDb.workflowData,
{ retryOf: currentExecutionDb.retryOf as string },
);
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
await job.progress({
executionId: job.data.executionId as string,
response: WebhookHelpers.encodeWebhookResponse(response),
} as IBullWebhookResponse);
},
];
additionalData.executionId = jobData.executionId;
let workflowExecute: WorkflowExecute;

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.145.0",
"version": "0.149.0",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -83,7 +83,7 @@
"dependencies": {
"@oclif/command": "^1.5.18",
"@oclif/errors": "^1.2.2",
"@rudderstack/rudder-sdk-node": "^1.0.2",
"@rudderstack/rudder-sdk-node": "1.0.6",
"@types/json-diff": "^0.5.1",
"@types/jsonwebtoken": "^8.5.2",
"basic-auth": "^2.0.1",
@ -110,10 +110,10 @@
"localtunnel": "^2.0.0",
"lodash.get": "^4.4.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.90.0",
"n8n-editor-ui": "~0.113.0",
"n8n-nodes-base": "~0.142.0",
"n8n-workflow": "~0.73.0",
"n8n-core": "~0.93.0",
"n8n-editor-ui": "~0.116.0",
"n8n-nodes-base": "~0.146.0",
"n8n-workflow": "~0.76.0",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
"pg": "^8.3.0",

View file

@ -5,9 +5,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { IRun } from 'n8n-workflow';
import { createDeferredPromise } from 'n8n-core';
import {
createDeferredPromise,
IDeferredPromise,
IExecuteResponsePromiseData,
IRun,
} from 'n8n-workflow';
import { ChildProcess } from 'child_process';
// eslint-disable-next-line import/no-extraneous-dependencies
@ -116,6 +119,28 @@ export class ActiveExecutions {
this.activeExecutions[executionId].workflowExecution = workflowExecution;
}
attachResponsePromise(
executionId: string,
responsePromise: IDeferredPromise<IExecuteResponsePromiseData>,
): void {
if (this.activeExecutions[executionId] === undefined) {
throw new Error(
`No active execution with id "${executionId}" got found to attach to workflowExecution to!`,
);
}
this.activeExecutions[executionId].responsePromise = responsePromise;
}
resolveResponsePromise(executionId: string, response: IExecuteResponsePromiseData): void {
if (this.activeExecutions[executionId] === undefined) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
this.activeExecutions[executionId].responsePromise?.resolve(response);
}
/**
* Remove an active execution
*
@ -193,6 +218,7 @@ export class ActiveExecutions {
this.activeExecutions[executionId].postExecutePromises.push(waitPromise);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
return waitPromise.promise();
}

View file

@ -12,7 +12,9 @@
import { ActiveWorkflows, NodeExecuteFunctions } from 'n8n-core';
import {
IDeferredPromise,
IExecuteData,
IExecuteResponsePromiseData,
IGetExecutePollFunctions,
IGetExecuteTriggerFunctions,
INode,
@ -40,8 +42,6 @@ import {
NodeTypes,
ResponseHelper,
WebhookHelpers,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
WorkflowCredentials,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
WorkflowRunner,
@ -550,6 +550,7 @@ export class ActiveWorkflowRunner {
data: INodeExecutionData[][],
additionalData: IWorkflowExecuteAdditionalDataWorkflow,
mode: WorkflowExecuteMode,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
) {
const nodeExecutionStack: IExecuteData[] = [
{
@ -580,7 +581,7 @@ export class ActiveWorkflowRunner {
};
const workflowRunner = new WorkflowRunner();
return workflowRunner.run(runData, true);
return workflowRunner.run(runData, true, undefined, undefined, responsePromise);
}
/**
@ -641,13 +642,16 @@ export class ActiveWorkflowRunner {
mode,
activation,
);
returnFunctions.emit = (data: INodeExecutionData[][]): void => {
returnFunctions.emit = (
data: INodeExecutionData[][],
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): void => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
Logger.debug(`Received trigger for workflow "${workflow.name}"`);
WorkflowHelpers.saveStaticData(workflow);
// eslint-disable-next-line id-denylist
this.runWorkflow(workflowData, node, data, additionalData, mode).catch((err) =>
console.error(err),
this.runWorkflow(workflowData, node, data, additionalData, mode, responsePromise).catch(
(error) => console.error(error),
);
};
return returnFunctions;

View file

@ -7,19 +7,19 @@ import {
ICredentialsEncrypted,
ICredentialType,
IDataObject,
IDeferredPromise,
IExecuteResponsePromiseData,
IRun,
IRunData,
IRunExecutionData,
ITaskData,
ITelemetrySettings,
IWorkflowBase as IWorkflowBaseWorkflow,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
IWorkflowCredentials,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { IDeferredPromise, WorkflowExecute } from 'n8n-core';
import { WorkflowExecute } from 'n8n-core';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as PCancelable from 'p-cancelable';
@ -47,6 +47,11 @@ export interface IBullJobResponse {
success: boolean;
}
export interface IBullWebhookResponse {
executionId: string;
response: IExecuteResponsePromiseData;
}
export interface ICustomRequest extends Request {
parsedUrl: Url | undefined;
}
@ -237,6 +242,7 @@ export interface IExecutingWorkflowData {
process?: ChildProcess;
startedAt: Date;
postExecutePromises: Array<IDeferredPromise<IRun | undefined>>;
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>;
workflowExecution?: PCancelable<IRun>;
}
@ -490,6 +496,7 @@ export interface IPushDataConsoleMessage {
export interface IResponseCallbackData {
data?: IDataObject | IDataObject[];
headers?: object;
noWebhookResponse?: boolean;
responseCode?: number;
}

View file

@ -40,6 +40,9 @@ class NodeTypesClass implements INodeTypes {
}
getByNameAndVersion(nodeType: string, version?: number): INodeType {
if (this.nodeTypes[nodeType] === undefined) {
throw new Error(`The node-type "${nodeType}" is not known!`);
}
return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version);
}
}

View file

@ -1,12 +1,21 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import * as Bull from 'bull';
import * as config from '../config';
// eslint-disable-next-line import/no-cycle
import { IBullJobData } from './Interfaces';
import { IBullJobData, IBullWebhookResponse } from './Interfaces';
// eslint-disable-next-line import/no-cycle
import * as ActiveExecutions from './ActiveExecutions';
// eslint-disable-next-line import/no-cycle
import * as WebhookHelpers from './WebhookHelpers';
export class Queue {
private activeExecutions: ActiveExecutions.ActiveExecutions;
private jobQueue: Bull.Queue;
constructor() {
this.activeExecutions = ActiveExecutions.getInstance();
const prefix = config.get('queue.bull.prefix') as string;
const redisOptions = config.get('queue.bull.redis') as object;
// Disabling ready check is necessary as it allows worker to
@ -16,6 +25,14 @@ export class Queue {
// More here: https://github.com/OptimalBits/bull/issues/890
// @ts-ignore
this.jobQueue = new Bull('jobs', { prefix, redis: redisOptions, enableReadyCheck: false });
this.jobQueue.on('global:progress', (jobId, progress: IBullWebhookResponse) => {
this.activeExecutions.resolveResponsePromise(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
progress.executionId,
WebhookHelpers.decodeWebhookResponse(progress.response),
);
});
}
async add(jobData: IBullJobData, jobOptions: object): Promise<Bull.Job> {

View file

@ -72,11 +72,16 @@ export function sendSuccessResponse(
data: any,
raw?: boolean,
responseCode?: number,
responseHeader?: object,
) {
if (responseCode !== undefined) {
res.status(responseCode);
}
if (responseHeader) {
res.header(responseHeader);
}
if (raw === true) {
if (typeof data === 'string') {
res.send(data);

View file

@ -679,6 +679,7 @@ class App {
// @ts-ignore
savedWorkflow.id = savedWorkflow.id.toString();
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(newWorkflow as IWorkflowBase);
return savedWorkflow;
},
@ -1579,11 +1580,11 @@ class App {
const findQuery = {} as FindManyOptions;
if (req.query.filter) {
findQuery.where = JSON.parse(req.query.filter as string);
if ((findQuery.where! as IDataObject).id !== undefined) {
if (findQuery.where.id !== undefined) {
// No idea if multiple where parameters make db search
// slower but to be sure that that is not the case we
// remove all unnecessary fields in case the id is defined.
findQuery.where = { id: (findQuery.where! as IDataObject).id };
findQuery.where = { id: findQuery.where.id };
}
}
@ -2668,7 +2669,13 @@ class App {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -2719,7 +2726,13 @@ class App {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -2745,7 +2758,13 @@ class App {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/prefer-optional-chain */
/* eslint-disable @typescript-eslint/no-shadow */
@ -18,9 +19,13 @@ import { get } from 'lodash';
import { BINARY_ENCODING, NodeExecuteFunctions } from 'n8n-core';
import {
createDeferredPromise,
IBinaryKeyData,
IDataObject,
IDeferredPromise,
IExecuteData,
IExecuteResponsePromiseData,
IN8nHttpFullResponse,
INode,
IRunExecutionData,
IWebhookData,
@ -34,20 +39,20 @@ import {
} from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
ActiveExecutions,
GenericHelpers,
IExecutionDb,
IResponseCallbackData,
IWorkflowDb,
IWorkflowExecutionDataProcess,
ResponseHelper,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
WorkflowCredentials,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
WorkflowRunner,
} from '.';
// eslint-disable-next-line import/no-cycle
import * as ActiveExecutions from './ActiveExecutions';
const activeExecutions = ActiveExecutions.getInstance();
/**
@ -91,6 +96,35 @@ export function getWorkflowWebhooks(
return returnData;
}
export function decodeWebhookResponse(
response: IExecuteResponsePromiseData,
): IExecuteResponsePromiseData {
if (
typeof response === 'object' &&
typeof response.body === 'object' &&
(response.body as IDataObject)['__@N8nEncodedBuffer@__']
) {
response.body = Buffer.from(
(response.body as IDataObject)['__@N8nEncodedBuffer@__'] as string,
BINARY_ENCODING,
);
}
return response;
}
export function encodeWebhookResponse(
response: IExecuteResponsePromiseData,
): IExecuteResponsePromiseData {
if (typeof response === 'object' && Buffer.isBuffer(response.body)) {
response.body = {
'__@N8nEncodedBuffer@__': response.body.toString(BINARY_ENCODING),
};
}
return response;
}
/**
* Returns all the webhooks which should be created for the give workflow
*
@ -169,7 +203,7 @@ export async function executeWebhook(
200,
) as number;
if (!['onReceived', 'lastNode'].includes(responseMode as string)) {
if (!['onReceived', 'lastNode', 'responseNode'].includes(responseMode as string)) {
// If the mode is not known we error. Is probably best like that instead of using
// the default that people know as early as possible (probably already testing phase)
// that something does not resolve properly.
@ -356,9 +390,52 @@ export async function executeWebhook(
workflowData,
};
let responsePromise: IDeferredPromise<IN8nHttpFullResponse> | undefined;
if (responseMode === 'responseNode') {
responsePromise = await createDeferredPromise<IN8nHttpFullResponse>();
responsePromise
.promise()
.then((response: IN8nHttpFullResponse) => {
if (didSendResponse) {
return;
}
if (Buffer.isBuffer(response.body)) {
res.header(response.headers);
res.end(response.body);
responseCallback(null, {
noWebhookResponse: true,
});
} else {
// TODO: This probably needs some more changes depending on the options on the
// Webhook Response node
responseCallback(null, {
data: response.body as IDataObject,
headers: response.headers,
responseCode: response.statusCode,
});
}
didSendResponse = true;
})
.catch(async (error) => {
Logger.error(
`Error with Webhook-Response for execution "${executionId}": "${error.message}"`,
{ executionId, workflowId: workflow.id },
);
});
}
// Start now to run the workflow
const workflowRunner = new WorkflowRunner();
executionId = await workflowRunner.run(runData, true, !didSendResponse, executionId);
executionId = await workflowRunner.run(
runData,
true,
!didSendResponse,
executionId,
responsePromise,
);
Logger.verbose(
`Started execution of workflow "${workflow.name}" from webhook with execution ID ${executionId}`,
@ -398,6 +475,20 @@ export async function executeWebhook(
return data;
}
if (responseMode === 'responseNode') {
if (!didSendResponse) {
// Return an error if no Webhook-Response node did send any data
responseCallback(null, {
data: {
message: 'Workflow executed sucessfully.',
},
responseCode,
});
didSendResponse = true;
}
return undefined;
}
if (returnData === undefined) {
if (!didSendResponse) {
responseCallback(null, {

View file

@ -64,7 +64,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -115,7 +121,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -141,7 +153,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -173,7 +191,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -199,7 +223,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -225,7 +255,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
}

View file

@ -15,6 +15,8 @@ import { IProcessMessage, WorkflowExecute } from 'n8n-core';
import {
ExecutionError,
IDeferredPromise,
IExecuteResponsePromiseData,
IRun,
LoggerProxy as Logger,
Workflow,
@ -41,9 +43,7 @@ import {
IBullJobResponse,
ICredentialsOverwrite,
ICredentialsTypeData,
IExecutionDb,
IExecutionFlattedDb,
IExecutionResponse,
IProcessMessageDataHook,
ITransferNodeTypes,
IWorkflowExecutionDataProcess,
@ -51,6 +51,7 @@ import {
NodeTypes,
Push,
ResponseHelper,
WebhookHelpers,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
} from '.';
@ -146,6 +147,7 @@ export class WorkflowRunner {
loadStaticData?: boolean,
realtime?: boolean,
executionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> {
const executionsProcess = config.get('executions.process') as string;
const executionsMode = config.get('executions.mode') as string;
@ -153,11 +155,17 @@ export class WorkflowRunner {
if (executionsMode === 'queue' && data.executionMode !== 'manual') {
// Do not run "manual" executions in bull because sending events to the
// frontend would not be possible
executionId = await this.runBull(data, loadStaticData, realtime, executionId);
executionId = await this.runBull(
data,
loadStaticData,
realtime,
executionId,
responsePromise,
);
} else if (executionsProcess === 'main') {
executionId = await this.runMainProcess(data, loadStaticData, executionId);
executionId = await this.runMainProcess(data, loadStaticData, executionId, responsePromise);
} else {
executionId = await this.runSubprocess(data, loadStaticData, executionId);
executionId = await this.runSubprocess(data, loadStaticData, executionId, responsePromise);
}
const postExecutePromise = this.activeExecutions.getPostExecutePromise(executionId);
@ -200,6 +208,7 @@ export class WorkflowRunner {
data: IWorkflowExecutionDataProcess,
loadStaticData?: boolean,
restartExecutionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> {
if (loadStaticData === true && data.workflowData.id) {
data.workflowData.staticData = await WorkflowHelpers.getStaticDataById(
@ -256,6 +265,15 @@ export class WorkflowRunner {
executionId,
true,
);
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
if (responsePromise) {
responsePromise.resolve(response);
}
},
];
additionalData.sendMessageToUI = WorkflowExecuteAdditionalData.sendMessageToUI.bind({
sessionId: data.sessionId,
});
@ -341,11 +359,15 @@ export class WorkflowRunner {
loadStaticData?: boolean,
realtime?: boolean,
restartExecutionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> {
// TODO: If "loadStaticData" is set to true it has to load data new on worker
// Register the active execution
const executionId = await this.activeExecutions.add(data, undefined, restartExecutionId);
if (responsePromise) {
this.activeExecutions.attachResponsePromise(executionId, responsePromise);
}
const jobData: IBullJobData = {
executionId,
@ -545,6 +567,7 @@ export class WorkflowRunner {
data: IWorkflowExecutionDataProcess,
loadStaticData?: boolean,
restartExecutionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> {
let startedAt = new Date();
const subprocess = fork(pathJoin(__dirname, 'WorkflowRunnerProcess.js'));
@ -653,6 +676,10 @@ export class WorkflowRunner {
} else if (message.type === 'end') {
clearTimeout(executionTimeout);
this.activeExecutions.remove(executionId, message.data.runData);
} else if (message.type === 'sendResponse') {
if (responsePromise) {
responsePromise.resolve(WebhookHelpers.decodeWebhookResponse(message.data.response));
}
} else if (message.type === 'sendMessageToUI') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
WorkflowExecuteAdditionalData.sendMessageToUI.bind({ sessionId: data.sessionId })(

View file

@ -10,6 +10,7 @@ import { IProcessMessage, UserSettings, WorkflowExecute } from 'n8n-core';
import {
ExecutionError,
IDataObject,
IExecuteResponsePromiseData,
IExecuteWorkflowInfo,
ILogger,
INodeExecutionData,
@ -33,6 +34,7 @@ import {
IWorkflowExecuteProcess,
IWorkflowExecutionDataProcessWithExecution,
NodeTypes,
WebhookHelpers,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
} from '.';
@ -200,6 +202,15 @@ export class WorkflowRunnerProcess {
workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000,
);
additionalData.hooks = this.getProcessForwardHooks();
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
await sendToParentProcess('sendResponse', {
response: WebhookHelpers.encodeWebhookResponse(response),
});
},
];
additionalData.executionId = inputData.executionId;
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -0,0 +1,39 @@
import { QueryRunner } from 'typeorm';
export class MigrationHelpers {
queryRunner: QueryRunner;
constructor(queryRunner: QueryRunner) {
this.queryRunner = queryRunner;
}
// runs an operation sequential on chunks of a query that returns a potentially large Array.
/* eslint-disable no-await-in-loop */
async runChunked(
query: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
operation: (results: any[]) => Promise<void>,
limit = 100,
): Promise<void> {
let offset = 0;
let chunkedQuery: string;
let chunkedQueryResults: unknown[];
do {
chunkedQuery = this.chunkQuery(query, limit, offset);
chunkedQueryResults = (await this.queryRunner.query(chunkedQuery)) as unknown[];
// pass a copy to prevent errors from mutation
await operation([...chunkedQueryResults]);
offset += limit;
} while (chunkedQueryResults.length === limit);
}
/* eslint-enable no-await-in-loop */
private chunkQuery(query: string, limit: number, offset = 0): string {
return `
${query}
LIMIT ${limit}
OFFSET ${offset}
`;
}
}

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
@ -8,58 +9,100 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630451444017';
public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, workflowData
FROM ${tablePrefix}execution_entity
WHERE waitTill IS NOT NULL AND finished = 0
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, workflowData
@ -68,8 +111,8 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
ORDER BY startedAt DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -78,7 +121,6 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
@ -92,77 +134,124 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
console.timeEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
credentialsUpdated = true;
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, workflowData
FROM ${tablePrefix}execution_entity
WHERE waitTill IS NOT NULL AND finished = 0
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, workflowData
@ -171,8 +260,8 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
ORDER BY startedAt DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -200,15 +289,15 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
}

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
@ -8,62 +9,104 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630419189837';
public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
let tablePrefix = config.get('database.tablePrefix');
const schema = config.get('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM ${tablePrefix}execution_entity
WHERE "waitTill" IS NOT NULL AND finished = FALSE
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData"
@ -73,7 +116,8 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -104,9 +148,10 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
console.timeEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
@ -115,62 +160,109 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
credentialsUpdated = true;
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM ${tablePrefix}execution_entity
WHERE "waitTill" IS NOT NULL AND finished = FALSE
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData"
@ -179,8 +271,8 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
ORDER BY "startedAt" DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -208,15 +300,15 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
}

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
@ -8,58 +9,101 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630330987096';
public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM "${tablePrefix}credentials_entity"
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM "${tablePrefix}workflow_entity"
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id || null, name };
credentialsUpdated = true;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM "${tablePrefix}execution_entity"
WHERE "waitTill" IS NOT NULL AND finished = 0
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData"
@ -68,8 +112,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
ORDER BY "startedAt" DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
@ -78,12 +122,11 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id || null, name };
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
@ -92,77 +135,127 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
console.timeEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM "${tablePrefix}credentials_entity"
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM "${tablePrefix}workflow_entity"
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
await helpers.runChunked(workflowsQuery, (workflows) => {
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore double-equals because creds.id can be string or number
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
credentialsUpdated = true;
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM "${tablePrefix}execution_entity"
WHERE "waitTill" IS NOT NULL AND finished = 0
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
// @ts-ignore
waitingExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore double-equals because creds.id can be string or number
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData"
@ -172,7 +265,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
@ -181,10 +275,9 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
// @ts-ignore double-equals because creds.id can be string or number
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
@ -200,15 +293,15 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
}

View file

@ -119,6 +119,7 @@ export class Telemetry {
this.client.identify(
{
userId: this.instanceId,
anonymousId: '000000000000',
traits: {
...traits,
instanceId: this.instanceId,
@ -138,6 +139,7 @@ export class Telemetry {
this.client.track(
{
userId: this.instanceId,
anonymousId: '000000000000',
event: eventName,
properties,
},

View file

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "0.90.0",
"version": "0.93.0",
"description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -50,7 +50,7 @@
"form-data": "^4.0.0",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.73.0",
"n8n-workflow": "~0.76.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"qs": "^6.10.1",

View file

@ -22,6 +22,7 @@ import {
ICredentialsExpressionResolveValues,
IDataObject,
IExecuteFunctions,
IExecuteResponsePromiseData,
IExecuteSingleFunctions,
IExecuteWorkflowInfo,
IHttpRequestOptions,
@ -71,7 +72,7 @@ import { fromBuffer } from 'file-type';
import { lookup } from 'mime-types';
import axios, { AxiosProxyConfig, AxiosRequestConfig, Method } from 'axios';
import { URLSearchParams } from 'url';
import { URL, URLSearchParams } from 'url';
// eslint-disable-next-line import/no-cycle
import {
BINARY_ENCODING,
@ -86,6 +87,14 @@ import {
axios.defaults.timeout = 300000;
// Prevent axios from adding x-form-www-urlencoded headers by default
axios.defaults.headers.post = {};
axios.defaults.headers.put = {};
axios.defaults.headers.patch = {};
axios.defaults.paramsSerializer = (params) => {
if (params instanceof URLSearchParams) {
return params.toString();
}
return stringify(params, { arrayFormat: 'indices' });
};
const requestPromiseWithDefaults = requestPromise.defaults({
timeout: 300000, // 5 minutes
@ -128,6 +137,28 @@ function searchForHeader(headers: IDataObject, headerName: string) {
return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName);
}
async function generateContentLengthHeader(formData: FormData, headers: IDataObject) {
if (!formData || !formData.getLength) {
return;
}
try {
const length = await new Promise((res, rej) => {
formData.getLength((error: Error | null, length: number) => {
if (error) {
rej(error);
return;
}
res(length);
});
});
headers = Object.assign(headers, {
'content-length': length,
});
} catch (error) {
Logger.error('Unable to calculate form data length', { error });
}
}
async function parseRequestObject(requestObject: IDataObject) {
// This function is a temporary implementation
// That translates all http requests done via
@ -192,6 +223,7 @@ async function parseRequestObject(requestObject: IDataObject) {
delete axiosConfig.headers[contentTypeHeaderKeyName];
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig.data, axiosConfig.headers);
} else {
// When using the `form` property it means the content should be x-www-form-urlencoded.
if (requestObject.form !== undefined && requestObject.body === undefined) {
@ -228,6 +260,7 @@ async function parseRequestObject(requestObject: IDataObject) {
// Mix in headers as FormData creates the boundary.
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig.data, axiosConfig.headers);
} else if (requestObject.body !== undefined) {
// If we have body and possibly form
if (requestObject.form !== undefined) {
@ -338,7 +371,63 @@ async function parseRequestObject(requestObject: IDataObject) {
}
if (requestObject.proxy !== undefined) {
axiosConfig.proxy = requestObject.proxy as AxiosProxyConfig;
// try our best to parse the url provided.
if (typeof requestObject.proxy === 'string') {
try {
const url = new URL(requestObject.proxy);
axiosConfig.proxy = {
host: url.hostname,
port: parseInt(url.port, 10),
protocol: url.protocol,
};
if (!url.port) {
// Sets port to a default if not informed
if (url.protocol === 'http') {
axiosConfig.proxy.port = 80;
} else if (url.protocol === 'https') {
axiosConfig.proxy.port = 443;
}
}
if (url.username || url.password) {
axiosConfig.proxy.auth = {
username: url.username,
password: url.password,
};
}
} catch (error) {
// Not a valid URL. We will try to simply parse stuff
// such as user:pass@host:port without protocol (we'll assume http)
if (requestObject.proxy.includes('@')) {
const [userpass, hostport] = requestObject.proxy.split('@');
const [username, password] = userpass.split(':');
const [hostname, port] = hostport.split(':');
axiosConfig.proxy = {
host: hostname,
port: parseInt(port, 10),
protocol: 'http',
auth: {
username,
password,
},
};
} else if (requestObject.proxy.includes(':')) {
const [hostname, port] = requestObject.proxy.split(':');
axiosConfig.proxy = {
host: hostname,
port: parseInt(port, 10),
protocol: 'http',
};
} else {
axiosConfig.proxy = {
host: requestObject.proxy,
port: 80,
protocol: 'http',
};
}
}
} else {
axiosConfig.proxy = requestObject.proxy as AxiosProxyConfig;
}
}
if (requestObject.encoding === null) {
@ -357,6 +446,7 @@ async function parseRequestObject(requestObject: IDataObject) {
if (
requestObject.json !== false &&
axiosConfig.data !== undefined &&
axiosConfig.data !== '' &&
!(axiosConfig.data instanceof Buffer) &&
!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type')
) {
@ -406,6 +496,11 @@ async function proxyRequestToAxios(
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject));
Logger.debug('Proxying request to axios', {
originalConfig: configObject,
parsedConfig: axiosConfig,
});
return new Promise((resolve, reject) => {
axios(axiosConfig)
.then((response) => {
@ -1567,6 +1662,9 @@ export function getExecuteFunctions(
Logger.warn(`There was a problem sending messsage to UI: ${error.message}`);
}
},
async sendResponse(response: IExecuteResponsePromiseData): Promise<void> {
await additionalData.hooks?.executeHookFunctions('sendResponse', [response]);
},
helpers: {
httpRequest,
prepareBinaryData,

View file

@ -12,7 +12,6 @@ export * from './ActiveWorkflows';
export * from './ActiveWebhooks';
export * from './Constants';
export * from './Credentials';
export * from './DeferredPromise';
export * from './Interfaces';
export * from './LoadNodeParameterOptions';
export * from './NodeExecuteFunctions';

View file

@ -4,6 +4,7 @@ import {
ICredentialDataDecryptedObject,
ICredentialsHelper,
IDataObject,
IDeferredPromise,
IExecuteWorkflowInfo,
INodeCredentialsDetails,
INodeExecutionData,
@ -20,7 +21,7 @@ import {
WorkflowHooks,
} from 'n8n-workflow';
import { Credentials, IDeferredPromise, IExecuteFunctions } from '../src';
import { Credentials, IExecuteFunctions } from '../src';
export class CredentialsHelper extends ICredentialsHelper {
getDecrypted(

View file

@ -1,6 +1,14 @@
import { IConnections, ILogger, INode, IRun, LoggerProxy, Workflow } from 'n8n-workflow';
import {
createDeferredPromise,
IConnections,
ILogger,
INode,
IRun,
LoggerProxy,
Workflow,
} from 'n8n-workflow';
import { createDeferredPromise, WorkflowExecute } from '../src';
import { WorkflowExecute } from '../src';
import * as Helpers from './Helpers';

View file

@ -1,6 +1,6 @@
{
"name": "n8n-design-system",
"version": "0.5.0",
"version": "0.6.0",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {

View file

@ -16,7 +16,7 @@ import VariableTable from './VariableTable.vue';
<Canvas>
<Story name="border-radius">
{{
template: `<variable-table :variables="['--border-radius-small','--border-radius-base']" />`,
template: `<variable-table :variables="['--border-radius-small','--border-radius-base', '--border-radius-large', '--border-radius-xlarge']" />`,
components: {
VariableTable,
},

View file

@ -44,7 +44,7 @@ import ColorCircles from './ColorCircles.vue';
<Canvas>
<Story name="success">
{{
template: `<color-circles :colors="['--color-success', '--color-success-tint-1', '--color-success-tint-2']" />`,
template: `<color-circles :colors="['--color-success', '--color-success-tint-1', '--color-success-tint-2', '--color-success-light']" />`,
components: {
ColorCircles,
},
@ -109,7 +109,7 @@ import ColorCircles from './ColorCircles.vue';
<Canvas>
<Story name="foreground">
{{
template: `<color-circles :colors="['--color-foreground-base', '--color-foreground-light', '--color-foreground-xlight']" />`,
template: `<color-circles :colors="['--color-foreground-xdark', '--color-foreground-dark', '--color-foreground-base', '--color-foreground-light', '--color-foreground-xlight']" />`,
components: {
ColorCircles,
},
@ -129,3 +129,16 @@ import ColorCircles from './ColorCircles.vue';
}}
</Story>
</Canvas>
## Canvas
<Canvas>
<Story name="canvas">
{{
template: `<color-circles :colors="['--color-canvas-background', '--color-canvas-dot']" />`,
components: {
ColorCircles,
},
}}
</Story>
</Canvas>

View file

@ -75,6 +75,15 @@
var(--color-success-tint-2-l)
);
--color-success-light-h: 150;
--color-success-light-s: 54%;
--color-success-light-l: 70%;
--color-success-light: hsl(
var(--color-success-light-h),
var(--color-success-light-s),
var(--color-success-light-l)
);
--color-warning-h: 36;
--color-warning-s: 77%;
--color-warning-l: 57%;
@ -187,6 +196,24 @@
var(--color-text-xlight-l)
);
--color-foreground-xdark-h: 220;
--color-foreground-xdark-s: 7.4%;
--color-foreground-xdark-l: 52.5%;
--color-foreground-xdark: hsl(
var(--color-foreground-xdark-h),
var(--color-foreground-xdark-s),
var(--color-foreground-xdark-l)
);
--color-foreground-dark-h: 228;
--color-foreground-dark-s: 9.6%;
--color-foreground-dark-l: 79.6%;
--color-foreground-dark: hsl(
var(--color-foreground-dark-h),
var(--color-foreground-dark-s),
var(--color-foreground-dark-l)
);
--color-foreground-base-h: 220;
--color-foreground-base-s: 20%;
--color-foreground-base-l: 88.2%;
@ -259,6 +286,25 @@
var(--color-background-xlight-l)
);
--color-canvas-dot-h: 204;
--color-canvas-dot-s: 15.6%;
--color-canvas-dot-l: 87.5%;
--color-canvas-dot: hsl(
var(--color-canvas-dot-h),
var(--color-canvas-dot-s),
var(--color-canvas-dot-l)
);
--color-canvas-background-h: 260;
--color-canvas-background-s: 100%;
--color-canvas-background-l: 99.4%;
--color-canvas-background: hsl(
var(--color-canvas-background-h),
var(--color-canvas-background-s),
var(--color-canvas-background-l)
);
--border-radius-xlarge: 12px;
--border-radius-large: 8px;
--border-radius-base: 4px;
--border-radius-small: 2px;

View file

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "0.113.0",
"version": "0.116.0",
"description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -26,7 +26,7 @@
},
"dependencies": {
"@fontsource/open-sans": "^4.5.0",
"n8n-design-system": "~0.5.0",
"n8n-design-system": "~0.6.0",
"timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2",
"vue-fragment": "^1.5.2"
@ -71,7 +71,7 @@
"lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"n8n-workflow": "~0.73.0",
"n8n-workflow": "~0.76.0",
"sass": "^1.26.5",
"normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1",

View file

@ -22,32 +22,61 @@ import {
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
PaintStyle,
} from 'jsplumb';
declare module 'jsplumb' {
interface PaintStyle {
stroke?: string;
fill?: string;
strokeWidth?: number;
outlineStroke?: string;
outlineWidth?: number;
}
interface Anchor {
lastReturnValue: number[];
}
interface Connection {
__meta?: {
sourceNodeName: string,
sourceOutputIndex: number,
targetNodeName: string,
targetOutputIndex: number,
};
canvas?: HTMLElement;
connector?: {
setTargetEndpoint: (endpoint: Endpoint) => void;
resetTargetEndpoint: () => void;
bounds: {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
};
// bind(event: string, (connection: Connection): void;): void; // tslint:disable-line:no-any
bind(event: string, callback: Function): void; // tslint:disable-line:no-any
bind(event: string, callback: Function): void;
removeOverlay(name: string): void;
removeOverlays(): void;
setParameter(name: string, value: any): void; // tslint:disable-line:no-any
setPaintStyle(arg0: PaintStyle): void;
addOverlay(arg0: any[]): void; // tslint:disable-line:no-any
setConnector(arg0: any[]): void; // tslint:disable-line:no-any
getUuids(): [string, string];
}
interface Endpoint {
__meta?: {
nodeName: string,
index: number,
};
getOverlay(name: string): any; // tslint:disable-line:no-any
}
interface Overlay {
setVisible(visible: boolean): void;
setLocation(location: number): void;
canvas?: HTMLElement;
}
interface OnConnectionBindInfo {
@ -66,18 +95,14 @@ export interface IEndpointOptions {
dragProxy?: any; // tslint:disable-line:no-any
endpoint?: string;
endpointStyle?: object;
endpointHoverStyle?: object;
isSource?: boolean;
isTarget?: boolean;
maxConnections?: number;
overlays?: any; // tslint:disable-line:no-any
parameters?: any; // tslint:disable-line:no-any
uuid?: string;
}
export interface IConnectionsUi {
[key: string]: {
[key: string]: IEndpointOptions;
};
enabled?: boolean;
}
export interface IUpdateInformation {
@ -95,20 +120,16 @@ export interface INodeUpdatePropertiesInformation {
};
}
export type XYPositon = [number, number];
export type XYPosition = [number, number];
export type MessageType = 'success' | 'warning' | 'info' | 'error';
export interface INodeUi extends INode {
position: XYPositon;
position: XYPosition;
color?: string;
notes?: string;
issues?: INodeIssues;
_jsPlumb?: {
endpoints?: {
[key: string]: IEndpointOptions[];
};
};
name: string;
}
export interface INodeTypesMaxCount {
@ -604,7 +625,7 @@ export interface IRootState {
lastSelectedNodeOutputIndex: number | null;
nodeIndex: Array<string | null>;
nodeTypes: INodeTypeDescription[];
nodeViewOffsetPosition: XYPositon;
nodeViewOffsetPosition: XYPosition;
nodeViewMoveInProgress: boolean;
selectedNodes: INodeUi[];
sessionId: string;
@ -670,5 +691,13 @@ export interface IRestApiContext {
export interface IZoomConfig {
scale: number;
offset: XYPositon;
offset: XYPosition;
}
export interface IBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}

View file

@ -1,25 +1,35 @@
<template>
<div class="node-wrapper" :style="nodePosition">
<div class="node-default" :ref="data.name" :style="nodeStyle" :class="nodeClass" @dblclick="setNodeActive" @click.left="mouseLeftClick" v-touch:start="touchStart" v-touch:end="touchEnd">
<div v-if="hasIssues" class="node-info-icon node-issues">
<n8n-tooltip placement="top" >
<div slot="content" v-html="nodeIssues"></div>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
<el-badge v-else :hidden="workflowDataItems === 0" class="node-info-icon data-count" :value="workflowDataItems"></el-badge>
<div :class="{'node-wrapper': true, selected: isSelected}" :style="nodePosition">
<div class="select-background" v-show="isSelected"></div>
<div :class="{'node-default': true, 'touch-active': isTouchActive, 'is-touch-device': isTouchDevice}" :data-name="data.name" :ref="data.name">
<div :class="nodeClass" :style="nodeStyle" @dblclick="setNodeActive" @click.left="mouseLeftClick" v-touch:start="touchStart" v-touch:end="touchEnd">
<div v-if="!data.disabled" :class="{'node-info-icon': true, 'shift-icon': shiftOutputCount}">
<div v-if="hasIssues" class="node-issues">
<n8n-tooltip placement="bottom" >
<div slot="content" v-html="nodeIssues"></div>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
<div v-else-if="waiting" class="waiting">
<n8n-tooltip placement="bottom">
<div slot="content" v-html="waiting"></div>
<font-awesome-icon icon="clock" />
</n8n-tooltip>
</div>
<span v-else-if="workflowDataItems" class="data-count">
<font-awesome-icon icon="check" />
<span v-if="workflowDataItems > 1" class="items-count"> {{ workflowDataItems }}</span>
</span>
</div>
<div v-if="waiting" class="node-info-icon waiting">
<n8n-tooltip placement="top">
<div slot="content" v-html="waiting"></div>
<font-awesome-icon icon="clock" />
</n8n-tooltip>
<div class="node-executing-info" title="Node is executing">
<font-awesome-icon icon="sync-alt" spin />
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" :disabled="this.data.disabled"/>
</div>
<div class="node-executing-info" title="Node is executing">
<font-awesome-icon icon="sync-alt" spin />
</div>
<div class="node-options no-select-on-click" v-if="!isReadOnly">
<div class="node-options no-select-on-click" v-if="!isReadOnly" v-show="!hideActions">
<div v-touch:tap="deleteNode" class="option" title="Delete Node" >
<font-awesome-icon icon="trash" />
</div>
@ -36,12 +46,12 @@
<font-awesome-icon class="execute-icon" icon="play-circle" />
</div>
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :circle="true" :shrink="true" :disabled="this.data.disabled"/>
<div :class="{'disabled-linethrough': true, success: workflowDataItems > 0}" v-if="showDisabledLinethrough"></div>
</div>
<div class="node-description">
<div class="node-name" :title="data.name">
{{data.name}}
<p>{{ nodeTitle }}</p>
<p v-if="data.disabled">(Disabled)</p>
</div>
<div v-if="nodeSubtitle !== undefined" class="node-subtitle" :title="nodeSubtitle">
{{nodeSubtitle}}
@ -61,6 +71,7 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import {
INodeTypeDescription,
ITaskData,
NodeHelpers,
} from 'n8n-workflow';
@ -69,6 +80,8 @@ import NodeIcon from '@/components/NodeIcon.vue';
import mixins from 'vue-typed-mixins';
import { get } from 'lodash';
import { getStyleTokenValue } from './helpers';
import { INodeUi, XYPosition } from '@/Interface';
export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).extend({
name: 'Node',
@ -76,8 +89,17 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
NodeIcon,
},
computed: {
workflowDataItems () {
const workflowResultDataNode = this.$store.getters.getWorkflowResultDataByNodeName(this.data.name);
nodeRunData(): ITaskData[] {
return this.$store.getters.getWorkflowResultDataByNodeName(this.data.name);
},
hasIssues (): boolean {
if (this.data.issues !== undefined && Object.keys(this.data.issues).length) {
return true;
}
return false;
},
workflowDataItems (): number {
const workflowResultDataNode = this.nodeRunData;
if (workflowResultDataNode === null) {
return 0;
}
@ -90,34 +112,12 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
nodeType (): INodeTypeDescription | null {
return this.$store.getters.nodeType(this.data.type);
},
nodeClass () {
const classes = [];
if (this.data.disabled) {
classes.push('disabled');
}
if (this.isExecuting) {
classes.push('executing');
}
if (this.workflowDataItems !== 0) {
classes.push('has-data');
}
if (this.hasIssues) {
classes.push('has-issues');
}
if (this.isTouchDevice) {
classes.push('is-touch-device');
}
if (this.isTouchActive) {
classes.push('touch-active');
}
return classes;
nodeClass (): object {
return {
'node-box': true,
disabled: this.data.disabled,
executing: this.isExecuting,
};
},
nodeIssues (): string {
if (this.data.issues === undefined) {
@ -135,6 +135,27 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
return 'play';
}
},
position (): XYPosition {
const node = this.$store.getters.nodesByName[this.name] as INodeUi; // position responsive to store changes
return node.position;
},
showDisabledLinethrough(): boolean {
return !!(this.data.disabled && this.nodeType && this.nodeType.inputs.length === 1 && this.nodeType.outputs.length === 1);
},
nodePosition (): object {
const returnStyles: {
[key: string]: string;
} = {
left: this.position[0] + 'px',
top: this.position[1] + 'px',
};
return returnStyles;
},
nodeTitle (): string {
return this.data.name;
},
waiting (): string | undefined {
const workflowExecution = this.$store.getters.getWorkflowExecution;
@ -154,6 +175,38 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
nodeStyle (): object {
let borderColor = getStyleTokenValue('--color-foreground-xdark');
if (this.data.disabled) {
borderColor = getStyleTokenValue('--color-foreground-base');
}
else if (!this.isExecuting) {
if (this.hasIssues) {
borderColor = getStyleTokenValue('--color-danger');
}
else if (this.waiting) {
borderColor = getStyleTokenValue('--color-secondary');
}
else if (this.workflowDataItems) {
borderColor = getStyleTokenValue('--color-success');
}
}
const returnStyles: {
[key: string]: string;
} = {
'border-color': borderColor,
};
return returnStyles;
},
isSelected (): boolean {
return this.$store.getters.getSelectedNodes.find((node: INodeUi) => node.name === this.data.name);
},
shiftOutputCount (): boolean {
return !!(this.nodeType && this.nodeType.outputs.length > 2);
},
},
watch: {
isActive(newValue, oldValue) {
@ -161,9 +214,15 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
this.setSubtitle();
}
},
nodeRunData(newValue) {
this.$emit('run', {name: this.data.name, data: newValue, waiting: !!this.waiting});
},
},
mounted() {
this.setSubtitle();
setTimeout(() => {
this.$emit('run', {name: this.data.name, data: this.nodeRunData, waiting: !!this.waiting});
}, 0);
},
data () {
return {
@ -213,7 +272,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
</script>
<style lang="scss">
<style lang="scss" scoped>
.node-wrapper {
position: absolute;
@ -221,20 +280,25 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
height: 100px;
.node-description {
line-height: 1.5;
position: absolute;
bottom: -55px;
top: 100px;
left: -50px;
width: 200px;
height: 50px;
line-height: 1.5;
text-align: center;
cursor: default;
padding: 8px;
width: 200px;
pointer-events: none; // prevent container from being draggable
.node-name {
white-space: nowrap;
overflow: hidden;
.node-name > p { // must be paragraph tag to have two lines in safari
text-overflow: ellipsis;
font-weight: 500;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-compact);
}
.node-subtitle {
@ -248,33 +312,24 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
}
.node-default {
position: absolute;
width: 100%;
height: 100%;
background-color: #fff;
border-radius: 25px;
text-align: center;
z-index: 24;
cursor: pointer;
color: #444;
border: 1px dashed grey;
&.has-data {
border-style: solid;
}
.node-box {
width: 100%;
height: 100%;
border: 2px solid var(--color-foreground-xdark);
border-radius: var(--border-radius-large);
background-color: var(--color-background-xlight);
&.disabled {
color: #a0a0a0;
text-decoration: line-through;
border: 1px solid #eee !important;
background-color: #eee;
}
&.executing {
background-color: $--color-primary-light !important;
&.executing {
background-color: $--color-primary-light !important;
border-color: $--color-primary !important;
.node-executing-info {
display: inline-block;
.node-executing-info {
display: inline-block;
}
}
}
@ -305,39 +360,35 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
.node-icon {
position: absolute;
top: calc(50% - 30px);
left: calc(50% - 30px);
top: calc(50% - 20px);
left: calc(50% - 20px);
}
.node-info-icon {
position: absolute;
top: -14px;
right: 12px;
z-index: 11;
bottom: 6px;
right: 6px;
&.data-count {
&.shift-icon {
right: 12px;
}
.data-count {
font-weight: 600;
top: -12px;
color: var(--color-success);
}
&.waiting {
left: 10px;
top: -12px;
.node-issues {
color: var(--color-danger);
}
}
.node-issues {
width: 25px;
height: 25px;
font-size: 20px;
color: #ff0000;
.items-count {
font-size: var(--font-size-s);
}
}
.waiting {
width: 25px;
height: 25px;
font-size: 20px;
color: #5e5efa;
color: var(--color-secondary);
}
.node-options {
@ -346,7 +397,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
top: -25px;
left: -10px;
width: 120px;
height: 45px;
height: 24px;
font-size: 0.9em;
text-align: left;
z-index: 10;
@ -381,45 +432,94 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
display: initial;
}
}
}
}
&.has-data .node-options,
&.has-issues .node-options {
top: -35px;
}
.select-background {
display: block;
background-color: hsla(var(--color-foreground-base-h), var(--color-foreground-base-s), var(--color-foreground-base-l), 60%);
border-radius: var(--border-radius-xlarge);
overflow: hidden;
position: absolute;
left: -8px !important;
top: -8px !important;
height: 116px;
width: 116px !important;
}
.disabled-linethrough {
border: 1px solid var(--color-foreground-dark);
position: absolute;
top: 49px;
left: -3px;
width: 111px;
pointer-events: none;
&.success {
border-color: var(--color-success-light);
}
}
</style>
<style>
.el-badge__content {
border-width: 2px;
background-color: #67c23a;
<style lang="scss">
/** node */
.node-wrapper.selected {
z-index: 2;
}
/** connector */
.jtk-connector {
z-index:4;
z-index: 3;
}
.jtk-connector path {
transition: stroke .1s ease-in-out;
}
.jtk-connector.success {
z-index: 4;
}
/** node endpoints */
.jtk-endpoint {
z-index:5;
}
.jtk-connector.jtk-hover {
z-index: 6;
}
.disabled-linethrough {
z-index: 6;
}
.jtk-endpoint.jtk-hover {
z-index: 7;
}
.jtk-overlay {
z-index:6;
z-index:7;
}
.jtk-endpoint.dropHover {
border: 2px solid #ff2244;
.jtk-connector.jtk-dragging {
z-index: 8;
}
.jtk-drag-selected .node-default {
/* https://www.cssmatic.com/box-shadow */
-webkit-box-shadow: 0px 0px 6px 2px rgba(50, 75, 216, 0.37);
-moz-box-shadow: 0px 0px 6px 2px rgba(50, 75, 216, 0.37);
box-shadow: 0px 0px 6px 2px rgba(50, 75, 216, 0.37);
.jtk-endpoint.jtk-drag-active {
z-index: 9;
}
.disabled .node-icon img {
-webkit-filter: contrast(40%) brightness(1.5) grayscale(100%);
filter: contrast(40%) brightness(1.5) grayscale(100%);
.connection-actions {
z-index: 10;
}
.node-options {
z-index: 10;
}
.drop-add-node-label {
z-index: 10;
}
</style>

View file

@ -101,7 +101,7 @@ export default mixins(
credentialTypesNodeDescription (): INodeCredentialDescription[] {
const node = this.node as INodeUi;
const activeNodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription;
const activeNodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription | null;
if (activeNodeType && activeNodeType.credentials) {
return activeNodeType.credentials;
}

View file

@ -1,8 +1,8 @@
<template>
<div class="node-icon-wrapper" :style="iconStyleData" :class="{shrink: isSvgIcon && shrink, full: !shrink}">
<div class="node-icon-wrapper" :style="iconStyleData">
<div v-if="nodeIconData !== null" class="icon">
<img v-if="nodeIconData.type === 'file'" :src="nodeIconData.fileBuffer || nodeIconData.path" style="max-width: 100%; max-height: 100%;" />
<font-awesome-icon v-else :icon="nodeIconData.icon || nodeIconData.path" />
<img v-if="nodeIconData.type === 'file'" :src="nodeIconData.fileBuffer || nodeIconData.path" :style="imageStyleData" />
<font-awesome-icon v-else :icon="nodeIconData.icon || nodeIconData.path" :style="fontStyleData" />
</div>
<div v-else class="node-icon-placeholder">
{{nodeType !== null ? nodeType.displayName.charAt(0) : '?' }}
@ -12,39 +12,65 @@
<script lang="ts">
import { IVersionNode } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import Vue from 'vue';
interface NodeIconData {
type: string;
path: string;
path?: string;
fileExtension?: string;
fileBuffer?: string;
}
export default Vue.extend({
name: 'NodeIcon',
props: [
'nodeType',
'size',
'shrink',
'disabled',
'circle',
],
props: {
nodeType: {},
size: {
type: Number,
},
disabled: {
type: Boolean,
default: false,
},
circle: {
type: Boolean,
default: false,
},
},
computed: {
iconStyleData (): object {
const color = this.disabled ? '#ccc' : this.nodeType.defaults && this.nodeType.defaults.color;
const nodeType = this.nodeType as INodeTypeDescription | IVersionNode | null;
const color = nodeType ? nodeType.defaults && nodeType!.defaults.color : '';
if (!this.size) {
return {color};
}
const size = parseInt(this.size, 10);
return {
color,
width: size + 'px',
height: size + 'px',
'font-size': Math.floor(parseInt(this.size, 10) * 0.6) + 'px',
'line-height': size + 'px',
'border-radius': this.circle ? '50%': '4px',
width: this.size + 'px',
height: this.size + 'px',
'font-size': this.size + 'px',
'line-height': this.size + 'px',
'border-radius': this.circle ? '50%': '2px',
...(this.disabled && {
color: '#ccc',
'-webkit-filter': 'contrast(40%) brightness(1.5) grayscale(100%)',
'filter': 'contrast(40%) brightness(1.5) grayscale(100%)',
}),
};
},
fontStyleData (): object {
return {
'max-width': this.size + 'px',
};
},
imageStyleData (): object {
return {
width: '100%',
'max-width': '100%',
'max-height': '100%',
};
},
isSvgIcon (): boolean {
@ -54,26 +80,27 @@ export default Vue.extend({
return false;
},
nodeIconData (): null | NodeIconData {
if (this.nodeType === null) {
const nodeType = this.nodeType as INodeTypeDescription | IVersionNode | null;
if (nodeType === null) {
return null;
}
if (this.nodeType.iconData) {
return this.nodeType.iconData;
if ((nodeType as IVersionNode).iconData) {
return (nodeType as IVersionNode).iconData;
}
const restUrl = this.$store.getters.getRestUrl;
if (this.nodeType.icon) {
if (nodeType.icon) {
let type, path;
[type, path] = this.nodeType.icon.split(':');
[type, path] = nodeType.icon.split(':');
const returnData: NodeIconData = {
type,
path,
};
if (type === 'file') {
returnData.path = restUrl + '/node-icon/' + this.nodeType.name;
returnData.path = restUrl + '/node-icon/' + nodeType.name;
returnData.fileExtension = path.split('.').slice(-1).join();
}
@ -90,7 +117,7 @@ export default Vue.extend({
.node-icon-wrapper {
width: 26px;
height: 26px;
border-radius: 4px;
border-radius: 2px;
color: #444;
line-height: 26px;
font-size: 1.1em;
@ -99,7 +126,7 @@ export default Vue.extend({
font-weight: bold;
font-size: 20px;
&.full .icon {
.icon {
height: 100%;
width: 100%;
@ -108,10 +135,6 @@ export default Vue.extend({
align-items: center;
}
&.shrink .icon {
margin: 0.24em;
}
.node-icon-placeholder {
text-align: center;
}

View file

@ -122,13 +122,6 @@ export default mixins(
return this.nodeType.properties;
},
isColorDefaultValue (): boolean {
if (this.nodeType === null) {
return false;
}
return this.node.color === this.nodeType.defaults.color;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
@ -170,14 +163,6 @@ export default mixins(
noDataExpression: true,
description: 'If active, the note above will display in the flow as a subtitle.',
},
{
displayName: 'Node Color',
name: 'color',
type: 'color',
default: '#ff0000',
noDataExpression: true,
description: 'The color of the node in the flow.',
},
{
displayName: 'Always Output Data',
name: 'alwaysOutputData',
@ -317,7 +302,7 @@ export default mixins(
// Update the values on the node
this.$store.commit('updateNodeProperties', updateInformation);
const node = this.$store.getters.nodeByName(updateInformation.name);
const node = this.$store.getters.getNodeByName(updateInformation.name);
// Update the issues
this.updateNodeCredentialIssues(node);
@ -337,7 +322,7 @@ export default mixins(
// Save the node name before we commit the change because
// we need the old name to rename the node properly
const nodeNameBefore = parameterData.node || this.node.name;
const node = this.$store.getters.nodeByName(nodeNameBefore);
const node = this.$store.getters.getNodeByName(nodeNameBefore);
if (parameterData.name === 'name') {
// Name of node changed so we have to set also the new node name as active
@ -353,7 +338,10 @@ export default mixins(
} else if (parameterData.name.startsWith('parameters.')) {
// A node parameter changed
const nodeType = this.$store.getters.nodeType(node.type);
const nodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription | null;
if (!nodeType) {
return;
}
// Get only the parameters which are different to the defaults
let nodeParameters = NodeHelpers.getNodeParameters(nodeType.properties, node.parameters, false, false);
@ -491,10 +479,6 @@ export default mixins(
// Set default value
Vue.set(this.nodeValues, nodeSetting.name, nodeSetting.default);
}
if (nodeSetting.name === 'color') {
// For color also apply the default node color to the node settings
nodeSetting.default = this.nodeType.defaults.color;
}
}
Vue.set(this.nodeValues, 'parameters', JSON.parse(JSON.stringify(this.node.parameters)));

View file

@ -64,7 +64,7 @@ export default mixins(
],
data () {
return {
isMinimized: this.nodeType.name !== WEBHOOK_NODE_TYPE,
isMinimized: this.nodeType && this.nodeType.name !== WEBHOOK_NODE_TYPE,
showUrlFor: 'test',
};
},

View file

@ -203,6 +203,7 @@ import {
IBinaryKeyData,
IDataObject,
INodeExecutionData,
INodeTypeDescription,
IRunData,
IRunExecutionData,
ITaskData,
@ -529,8 +530,8 @@ export default mixins(
return outputIndex + 1;
}
const nodeType = this.$store.getters.nodeType(this.node.type);
if (!nodeType.hasOwnProperty('outputNames') || nodeType.outputNames.length <= outputIndex) {
const nodeType = this.$store.getters.nodeType(this.node.type) as INodeTypeDescription | null;
if (!nodeType || !nodeType.outputNames || nodeType.outputNames.length <= outputIndex) {
return outputIndex + 1;
}

View file

@ -13,3 +13,8 @@ export function convertToHumanReadableDate (epochTime: number) {
export function getAppNameFromCredType(name: string) {
return name.split(' ').filter((word) => !KEYWORDS_TO_FILTER.includes(word)).join(' ');
}
export function getStyleTokenValue(name: string): string {
const style = getComputedStyle(document.body);
return style.getPropertyValue(name);
}

View file

@ -1,9 +1,10 @@
import { INodeUi } from '@/Interface';
import { INodeUi, XYPosition } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { getMousePosition, getRelativePosition } from '@/views/canvasHelpers';
export const mouseSelect = mixins(
deviceSupportHelpers,
@ -42,23 +43,14 @@ export const mouseSelect = mixins(
}
return e.ctrlKey;
},
/**
* Gets mouse position within the node view. Both node view offset and scale (zoom) are considered when
* calculating position.
*
* @param event - mouse event within node view
*/
getMousePositionWithinNodeView (event: MouseEvent) {
getMousePositionWithinNodeView (event: MouseEvent | TouchEvent): XYPosition {
const [x, y] = getMousePosition(event);
// @ts-ignore
const nodeViewScale = this.nodeViewScale;
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
return {
x: (event.pageX - offsetPosition[0]) / nodeViewScale,
y: (event.pageY - offsetPosition[1]) / nodeViewScale,
};
return getRelativePosition(x, y, this.nodeViewScale, this.$store.getters.getNodeViewOffsetPosition);
},
showSelectBox (event: MouseEvent) {
this.selectBox = Object.assign(this.selectBox, this.getMousePositionWithinNodeView(event));
const [x, y] = this.getMousePositionWithinNodeView(event);
this.selectBox = Object.assign(this.selectBox, {x, y});
// @ts-ignore
this.selectBox.style.left = this.selectBox.x + 'px';
@ -90,7 +82,7 @@ export const mouseSelect = mixins(
this.selectActive = false;
},
getSelectionBox (event: MouseEvent) {
const {x, y} = this.getMousePositionWithinNodeView(event);
const [x, y] = this.getMousePositionWithinNodeView(event);
return {
// @ts-ignore
x: Math.min(x, this.selectBox.x),
@ -162,6 +154,10 @@ export const mouseSelect = mixins(
this.nodeSelected(node);
});
if (selectedNodes.length === 1) {
this.$store.commit('setLastSelectedNode', selectedNodes[0].name);
}
this.hideSelectBox();
},
mouseMoveSelect (e: MouseEvent) {
@ -195,6 +191,10 @@ export const mouseSelect = mixins(
this.$store.commit('setLastSelectedNode', null);
this.$store.commit('setLastSelectedNodeOutputIndex', null);
this.$store.commit('setActiveNode', null);
// @ts-ignore
this.lastSelectedConnection = null;
// @ts-ignore
this.newNodeInsertPosition = null;
},
},
});

View file

@ -3,6 +3,7 @@ import mixins from 'vue-typed-mixins';
import normalizeWheel from 'normalize-wheel';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { getMousePosition } from '@/views/canvasHelpers';
export const moveNodeWorkflow = mixins(
deviceSupportHelpers,
@ -15,29 +16,18 @@ export const moveNodeWorkflow = mixins(
},
methods: {
getMousePosition(e: MouseEvent | TouchEvent) {
// @ts-ignore
const x = e.pageX !== undefined ? e.pageX : (e.touches && e.touches[0] && e.touches[0].pageX ? e.touches[0].pageX : 0);
// @ts-ignore
const y = e.pageY !== undefined ? e.pageY : (e.touches && e.touches[0] && e.touches[0].pageY ? e.touches[0].pageY : 0);
return {
x,
y,
};
},
moveWorkflow (e: MouseEvent) {
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
const position = this.getMousePosition(e);
const [x, y] = getMousePosition(e);
const nodeViewOffsetPositionX = offsetPosition[0] + (position.x - this.moveLastPosition[0]);
const nodeViewOffsetPositionY = offsetPosition[1] + (position.y - this.moveLastPosition[1]);
const nodeViewOffsetPositionX = offsetPosition[0] + (x - this.moveLastPosition[0]);
const nodeViewOffsetPositionY = offsetPosition[1] + (y - this.moveLastPosition[1]);
this.$store.commit('setNodeViewOffsetPosition', {newOffset: [nodeViewOffsetPositionX, nodeViewOffsetPositionY]});
// Update the last position
this.moveLastPosition[0] = position.x;
this.moveLastPosition[1] = position.y;
this.moveLastPosition[0] = x;
this.moveLastPosition[1] = y;
},
mouseDownMoveWorkflow (e: MouseEvent) {
if (this.isCtrlKeyPressed(e) === false) {
@ -53,10 +43,10 @@ export const moveNodeWorkflow = mixins(
this.$store.commit('setNodeViewMoveInProgress', true);
const position = this.getMousePosition(e);
const [x, y] = getMousePosition(e);
this.moveLastPosition[0] = position.x;
this.moveLastPosition[1] = position.y;
this.moveLastPosition[0] = x;
this.moveLastPosition[1] = y;
// @ts-ignore
this.$el.addEventListener('mousemove', this.mouseMoveNodeWorkflow);

View file

@ -1,10 +1,16 @@
import { IConnectionsUi, IEndpointOptions, INodeUi, XYPositon } from '@/Interface';
import { IEndpointOptions, INodeUi, XYPosition } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { NODE_NAME_PREFIX, NO_OP_NODE_TYPE } from '@/constants';
import * as CanvasHelpers from '@/views/canvasHelpers';
import { Endpoint } from 'jsplumb';
import {
INodeTypeDescription,
} from 'n8n-workflow';
export const nodeBase = mixins(
deviceSupportHelpers,
@ -18,145 +24,31 @@ export const nodeBase = mixins(
},
computed: {
data (): INodeUi {
return this.$store.getters.nodeByName(this.name);
return this.$store.getters.getNodeByName(this.name);
},
hasIssues (): boolean {
if (this.data.issues !== undefined && Object.keys(this.data.issues).length) {
return true;
}
return false;
},
nodeName (): string {
nodeId (): string {
return NODE_NAME_PREFIX + this.nodeIndex;
},
nodeIndex (): string {
return this.$store.getters.getNodeIndex(this.data.name).toString();
},
nodePosition (): object {
const returnStyles: {
[key: string]: string;
} = {
left: this.data.position[0] + 'px',
top: this.data.position[1] + 'px',
};
return returnStyles;
},
nodeStyle (): object {
const returnStyles: {
[key: string]: string;
} = {
'border-color': this.data.color as string,
};
return returnStyles;
},
},
props: [
'name',
'nodeId',
'instance',
'isReadOnly',
'isActive',
'hideActions',
],
methods: {
__addNode (node: INodeUi) {
// TODO: Later move the node-connection definitions to a special file
let nodeTypeData = this.$store.getters.nodeType(node.type);
const nodeConnectors: IConnectionsUi = {
main: {
input: {
uuid: '-input',
maxConnections: -1,
endpoint: 'Rectangle',
endpointStyle: {
width: nodeTypeData && nodeTypeData.outputs.length > 2 ? 9 : 10,
height: nodeTypeData && nodeTypeData.outputs.length > 2 ? 18 : 24,
fill: '#777',
stroke: '#777',
lineWidth: 0,
},
dragAllowedWhenFull: true,
},
output: {
uuid: '-output',
maxConnections: -1,
endpoint: 'Dot',
endpointStyle: {
radius: nodeTypeData && nodeTypeData.outputs.length > 2 ? 7 : 11,
fill: '#555',
outlineStroke: 'none',
},
dragAllowedWhenFull: true,
},
},
};
if (!nodeTypeData) {
// If node type is not know use by default the base.noOp data to display it
nodeTypeData = this.$store.getters.nodeType(NO_OP_NODE_TYPE);
}
const anchorPositions: {
[key: string]: {
[key: number]: string[] | number[][];
}
} = {
input: {
1: [
'Left',
],
2: [
[0, 0.3, -1, 0],
[0, 0.7, -1, 0],
],
3: [
[0, 0.25, -1, 0],
[0, 0.5, -1, 0],
[0, 0.75, -1, 0],
],
4: [
[0, 0.2, -1, 0],
[0, 0.4, -1, 0],
[0, 0.6, -1, 0],
[0, 0.8, -1, 0],
],
},
output: {
1: [
'Right',
],
2: [
[1, 0.3, 1, 0],
[1, 0.7, 1, 0],
],
3: [
[1, 0.25, 1, 0],
[1, 0.5, 1, 0],
[1, 0.75, 1, 0],
],
4: [
[1, 0.2, 1, 0],
[1, 0.4, 1, 0],
[1, 0.6, 1, 0],
[1, 0.8, 1, 0],
],
},
};
__addInputEndpoints (node: INodeUi, nodeTypeData: INodeTypeDescription) {
// Add Inputs
let index, inputData, anchorPosition;
let newEndpointData: IEndpointOptions;
let indexData: {
let index;
const indexData: {
[key: string]: number;
} = {};
nodeTypeData.inputs.forEach((inputName: string) => {
// @ts-ignore
inputData = nodeConnectors[inputName].input;
nodeTypeData.inputs.forEach((inputName: string, i: number) => {
// Increment the index for inputs with current name
if (indexData.hasOwnProperty(inputName)) {
indexData[inputName]++;
@ -166,14 +58,15 @@ export const nodeBase = mixins(
index = indexData[inputName];
// Get the position of the anchor depending on how many it has
anchorPosition = anchorPositions.input[nodeTypeData.inputs.length][index];
const anchorPosition = CanvasHelpers.ANCHOR_POSITIONS.input[nodeTypeData.inputs.length][index];
newEndpointData = {
uuid: `${this.nodeIndex}` + inputData.uuid + index,
const newEndpointData: IEndpointOptions = {
uuid: CanvasHelpers.getInputEndpointUUID(this.nodeIndex, index),
anchor: anchorPosition,
maxConnections: inputData.maxConnections,
endpoint: inputData.endpoint,
endpointStyle: inputData.endpointStyle,
maxConnections: -1,
endpoint: 'Rectangle',
endpointStyle: CanvasHelpers.getInputEndpointStyle(nodeTypeData, '--color-foreground-xdark'),
endpointHoverStyle: CanvasHelpers.getInputEndpointStyle(nodeTypeData, '--color-primary'),
isSource: false,
isTarget: !this.isReadOnly,
parameters: {
@ -181,7 +74,8 @@ export const nodeBase = mixins(
type: inputName,
index,
},
dragAllowedWhenFull: inputData.dragAllowedWhenFull,
enabled: !this.isReadOnly,
dragAllowedWhenFull: true,
dropOptions: {
tolerance: 'touch',
hoverClass: 'dropHover',
@ -191,19 +85,15 @@ export const nodeBase = mixins(
if (nodeTypeData.inputNames) {
// Apply input names if they got set
newEndpointData.overlays = [
['Label',
{
id: 'input-name-label',
location: [-2, 0.5],
label: nodeTypeData.inputNames[index],
cssClass: 'node-input-endpoint-label',
visible: true,
},
],
CanvasHelpers.getInputNameOverlay(nodeTypeData.inputNames[index]),
];
}
this.instance.addEndpoint(this.nodeName, newEndpointData);
const endpoint: Endpoint = this.instance.addEndpoint(this.nodeId, newEndpointData);
endpoint.__meta = {
nodeName: node.name,
index: i,
};
// TODO: Activate again if it makes sense. Currently makes problems when removing
// connection on which the input has a name. It does not get hidden because
@ -213,15 +103,17 @@ export const nodeBase = mixins(
// if (index === 0 && inputName === 'main') {
// // Make the first main-input the default one to connect to when connection gets dropped on node
// this.instance.makeTarget(this.nodeName, newEndpointData);
// this.instance.makeTarget(this.nodeId, newEndpointData);
// }
});
},
__addOutputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
let index;
const indexData: {
[key: string]: number;
} = {};
// Add Outputs
indexData = {};
nodeTypeData.outputs.forEach((inputName: string) => {
inputData = nodeConnectors[inputName].output;
nodeTypeData.outputs.forEach((inputName: string, i: number) => {
// Increment the index for outputs with current name
if (indexData.hasOwnProperty(inputName)) {
indexData[inputName]++;
@ -231,49 +123,48 @@ export const nodeBase = mixins(
index = indexData[inputName];
// Get the position of the anchor depending on how many it has
anchorPosition = anchorPositions.output[nodeTypeData.outputs.length][index];
const anchorPosition = CanvasHelpers.ANCHOR_POSITIONS.output[nodeTypeData.outputs.length][index];
newEndpointData = {
uuid: `${this.nodeIndex}` + inputData.uuid + index,
const newEndpointData: IEndpointOptions = {
uuid: CanvasHelpers.getOutputEndpointUUID(this.nodeIndex, index),
anchor: anchorPosition,
maxConnections: inputData.maxConnections,
endpoint: inputData.endpoint,
endpointStyle: inputData.endpointStyle,
isSource: !this.isReadOnly,
maxConnections: -1,
endpoint: 'Dot',
endpointStyle: CanvasHelpers.getOutputEndpointStyle(nodeTypeData, '--color-foreground-xdark'),
endpointHoverStyle: CanvasHelpers.getOutputEndpointStyle(nodeTypeData, '--color-primary'),
isSource: true,
isTarget: false,
enabled: !this.isReadOnly,
parameters: {
nodeIndex: this.nodeIndex,
type: inputName,
index,
},
dragAllowedWhenFull: inputData.dragAllowedWhenFull,
dragAllowedWhenFull: false,
dragProxy: ['Rectangle', { width: 1, height: 1, strokeWidth: 0 }],
};
if (nodeTypeData.outputNames) {
// Apply output names if they got set
newEndpointData.overlays = [
['Label',
{
id: 'output-name-label',
location: [1.75, 0.5],
label: nodeTypeData.outputNames[index],
cssClass: 'node-output-endpoint-label',
visible: true,
},
],
CanvasHelpers.getOutputNameOverlay(nodeTypeData.outputNames[index]),
];
}
this.instance.addEndpoint(this.nodeName, newEndpointData);
const endpoint: Endpoint = this.instance.addEndpoint(this.nodeId, newEndpointData);
endpoint.__meta = {
nodeName: node.name,
index: i,
};
});
},
__makeInstanceDraggable(node: INodeUi) {
// TODO: This caused problems with displaying old information
// https://github.com/jsplumb/katavorio/wiki
// https://jsplumb.github.io/jsplumb/home.html
// Make nodes draggable
this.instance.draggable(this.nodeName, {
grid: [10, 10],
this.instance.draggable(this.nodeId, {
grid: [CanvasHelpers.GRID_SIZE, CanvasHelpers.GRID_SIZE],
start: (params: { e: MouseEvent }) => {
if (this.isReadOnly === true) {
// Do not allow to move nodes in readOnly mode
@ -305,7 +196,7 @@ export const nodeBase = mixins(
// even though "start" and "drag" gets called for all. So lets do for now
// some dirty DOM query to get the new positions till I have more time to
// create a proper solution
let newNodePositon: XYPositon;
let newNodePositon: XYPosition;
moveNodes.forEach((node: INodeUi) => {
const nodeElement = `node-${this.getNodeIndex(node.name)}`;
const element = document.getElementById(nodeElement);
@ -328,11 +219,23 @@ export const nodeBase = mixins(
this.$store.commit('updateNodeProperties', updateInformation);
});
this.$emit('moved', node);
}
},
filter: '.node-description, .node-description .node-name, .node-description .node-subtitle',
});
},
__addNode (node: INodeUi) {
let nodeTypeData = this.$store.getters.nodeType(node.type) as INodeTypeDescription | null;
if (!nodeTypeData) {
// If node type is not know use by default the base.noOp data to display it
nodeTypeData = this.$store.getters.nodeType(NO_OP_NODE_TYPE) as INodeTypeDescription;
}
this.__addInputEndpoints(node, nodeTypeData);
this.__addOutputEndpoints(node, nodeTypeData);
this.__makeInstanceDraggable(node);
},
touchEnd(e: MouseEvent) {
if (this.isTouchDevice) {

View file

@ -344,6 +344,7 @@ export const nodeHelpers = mixins(
};
this.$store.commit('updateNodeProperties', updateInformation);
this.$store.commit('clearNodeExecutionData', node.name);
this.updateNodeParameterIssues(node);
this.updateNodeCredentialIssues(node);
}

View file

@ -110,7 +110,8 @@ export const showMessage = mixins(externalHooks).extend({
return errorMessage;
},
$showError(error: Error, title: string, message?: string) {
$showError(e: Error | unknown, title: string, message?: string) {
const error = e as Error;
const messageLine = message ? `${message}<br/>` : '';
this.$showMessage({
title,

View file

@ -36,7 +36,7 @@ import {
IWorkflowData,
IWorkflowDb,
IWorkflowDataUpdate,
XYPositon,
XYPosition,
ITag,
IUpdateInformation,
} from '../../Interface';
@ -225,7 +225,7 @@ export const workflowHelpers = mixins(
return [];
},
getByName: (nodeType: string): INodeType | INodeVersionedType | undefined => {
const nodeTypeDescription = this.$store.getters.nodeType(nodeType);
const nodeTypeDescription = this.$store.getters.nodeType(nodeType) as INodeTypeDescription | null;
if (nodeTypeDescription === null) {
return undefined;
@ -236,7 +236,7 @@ export const workflowHelpers = mixins(
};
},
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
const nodeTypeDescription = this.$store.getters.nodeType(nodeType, version);
const nodeTypeDescription = this.$store.getters.nodeType(nodeType, version) as INodeTypeDescription | null;
if (nodeTypeDescription === null) {
return undefined;
@ -329,7 +329,7 @@ export const workflowHelpers = mixins(
// Get the data of the node type that we can get the default values
// TODO: Later also has to care about the node-type-version as defaults could be different
const nodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription;
const nodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription | null;
if (nodeType !== null) {
// Node-Type is known so we can save the parameters correctly
@ -362,11 +362,6 @@ export const workflowHelpers = mixins(
nodeData.credentials = saveCredenetials;
}
}
// Save the node color only if it is different to the default color
if (node.color && node.color !== nodeType.defaults.color) {
nodeData.color = node.color;
}
} else {
// Node-Type is not known so save the data as it is
nodeData.credentials = node.credentials;
@ -568,7 +563,7 @@ export const workflowHelpers = mixins(
// Updates the position of all the nodes that the top-left node
// is at the given position
updateNodePositions (workflowData: IWorkflowData | IWorkflowDataUpdate, position: XYPositon): void {
updateNodePositions (workflowData: IWorkflowData | IWorkflowDataUpdate, position: XYPosition): void {
if (workflowData.nodes === undefined) {
return;
}

View file

@ -191,6 +191,7 @@ export const workflowRun = mixins(
},
};
this.$store.commit('setWorkflowExecutionData', executionData);
this.updateNodesExecutionIssues();
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);

View file

@ -116,3 +116,4 @@ export const COMPANY_SIZE_PERSONAL_USE = 'personalUser';
export const CODING_SKILL_KEY = 'codingSkill';
export const OTHER_WORK_AREA_KEY = 'otherWorkArea';

View file

@ -98,7 +98,7 @@ const module: Module<ICredentialsState, IRootState> = {
},
getCredentialsByType: (state: ICredentialsState, getters: any) => { // tslint:disable-line:no-any
return (credentialType: string): ICredentialsResponse[] => {
return getters.allCredentialsByType[credentialType];
return getters.allCredentialsByType[credentialType] || [];
};
},
getNodesWithAccess (state: ICredentialsState, getters: any, rootState: IRootState, rootGetters: any) { // tslint:disable-line:no-any

View file

@ -28,8 +28,6 @@ $--badge-warning-color: #6b5900;
// Warning tooltip
$--warning-tooltip-color: #ff8080;
$--custom-node-view-background : #faf9fe;
// Table
$--custom-table-background-main: $--custom-header-background;
$--custom-table-background-stripe-color: #f6f6f6;

View file

@ -2,8 +2,9 @@
@import "~n8n-design-system/theme/dist/index.css";
body {
background-color: $--custom-node-view-background;
background-color: var(--color-canvas-background);
}
.clickable {

View file

@ -0,0 +1,779 @@
/**
* Custom connector type
* Based on jsplumb Flowchart and Bezier types
*
* Source GitHub repository:
* https://github.com/jsplumb/jsplumb
*
* Source files:
* https://github.com/jsplumb/jsplumb/blob/fb5fce52794fa52306825bdaa62bf3855cdfd7e0/src/connectors-flowchart.js
* https://github.com/jsplumb/jsplumb/blob/fb5fce52794fa52306825bdaa62bf3855cdfd7e0/src/connectors-bezier.js
*
*
* All 1.x.x and 2.x.x versions of jsPlumb Community edition, and so also the
* content of this file, are dual-licensed under both MIT and GPLv2.
*
* MIT LICENSE
*
* Copyright (c) 2010 - 2014 jsPlumb, http://jsplumbtoolkit.com/
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* ===============================================================================
* GNU GENERAL PUBLIC LICENSE
* Version 2, June 1991
*
* Copyright (C) 1989, 1991 Free Software Foundation, Inc.
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
* Everyone is permitted to copy and distribute verbatim copies
* of this license document, but changing it is not allowed.
*
* Preamble
*
* The licenses for most software are designed to take away your
* freedom to share and change it. By contrast, the GNU General Public
* License is intended to guarantee your freedom to share and change free
* software--to make sure the software is free for all its users. This
* General Public License applies to most of the Free Software
* Foundation's software and to any other program whose authors commit to
* using it. (Some other Free Software Foundation software is covered by
* the GNU Lesser General Public License instead.) You can apply it to
* your programs, too.
*
* When we speak of free software, we are referring to freedom, not
* price. Our General Public Licenses are designed to make sure that you
* have the freedom to distribute copies of free software (and charge for
* this service if you wish), that you receive source code or can get it
* if you want it, that you can change the software or use pieces of it
* in new free programs; and that you know you can do these things.
*
* To protect your rights, we need to make restrictions that forbid
* anyone to deny you these rights or to ask you to surrender the rights.
* These restrictions translate to certain responsibilities for you if you
* distribute copies of the software, or if you modify it.
*
* For example, if you distribute copies of such a program, whether
* gratis or for a fee, you must give the recipients all the rights that
* you have. You must make sure that they, too, receive or can get the
* source code. And you must show them these terms so they know their
* rights.
*
* We protect your rights with two steps: (1) copyright the software, and
* (2) offer you this license which gives you legal permission to copy,
* distribute and/or modify the software.
*
* Also, for each author's protection and ours, we want to make certain
* that everyone understands that there is no warranty for this free
* software. If the software is modified by someone else and passed on, we
* want its recipients to know that what they have is not the original, so
* that any problems introduced by others will not reflect on the original
* authors' reputations.
*
* Finally, any free program is threatened constantly by software
* patents. We wish to avoid the danger that redistributors of a free
* program will individually obtain patent licenses, in effect making the
* program proprietary. To prevent this, we have made it clear that any
* patent must be licensed for everyone's free use or not licensed at all.
*
* The precise terms and conditions for copying, distribution and
* modification follow.
*
* GNU GENERAL PUBLIC LICENSE
* TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
*
* 0. This License applies to any program or other work which contains
* a notice placed by the copyright holder saying it may be distributed
* under the terms of this General Public License. The "Program", below,
* refers to any such program or work, and a "work based on the Program"
* means either the Program or any derivative work under copyright law:
* that is to say, a work containing the Program or a portion of it,
* either verbatim or with modifications and/or translated into another
* language. (Hereinafter, translation is included without limitation in
* the term "modification".) Each licensee is addressed as "you".
*
* Activities other than copying, distribution and modification are not
* covered by this License; they are outside its scope. The act of
* running the Program is not restricted, and the output from the Program
* is covered only if its contents constitute a work based on the
* Program (independent of having been made by running the Program).
* Whether that is true depends on what the Program does.
*
* 1. You may copy and distribute verbatim copies of the Program's
* source code as you receive it, in any medium, provided that you
* conspicuously and appropriately publish on each copy an appropriate
* copyright notice and disclaimer of warranty; keep intact all the
* notices that refer to this License and to the absence of any warranty;
* and give any other recipients of the Program a copy of this License
* along with the Program.
*
* You may charge a fee for the physical act of transferring a copy, and
* you may at your option offer warranty protection in exchange for a fee.
*
* 2. You may modify your copy or copies of the Program or any portion
* of it, thus forming a work based on the Program, and copy and
* distribute such modifications or work under the terms of Section 1
* above, provided that you also meet all of these conditions:
*
* a) You must cause the modified files to carry prominent notices
* stating that you changed the files and the date of any change.
*
* b) You must cause any work that you distribute or publish, that in
* whole or in part contains or is derived from the Program or any
* part thereof, to be licensed as a whole at no charge to all third
* parties under the terms of this License.
*
* c) If the modified program normally reads commands interactively
* when run, you must cause it, when started running for such
* interactive use in the most ordinary way, to print or display an
* announcement including an appropriate copyright notice and a
* notice that there is no warranty (or else, saying that you provide
* a warranty) and that users may redistribute the program under
* these conditions, and telling the user how to view a copy of this
* License. (Exception: if the Program itself is interactive but
* does not normally print such an announcement, your work based on
* the Program is not required to print an announcement.)
*
* These requirements apply to the modified work as a whole. If
* identifiable sections of that work are not derived from the Program,
* and can be reasonably considered independent and separate works in
* themselves, then this License, and its terms, do not apply to those
* sections when you distribute them as separate works. But when you
* distribute the same sections as part of a whole which is a work based
* on the Program, the distribution of the whole must be on the terms of
* this License, whose permissions for other licensees extend to the
* entire whole, and thus to each and every part regardless of who wrote it.
*
* Thus, it is not the intent of this section to claim rights or contest
* your rights to work written entirely by you; rather, the intent is to
* exercise the right to control the distribution of derivative or
* collective works based on the Program.
*
* In addition, mere aggregation of another work not based on the Program
* with the Program (or with a work based on the Program) on a volume of
* a storage or distribution medium does not bring the other work under
* the scope of this License.
*
* 3. You may copy and distribute the Program (or a work based on it,
* under Section 2) in object code or executable form under the terms of
* Sections 1 and 2 above provided that you also do one of the following:
*
* a) Accompany it with the complete corresponding machine-readable
* source code, which must be distributed under the terms of Sections
* 1 and 2 above on a medium customarily used for software interchange; or,
*
* b) Accompany it with a written offer, valid for at least three
* years, to give any third party, for a charge no more than your
* cost of physically performing source distribution, a complete
* machine-readable copy of the corresponding source code, to be
* distributed under the terms of Sections 1 and 2 above on a medium
* customarily used for software interchange; or,
*
* c) Accompany it with the information you received as to the offer
* to distribute corresponding source code. (This alternative is
* allowed only for noncommercial distribution and only if you
* received the program in object code or executable form with such
* an offer, in accord with Subsection b above.)
*
* The source code for a work means the preferred form of the work for
* making modifications to it. For an executable work, complete source
* code means all the source code for all modules it contains, plus any
* associated interface definition files, plus the scripts used to
* control compilation and installation of the executable. However, as a
* special exception, the source code distributed need not include
* anything that is normally distributed (in either source or binary
* form) with the major components (compiler, kernel, and so on) of the
* operating system on which the executable runs, unless that component
* itself accompanies the executable.
*
* If distribution of executable or object code is made by offering
* access to copy from a designated place, then offering equivalent
* access to copy the source code from the same place counts as
* distribution of the source code, even though third parties are not
* compelled to copy the source along with the object code.
*
* 4. You may not copy, modify, sublicense, or distribute the Program
* except as expressly provided under this License. Any attempt
* otherwise to copy, modify, sublicense or distribute the Program is
* void, and will automatically terminate your rights under this License.
* However, parties who have received copies, or rights, from you under
* this License will not have their licenses terminated so long as such
* parties remain in full compliance.
*
* 5. You are not required to accept this License, since you have not
* signed it. However, nothing else grants you permission to modify or
* distribute the Program or its derivative works. These actions are
* prohibited by law if you do not accept this License. Therefore, by
* modifying or distributing the Program (or any work based on the
* Program), you indicate your acceptance of this License to do so, and
* all its terms and conditions for copying, distributing or modifying
* the Program or works based on it.
*
* 6. Each time you redistribute the Program (or any work based on the
* Program), the recipient automatically receives a license from the
* original licensor to copy, distribute or modify the Program subject to
* these terms and conditions. You may not impose any further
* restrictions on the recipients' exercise of the rights granted herein.
* You are not responsible for enforcing compliance by third parties to
* this License.
*
* 7. If, as a consequence of a court judgment or allegation of patent
* infringement or for any other reason (not limited to patent issues),
* conditions are imposed on you (whether by court order, agreement or
* otherwise) that contradict the conditions of this License, they do not
* excuse you from the conditions of this License. If you cannot
* distribute so as to satisfy simultaneously your obligations under this
* License and any other pertinent obligations, then as a consequence you
* may not distribute the Program at all. For example, if a patent
* license would not permit royalty-free redistribution of the Program by
* all those who receive copies directly or indirectly through you, then
* the only way you could satisfy both it and this License would be to
* refrain entirely from distribution of the Program.
*
* If any portion of this section is held invalid or unenforceable under
* any particular circumstance, the balance of the section is intended to
* apply and the section as a whole is intended to apply in other
* circumstances.
*
* It is not the purpose of this section to induce you to infringe any
* patents or other property right claims or to contest validity of any
* such claims; this section has the sole purpose of protecting the
* integrity of the free software distribution system, which is
* implemented by public license practices. Many people have made
* generous contributions to the wide range of software distributed
* through that system in reliance on consistent application of that
* system; it is up to the author/donor to decide if he or she is willing
* to distribute software through any other system and a licensee cannot
* impose that choice.
*
* This section is intended to make thoroughly clear what is believed to
* be a consequence of the rest of this License.
*
* 8. If the distribution and/or use of the Program is restricted in
* certain countries either by patents or by copyrighted interfaces, the
* original copyright holder who places the Program under this License
* may add an explicit geographical distribution limitation excluding
* those countries, so that distribution is permitted only in or among
* countries not thus excluded. In such case, this License incorporates
* the limitation as if written in the body of this License.
*
* 9. The Free Software Foundation may publish revised and/or new versions
* of the General Public License from time to time. Such new versions will
* be similar in spirit to the present version, but may differ in detail to
* address new problems or concerns.
*
* Each version is given a distinguishing version number. If the Program
* specifies a version number of this License which applies to it and "any
* later version", you have the option of following the terms and conditions
* either of that version or of any later version published by the Free
* Software Foundation. If the Program does not specify a version number of
* this License, you may choose any version ever published by the Free Software
* Foundation.
*
* 10. If you wish to incorporate parts of the Program into other free
* programs whose distribution conditions are different, write to the author
* to ask for permission. For software which is copyrighted by the Free
* Software Foundation, write to the Free Software Foundation; we sometimes
* make exceptions for this. Our decision will be guided by the two goals
* of preserving the free status of all derivatives of our free software and
* of promoting the sharing and reuse of software generally.
*
* NO WARRANTY
*
* 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
* FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
* OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
* PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
* OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
* TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
* PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
* REPAIR OR CORRECTION.
*
* 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
* WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
* REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
* INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
* OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
* TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
* YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
* PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGES.
*
*/
(function () {
"use strict";
var root = this, _jp = root.jsPlumb, _ju = root.jsPlumbUtil, _jg = root.Biltong;
var STRAIGHT = "Straight";
var ARC = "Arc";
/**
* Custom connector type
*
* @param stub {number} length of stub segments in flowchart
* @param getEndpointOffset {Function} callback to offset stub length based on endpoint in flowchart
* @param midpoint {number} float percent of halfway point of segments in flowchart
* @param loopbackVerticalLength {number} height of vertical segment when looping in flowchart
* @param cornerRadius {number} radius of flowchart connectors
* @param loopbackMinimum {number} minimum threshold before looping behavior takes effect in flowchart
* @param targetGap {number} gap between connector and target endpoint in both flowchart and bezier
*/
const N8nCustom = function (params) {
params = params || {};
this.type = "N8nCustom";
params.stub = params.stub == null ? 30 : params.stub;
var _super = _jp.Connectors.AbstractConnector.apply(this, arguments),
minorAnchor = 0, // seems to be angle at which connector leaves endpoint
majorAnchor = 0, // translates to curviness of bezier curve
segments,
midpoint = params.midpoint == null ? 0.5 : params.midpoint,
alwaysRespectStubs = params.alwaysRespectStubs === true,
loopbackVerticalLength = params.loopbackVerticalLength || 0,
lastx = null, lasty = null,
cornerRadius = params.cornerRadius != null ? params.cornerRadius : 0,
loopbackMinimum = params.loopbackMinimum || 100,
curvinessCoeffient = 0.4,
zBezierOffset = 40,
targetGap = params.targetGap || 0,
stub = params.stub || 0;
/**
* Set target endpoint
* (to override default behavior tracking mouse when dragging mouse)
* @param {Endpoint} endpoint
*/
this.setTargetEndpoint = function (endpoint) {
this.overrideTargetEndpoint = endpoint;
};
/**
* reset target endpoint overriding default behavior
*/
this.resetTargetEndpoint = function () {
this.overrideTargetEndpoint = null;
};
this._compute = function (originalPaintInfo, connParams) {
const paintInfo = _getPaintInfo(connParams, { targetGap, stub, overrideTargetEndpoint: this.overrideTargetEndpoint, getEndpointOffset: params.getEndpointOffset });
Object.keys(paintInfo).forEach((key) => {
// override so that bounding box is calculated correctly wheen target override is set
originalPaintInfo[key] = paintInfo[key];
});
if (paintInfo.tx < 0) {
this._computeFlowchart(paintInfo);
}
else {
this._computeBezier(paintInfo);
}
};
this._computeBezier = function (paintInfo) {
var sp = paintInfo.sourcePos,
tp = paintInfo.targetPos,
_w = Math.abs(sp[0] - tp[0]) - paintInfo.targetGap,
_h = Math.abs(sp[1] - tp[1]);
var _CP, _CP2,
_sx = sp[0] < tp[0] ? _w : 0,
_sy = sp[1] < tp[1] ? _h : 0,
_tx = sp[0] < tp[0] ? 0 : _w,
_ty = sp[1] < tp[1] ? 0 : _h;
if (paintInfo.ySpan <= 20 || (paintInfo.ySpan <= 100 && paintInfo.xSpan <= 100)) {
majorAnchor = 0.1;
}
else {
majorAnchor = paintInfo.xSpan * curvinessCoeffient + zBezierOffset;
}
_CP = _findControlPoint([_sx, _sy], sp, tp, paintInfo.sourceEndpoint, paintInfo.targetEndpoint, paintInfo.so, paintInfo.to, majorAnchor, minorAnchor);
_CP2 = _findControlPoint([_tx, _ty], tp, sp, paintInfo.targetEndpoint, paintInfo.sourceEndpoint, paintInfo.to, paintInfo.so, majorAnchor, minorAnchor);
_super.addSegment(this, "Bezier", {
x1: _sx, y1: _sy, x2: _tx, y2: _ty,
cp1x: _CP[0], cp1y: _CP[1], cp2x: _CP2[0], cp2y: _CP2[1],
});
};
/**
* helper method to add a segment.
*/
const addFlowchartSegment = function (segments, x, y, paintInfo) {
if (lastx === x && lasty === y) {
return;
}
var lx = lastx == null ? paintInfo.sx : lastx,
ly = lasty == null ? paintInfo.sy : lasty,
o = lx === x ? "v" : "h";
lastx = x;
lasty = y;
segments.push([ lx, ly, x, y, o ]);
};
this._computeFlowchart = function (paintInfo) {
segments = [];
lastx = null;
lasty = null;
// calculate Stubs.
var stubs = calcualteStubSegment(paintInfo, {alwaysRespectStubs});
// add the start stub segment. use stubs for loopback as it will look better, with the loop spaced
// away from the element.
addFlowchartSegment(segments, stubs[0], stubs[1], paintInfo);
// compute the rest of the line
var p = calculateLineSegment(paintInfo, stubs, {midpoint, loopbackMinimum, loopbackVerticalLength});
if (p) {
for (var i = 0; i < p.length; i++) {
addFlowchartSegment(segments, p[i][0], p[i][1], paintInfo);
}
}
// line to end stub
addFlowchartSegment(segments, stubs[2], stubs[3], paintInfo);
// end stub to end (common)
addFlowchartSegment(segments, paintInfo.tx, paintInfo.ty, paintInfo);
// write out the segments.
writeFlowchartSegments(_super, this, segments, paintInfo, cornerRadius);
};
};
_jp.Connectors.N8nCustom = N8nCustom;
_ju.extend(_jp.Connectors.N8nCustom, _jp.Connectors.AbstractConnector);
function _findControlPoint(point, sourceAnchorPosition, targetAnchorPosition, sourceEndpoint, targetEndpoint, soo, too, majorAnchor, minorAnchor) {
// determine if the two anchors are perpendicular to each other in their orientation. we swap the control
// points around if so (code could be tightened up)
var perpendicular = soo[0] !== too[0] || soo[1] === too[1],
p = [];
if (!perpendicular) {
if (soo[0] === 0) {
p.push(sourceAnchorPosition[0] < targetAnchorPosition[0] ? point[0] + minorAnchor : point[0] - minorAnchor);
}
else {
p.push(point[0] - (majorAnchor * soo[0]));
}
if (soo[1] === 0) {
p.push(sourceAnchorPosition[1] < targetAnchorPosition[1] ? point[1] + minorAnchor : point[1] - minorAnchor);
}
else {
p.push(point[1] + (majorAnchor * too[1]));
}
}
else {
if (too[0] === 0) {
p.push(targetAnchorPosition[0] < sourceAnchorPosition[0] ? point[0] + minorAnchor : point[0] - minorAnchor);
}
else {
p.push(point[0] + (majorAnchor * too[0]));
}
if (too[1] === 0) {
p.push(targetAnchorPosition[1] < sourceAnchorPosition[1] ? point[1] + minorAnchor : point[1] - minorAnchor);
}
else {
p.push(point[1] + (majorAnchor * soo[1]));
}
}
return p;
};
function sgn(n) {
return n < 0 ? -1 : n === 0 ? 0 : 1;
};
function getFlowchartSegmentDirections(segment) {
return [
sgn( segment[2] - segment[0] ),
sgn( segment[3] - segment[1] ),
];
};
function getSegmentLength(s) {
return Math.sqrt(Math.pow(s[0] - s[2], 2) + Math.pow(s[1] - s[3], 2));
};
function _cloneArray(a) {
var _a = [];
_a.push.apply(_a, a);
return _a;
};
function writeFlowchartSegments(_super, conn, segments, paintInfo, cornerRadius) {
var current = null, next, currentDirection, nextDirection;
for (var i = 0; i < segments.length - 1; i++) {
current = current || _cloneArray(segments[i]);
next = _cloneArray(segments[i + 1]);
currentDirection = getFlowchartSegmentDirections(current);
nextDirection = getFlowchartSegmentDirections(next);
if (cornerRadius > 0 && current[4] !== next[4]) {
var minSegLength = Math.min(getSegmentLength(current), getSegmentLength(next));
var radiusToUse = Math.min(cornerRadius, minSegLength / 2);
current[2] -= currentDirection[0] * radiusToUse;
current[3] -= currentDirection[1] * radiusToUse;
next[0] += nextDirection[0] * radiusToUse;
next[1] += nextDirection[1] * radiusToUse;
var ac = (currentDirection[1] === nextDirection[0] && nextDirection[0] === 1) ||
((currentDirection[1] === nextDirection[0] && nextDirection[0] === 0) && currentDirection[0] !== nextDirection[1]) ||
(currentDirection[1] === nextDirection[0] && nextDirection[0] === -1),
sgny = next[1] > current[3] ? 1 : -1,
sgnx = next[0] > current[2] ? 1 : -1,
sgnEqual = sgny === sgnx,
cx = (sgnEqual && ac || (!sgnEqual && !ac)) ? next[0] : current[2],
cy = (sgnEqual && ac || (!sgnEqual && !ac)) ? current[3] : next[1];
_super.addSegment(conn, STRAIGHT, {
x1: current[0], y1: current[1], x2: current[2], y2: current[3],
});
_super.addSegment(conn, ARC, {
r: radiusToUse,
x1: current[2],
y1: current[3],
x2: next[0],
y2: next[1],
cx: cx,
cy: cy,
ac: ac,
});
}
else {
// dx + dy are used to adjust for line width.
var dx = (current[2] === current[0]) ? 0 : (current[2] > current[0]) ? (paintInfo.lw / 2) : -(paintInfo.lw / 2),
dy = (current[3] === current[1]) ? 0 : (current[3] > current[1]) ? (paintInfo.lw / 2) : -(paintInfo.lw / 2);
_super.addSegment(conn, STRAIGHT, {
x1: current[0] - dx, y1: current[1] - dy, x2: current[2] + dx, y2: current[3] + dy,
});
}
current = next;
}
if (next != null) {
// last segment
_super.addSegment(conn, STRAIGHT, {
x1: next[0], y1: next[1], x2: next[2], y2: next[3],
});
}
};
const lineCalculators = {
opposite: function (paintInfo, {axis, startStub, endStub, idx, midx, midy}) {
var pi = paintInfo,
comparator = pi["is" + axis.toUpperCase() + "GreaterThanStubTimes2"];
if (!comparator || (pi.so[idx] === 1 && startStub > endStub) || (pi.so[idx] === -1 && startStub < endStub)) {
return {
"x": [
[startStub, midy],
[endStub, midy],
],
"y": [
[midx, startStub],
[midx, endStub],
],
}[axis];
}
else if ((pi.so[idx] === 1 && startStub < endStub) || (pi.so[idx] === -1 && startStub > endStub)) {
return {
"x": [
[midx, pi.sy],
[midx, pi.ty],
],
"y": [
[pi.sx, midy],
[pi.tx, midy],
],
}[axis];
}
},
};
const stubCalculators = {
opposite: function (paintInfo, {axis, alwaysRespectStubs}) {
var pi = paintInfo,
idx = axis === "x" ? 0 : 1,
areInProximity = {
"x": function () {
return ( (pi.so[idx] === 1 && (
( (pi.startStubX > pi.endStubX) && (pi.tx > pi.startStubX) ) ||
( (pi.sx > pi.endStubX) && (pi.tx > pi.sx))))) ||
( (pi.so[idx] === -1 && (
( (pi.startStubX < pi.endStubX) && (pi.tx < pi.startStubX) ) ||
( (pi.sx < pi.endStubX) && (pi.tx < pi.sx)))));
},
"y": function () {
return ( (pi.so[idx] === 1 && (
( (pi.startStubY > pi.endStubY) && (pi.ty > pi.startStubY) ) ||
( (pi.sy > pi.endStubY) && (pi.ty > pi.sy))))) ||
( (pi.so[idx] === -1 && (
( (pi.startStubY < pi.endStubY) && (pi.ty < pi.startStubY) ) ||
( (pi.sy < pi.endStubY) && (pi.ty < pi.sy)))));
},
};
if (!alwaysRespectStubs && areInProximity[axis]()) {
return {
"x": [(paintInfo.sx + paintInfo.tx) / 2, paintInfo.startStubY, (paintInfo.sx + paintInfo.tx) / 2, paintInfo.endStubY],
"y": [paintInfo.startStubX, (paintInfo.sy + paintInfo.ty) / 2, paintInfo.endStubX, (paintInfo.sy + paintInfo.ty) / 2],
}[axis];
}
else {
return [paintInfo.startStubX, paintInfo.startStubY, paintInfo.endStubX, paintInfo.endStubY];
}
},
};
function calcualteStubSegment(paintInfo, {alwaysRespectStubs}) {
return stubCalculators['opposite'](paintInfo, {axis: paintInfo.sourceAxis, alwaysRespectStubs});
}
function calculateLineSegment(paintInfo, stubs, { midpoint, loopbackVerticalLength, loopbackMinimum }) {
const axis = paintInfo.sourceAxis,
idx = paintInfo.sourceAxis === "x" ? 0 : 1,
oidx = paintInfo.sourceAxis === "x" ? 1 : 0,
startStub = stubs[idx],
otherStartStub = stubs[oidx],
endStub = stubs[idx + 2],
otherEndStub = stubs[oidx + 2];
const diffX = paintInfo.endStubX - paintInfo.startStubX;
const diffY = paintInfo.endStubY - paintInfo.startStubY;
const direction = diffY >= 0 ? 1 : -1; // vertical direction of loop, above or below source
var midx = paintInfo.startStubX + ((paintInfo.endStubX - paintInfo.startStubX) * midpoint),
midy;
if (diffX < (-1 * loopbackMinimum)) {
// loop backward behavior
midy = paintInfo.startStubY - (diffX < 0 ? direction * loopbackVerticalLength : 0);
} else {
// original flowchart behavior
midy = paintInfo.startStubY + ((paintInfo.endStubY - paintInfo.startStubY) * midpoint);
}
return lineCalculators['opposite'](paintInfo, {axis, startStub, otherStartStub, endStub, otherEndStub, idx, oidx, midx, midy});
}
function _getPaintInfo(params, { targetGap, stub, overrideTargetEndpoint, getEndpointOffset }) {
let { targetPos, targetEndpoint } = params;
if (
overrideTargetEndpoint
) {
targetPos = overrideTargetEndpoint.anchor.getCurrentLocation();
targetEndpoint = overrideTargetEndpoint;
}
const sourceGap = 0;
stub = stub || 0;
const sourceStub = _ju.isArray(stub) ? stub[0] : stub;
const targetStub = _ju.isArray(stub) ? stub[1] : stub;
var segment = _jg.quadrant(params.sourcePos, targetPos),
swapX = targetPos[0] < params.sourcePos[0],
swapY = targetPos[1] < params.sourcePos[1],
lw = params.strokeWidth || 1,
so = params.sourceEndpoint.anchor.getOrientation(params.sourceEndpoint), // source orientation
to = targetEndpoint.anchor.getOrientation(targetEndpoint), // target orientation
x = swapX ? targetPos[0] : params.sourcePos[0],
y = swapY ? targetPos[1] : params.sourcePos[1],
w = Math.abs(targetPos[0] - params.sourcePos[0]),
h = Math.abs(targetPos[1] - params.sourcePos[1]);
// if either anchor does not have an orientation set, we derive one from their relative
// positions. we fix the axis to be the one in which the two elements are further apart, and
// point each anchor at the other element. this is also used when dragging a new connection.
if (so[0] === 0 && so[1] === 0 || to[0] === 0 && to[1] === 0) {
var index = w > h ? 0 : 1, oIndex = [1, 0][index];
so = [];
to = [];
so[index] = params.sourcePos[index] > targetPos[index] ? -1 : 1;
to[index] = params.sourcePos[index] > targetPos[index] ? 1 : -1;
so[oIndex] = 0;
to[oIndex] = 0;
}
const sx = swapX ? w + (sourceGap * so[0]) : sourceGap * so[0],
sy = swapY ? h + (sourceGap * so[1]) : sourceGap * so[1],
tx = swapX ? targetGap * to[0] : w + (targetGap * to[0]),
ty = swapY ? targetGap * to[1] : h + (targetGap * to[1]),
oProduct = ((so[0] * to[0]) + (so[1] * to[1]));
const sourceStubWithOffset = sourceStub + (getEndpointOffset && params.sourceEndpoint ? getEndpointOffset(params.sourceEndpoint) : 0);
const targetStubWithOffset = targetStub + (getEndpointOffset && targetEndpoint ? getEndpointOffset(targetEndpoint) : 0);
// same as paintinfo generated by jsplumb AbstractConnector type
var result = {
sx: sx, sy: sy, tx: tx, ty: ty, lw: lw,
xSpan: Math.abs(tx - sx),
ySpan: Math.abs(ty - sy),
mx: (sx + tx) / 2,
my: (sy + ty) / 2,
so: so, to: to, x: x, y: y, w: w, h: h,
segment: segment,
startStubX: sx + (so[0] * sourceStubWithOffset),
startStubY: sy + (so[1] * sourceStubWithOffset),
endStubX: tx + (to[0] * targetStubWithOffset),
endStubY: ty + (to[1] * targetStubWithOffset),
isXGreaterThanStubTimes2: Math.abs(sx - tx) > (sourceStubWithOffset + targetStubWithOffset),
isYGreaterThanStubTimes2: Math.abs(sy - ty) > (sourceStubWithOffset + targetStubWithOffset),
opposite: oProduct === -1,
perpendicular: oProduct === 0,
orthogonal: oProduct === 1,
sourceAxis: so[0] === 0 ? "y" : "x",
points: [x, y, w, h, sx, sy, tx, ty ],
stubs:[sourceStubWithOffset, targetStubWithOffset],
anchorOrientation: "opposite", // always opposite since our endpoints are always opposite (source orientation is left (1) and target orientaiton is right (-1))
/** custom keys added */
sourceEndpoint: params.sourceEndpoint,
targetEndpoint: targetEndpoint,
sourcePos: params.sourcePos,
targetPos: targetEndpoint.anchor.getCurrentLocation(),
targetGap,
};
return result;
};
}).call(typeof window !== 'undefined' ? window : this);

View file

@ -28,7 +28,7 @@ import {
IPushDataNodeExecuteAfter,
IUpdateInformation,
IWorkflowDb,
XYPositon,
XYPosition,
IRestApiContext,
} from './Interface';
@ -576,6 +576,13 @@ export const store = new Vuex.Store({
}
state.workflowExecutionData.data.resultData.runData[pushData.nodeName].push(pushData.data);
},
clearNodeExecutionData (state, nodeName: string): void {
if (state.workflowExecutionData === null) {
return;
}
Vue.delete(state.workflowExecutionData.data.resultData.runData, nodeName);
},
setWorkflowSettings (state, workflowSettings: IWorkflowSettings) {
Vue.set(state.workflow, 'settings', workflowSettings);
@ -739,7 +746,7 @@ export const store = new Vuex.Store({
return state.nodeIndex[index];
},
getNodeViewOffsetPosition: (state): XYPositon => {
getNodeViewOffsetPosition: (state): XYPosition => {
return state.nodeViewOffsetPosition;
},
isNodeViewMoveInProgress: (state): boolean => {
@ -766,9 +773,7 @@ export const store = new Vuex.Store({
allConnections: (state): IConnections => {
return state.workflow.connections;
},
// connectionsByNodeName: (state) => (nodeName: string): {[key: string]: Connection[][]} | null => {
// connectionsByNodeName: (state) => (nodeName: string): { [key: string]: NodeConnections} | null => {
connectionsByNodeName: (state) => (nodeName: string): INodeConnections => {
outgoingConnectionsByNodeName: (state) => (nodeName: string): INodeConnections => {
if (state.workflow.connections.hasOwnProperty(nodeName)) {
return state.workflow.connections[nodeName];
}
@ -777,15 +782,14 @@ export const store = new Vuex.Store({
allNodes: (state): INodeUi[] => {
return state.workflow.nodes;
},
nodeByName: (state) => (nodeName: string): INodeUi | null => {
const foundNode = state.workflow.nodes.find(node => {
return node.name === nodeName;
});
if (foundNode === undefined) {
return null;
}
return foundNode;
nodesByName: (state: IRootState): {[name: string]: INodeUi} => {
return state.workflow.nodes.reduce((accu: {[name: string]: INodeUi}, node) => {
accu[node.name] = node;
return accu;
}, {});
},
getNodeByName: (state, getters) => (nodeName: string): INodeUi | null => {
return getters.nodesByName[nodeName] || null;
},
nodesIssuesExist: (state): boolean => {
for (const node of state.workflow.nodes) {
@ -810,10 +814,10 @@ export const store = new Vuex.Store({
return foundType;
},
activeNode: (state, getters): INodeUi | null => {
return getters.nodeByName(state.activeNode);
return getters.getNodeByName(state.activeNode);
},
lastSelectedNode: (state, getters): INodeUi | null => {
return getters.nodeByName(state.lastSelectedNode);
return getters.getNodeByName(state.lastSelectedNode);
},
lastSelectedNodeOutputIndex: (state, getters): number | null => {
return state.lastSelectedNodeOutputIndex;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,725 @@
import { getStyleTokenValue } from "@/components/helpers";
import { START_NODE_TYPE } from "@/constants";
import { IBounds, INodeUi, IZoomConfig, XYPosition } from "@/Interface";
import { Connection, Endpoint, Overlay, OverlaySpec, PaintStyle } from "jsplumb";
import {
IConnection,
ITaskData,
INodeExecutionData,
NodeInputConnections,
INodeTypeDescription,
} from 'n8n-workflow';
export const OVERLAY_DROP_NODE_ID = 'drop-add-node';
export const OVERLAY_MIDPOINT_ARROW_ID = 'midpoint-arrow';
export const OVERLAY_ENDPOINT_ARROW_ID = 'endpoint-arrow';
export const OVERLAY_RUN_ITEMS_ID = 'run-items-label';
export const OVERLAY_CONNECTION_ACTIONS_ID = 'connection-actions';
export const JSPLUMB_FLOWCHART_STUB = 26;
export const OVERLAY_INPUT_NAME_LABEL = 'input-name-label';
export const OVERLAY_INPUT_NAME_LABEL_POSITION = [-3, .5];
export const OVERLAY_INPUT_NAME_LABEL_POSITION_MOVED = [-4.5, .5];
export const OVERLAY_OUTPUT_NAME_LABEL = 'output-name-label';
export const GRID_SIZE = 20;
const MIN_X_TO_SHOW_OUTPUT_LABEL = 90;
const MIN_Y_TO_SHOW_OUTPUT_LABEL = 100;
export const NODE_SIZE = 100;
export const DEFAULT_START_POSITION_X = 240;
export const DEFAULT_START_POSITION_Y = 300;
export const HEADER_HEIGHT = 65;
export const SIDEBAR_WIDTH = 65;
export const MAX_X_TO_PUSH_DOWNSTREAM_NODES = 300;
export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE;
const LOOPBACK_MINIMUM = 140;
export const INPUT_UUID_KEY = '-input';
export const OUTPUT_UUID_KEY = '-output';
export const DEFAULT_START_NODE = {
name: 'Start',
type: START_NODE_TYPE,
typeVersion: 1,
position: [
DEFAULT_START_POSITION_X,
DEFAULT_START_POSITION_Y,
] as XYPosition,
parameters: {},
};
export const CONNECTOR_FLOWCHART_TYPE = ['N8nCustom', {
cornerRadius: 12,
stub: JSPLUMB_FLOWCHART_STUB + 10,
targetGap: 4,
alwaysRespectStubs: false,
loopbackVerticalLength: NODE_SIZE, // height of vertical segment when looping
loopbackMinimum: LOOPBACK_MINIMUM, // minimum length before flowchart loops around
getEndpointOffset(endpoint: Endpoint) {
const indexOffset = 10; // stub offset between different endpoints of same node
const index = endpoint && endpoint.__meta ? endpoint.__meta.index : 0;
const outputOverlay = getOverlay(endpoint, OVERLAY_OUTPUT_NAME_LABEL);
const labelOffset = outputOverlay && outputOverlay.label && outputOverlay.label.length > 1 ? 10 : 0;
return index * indexOffset + labelOffset;
},
}];
export const CONNECTOR_PAINT_STYLE_DEFAULT: PaintStyle = {
stroke: getStyleTokenValue('--color-foreground-dark'),
strokeWidth: 2,
outlineWidth: 12,
outlineStroke: 'transparent',
};
export const CONNECTOR_PAINT_STYLE_PULL: PaintStyle = {
...CONNECTOR_PAINT_STYLE_DEFAULT,
stroke: getStyleTokenValue('--color-foreground-xdark'),
};
export const CONNECTOR_PAINT_STYLE_PRIMARY = {
...CONNECTOR_PAINT_STYLE_DEFAULT,
stroke: getStyleTokenValue('--color-primary'),
};
export const CONNECTOR_PAINT_STYLE_SUCCESS = {
...CONNECTOR_PAINT_STYLE_DEFAULT,
stroke: getStyleTokenValue('--color-success-light'),
};
export const CONNECTOR_ARROW_OVERLAYS: OverlaySpec[] = [
[
'Arrow',
{
id: OVERLAY_ENDPOINT_ARROW_ID,
location: 1,
width: 12,
foldback: 1,
length: 10,
visible: true,
},
],
[
'Arrow',
{
id: OVERLAY_MIDPOINT_ARROW_ID,
location: 0.5,
width: 12,
foldback: 1,
length: 10,
visible: false,
},
],
];
export const CONNECTOR_DROP_NODE_OVERLAY: OverlaySpec[] = [
[
'Label',
{
id: OVERLAY_DROP_NODE_ID,
label: 'Drop connection<br />to add node',
cssClass: 'drop-add-node-label',
location: 0.5,
visible: true,
},
],
];
export const ANCHOR_POSITIONS: {
[key: string]: {
[key: number]: string[] | number[][];
}
} = {
input: {
1: [
[0.01, 0.5, -1, 0],
],
2: [
[0.01, 0.3, -1, 0],
[0.01, 0.7, -1, 0],
],
3: [
[0.01, 0.25, -1, 0],
[0.01, 0.5, -1, 0],
[0.01, 0.75, -1, 0],
],
4: [
[0.01, 0.2, -1, 0],
[0.01, 0.4, -1, 0],
[0.01, 0.6, -1, 0],
[0.01, 0.8, -1, 0],
],
},
output: {
1: [
[.99, 0.5, 1, 0],
],
2: [
[.99, 0.3, 1, 0],
[.99, 0.7, 1, 0],
],
3: [
[.99, 0.25, 1, 0],
[.99, 0.5, 1, 0],
[.99, 0.75, 1, 0],
],
4: [
[.99, 0.2, 1, 0],
[.99, 0.4, 1, 0],
[.99, 0.6, 1, 0],
[.99, 0.8, 1, 0],
],
},
};
export const getInputEndpointStyle = (nodeTypeData: INodeTypeDescription, color: string) => ({
width: 8,
height: nodeTypeData && nodeTypeData.outputs.length > 2 ? 18 : 20,
fill: getStyleTokenValue(color),
stroke: getStyleTokenValue(color),
lineWidth: 0,
});
export const getInputNameOverlay = (label: string) => ([
'Label',
{
id: OVERLAY_INPUT_NAME_LABEL,
location: OVERLAY_INPUT_NAME_LABEL_POSITION,
label,
cssClass: 'node-input-endpoint-label',
visible: true,
},
]);
export const getOutputEndpointStyle = (nodeTypeData: INodeTypeDescription, color: string) => ({
radius: nodeTypeData && nodeTypeData.outputs.length > 2 ? 7 : 9,
fill: getStyleTokenValue(color),
outlineStroke: 'none',
});
export const getOutputNameOverlay = (label: string) => ([
'Label',
{
id: OVERLAY_OUTPUT_NAME_LABEL,
location: [1.9, 0.5],
label,
cssClass: 'node-output-endpoint-label',
visible: true,
},
]);
export const addOverlays = (connection: Connection, overlays: OverlaySpec[]) => {
overlays.forEach((overlay: OverlaySpec) => {
connection.addOverlay(overlay);
});
};
export const getLeftmostTopNode = (nodes: INodeUi[]): INodeUi => {
return nodes.reduce((leftmostTop, node) => {
if (node.position[0] > leftmostTop.position[0] || node.position[1] > leftmostTop.position[1]) {
return leftmostTop;
}
return node;
});
};
export const getWorkflowCorners = (nodes: INodeUi[]): IBounds => {
return nodes.reduce((accu: IBounds, node: INodeUi) => {
if (node.position[0] < accu.minX) {
accu.minX = node.position[0];
}
if (node.position[1] < accu.minY) {
accu.minY = node.position[1];
}
if (node.position[0] > accu.maxX) {
accu.maxX = node.position[0];
}
if (node.position[1] > accu.maxY) {
accu.maxY = node.position[1];
}
return accu;
}, {
minX: nodes[0].position[0],
minY: nodes[0].position[1],
maxX: nodes[0].position[0],
maxY: nodes[0].position[1],
});
};
export const scaleSmaller = ({scale, offset: [xOffset, yOffset]}: IZoomConfig): IZoomConfig => {
scale /= 1.25;
xOffset /= 1.25;
yOffset /= 1.25;
xOffset += window.innerWidth / 10;
yOffset += window.innerHeight / 10;
return {
scale,
offset: [xOffset, yOffset],
};
};
export const scaleBigger = ({scale, offset: [xOffset, yOffset]}: IZoomConfig): IZoomConfig => {
scale *= 1.25;
xOffset -= window.innerWidth / 10;
yOffset -= window.innerHeight / 10;
xOffset *= 1.25;
yOffset *= 1.25;
return {
scale,
offset: [xOffset, yOffset],
};
};
export const scaleReset = (config: IZoomConfig): IZoomConfig => {
if (config.scale > 1) { // zoomed in
while (config.scale > 1) {
config = scaleSmaller(config);
}
}
else {
while (config.scale < 1) {
config = scaleBigger(config);
}
}
config.scale = 1;
return config;
};
export const getOverlay = (item: Connection | Endpoint, overlayId: string) => {
try {
return item.getOverlay(overlayId); // handle when _jsPlumb element is deleted
} catch (e) {
return null;
}
};
export const showOverlay = (item: Connection | Endpoint, overlayId: string) => {
const overlay = getOverlay(item, overlayId);
if (overlay) {
overlay.setVisible(true);
}
};
export const hideOverlay = (item: Connection | Endpoint, overlayId: string) => {
const overlay = getOverlay(item, overlayId);
if (overlay) {
overlay.setVisible(false);
}
};
export const showOrHideMidpointArrow = (connection: Connection) => {
if (!connection || !connection.endpoints || connection.endpoints.length !== 2) {
return;
}
const hasItemsLabel = !!getOverlay(connection, OVERLAY_RUN_ITEMS_ID);
const sourceEndpoint = connection.endpoints[0];
const targetEndpoint = connection.endpoints[1];
const sourcePosition = sourceEndpoint.anchor.lastReturnValue[0];
const targetPosition = targetEndpoint.anchor.lastReturnValue ? targetEndpoint.anchor.lastReturnValue[0] : sourcePosition + 1; // lastReturnValue is null when moving connections from node to another
const minimum = hasItemsLabel ? 150 : 0;
const isBackwards = sourcePosition >= targetPosition;
const isTooLong = Math.abs(sourcePosition - targetPosition) >= minimum;
const arrow = getOverlay(connection, OVERLAY_MIDPOINT_ARROW_ID);
if (arrow) {
arrow.setVisible(isBackwards && isTooLong);
arrow.setLocation(hasItemsLabel ? .6: .5);
}
};
export const getConnectorLengths = (connection: Connection): [number, number] => {
if (!connection.connector) {
return [0, 0];
}
const bounds = connection.connector.bounds;
const diffX = Math.abs(bounds.maxX - bounds.minX);
const diffY = Math.abs(bounds.maxY - bounds.minY);
return [diffX, diffY];
};
const isLoopingBackwards = (connection: Connection) => {
const sourceEndpoint = connection.endpoints[0];
const targetEndpoint = connection.endpoints[1];
const sourcePosition = sourceEndpoint.anchor.lastReturnValue[0];
const targetPosition = targetEndpoint.anchor.lastReturnValue[0];
return targetPosition - sourcePosition < (-1 * LOOPBACK_MINIMUM);
};
export const showOrHideItemsLabel = (connection: Connection) => {
if (!connection || !connection.connector) {
return;
}
const overlay = getOverlay(connection, OVERLAY_RUN_ITEMS_ID);
if (!overlay) {
return;
}
const actionsOverlay = getOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID);
if (actionsOverlay && actionsOverlay.visible) {
overlay.setVisible(false);
return;
}
const [diffX, diffY] = getConnectorLengths(connection);
if (diffX < MIN_X_TO_SHOW_OUTPUT_LABEL && diffY < MIN_Y_TO_SHOW_OUTPUT_LABEL) {
overlay.setVisible(false);
}
else {
overlay.setVisible(true);
}
const innerElement = overlay.canvas && overlay.canvas.querySelector('span');
if (innerElement) {
if (diffY === 0 || isLoopingBackwards(connection)) {
innerElement.classList.add('floating');
}
else {
innerElement.classList.remove('floating');
}
}
};
export const getIcon = (name: string): string => {
if (name === 'trash') {
return `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="trash" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="svg-inline--fa fa-trash fa-w-14 Icon__medium_ctPPJ"><path data-v-66d5c7e2="" fill="currentColor" d="M432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16zM53.2 467a48 48 0 0 0 47.9 45h245.8a48 48 0 0 0 47.9-45L416 128H32z" class=""></path></svg>`;
}
if (name === 'plus') {
return `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="plus" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="svg-inline--fa fa-plus fa-w-14 Icon__medium_ctPPJ"><path data-v-301ed208="" fill="currentColor" d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z" class=""></path></svg>`;
}
return '';
};
const canUsePosition = (position1: XYPosition, position2: XYPosition) => {
if (Math.abs(position1[0] - position2[0]) <= 100) {
if (Math.abs(position1[1] - position2[1]) <= 50) {
return false;
}
}
return true;
};
export const getNewNodePosition = (nodes: INodeUi[], newPosition: XYPosition, movePosition?: XYPosition): XYPosition => {
const targetPosition: XYPosition = [...newPosition];
targetPosition[0] = targetPosition[0] - (targetPosition[0] % GRID_SIZE);
targetPosition[1] = targetPosition[1] - (targetPosition[1] % GRID_SIZE);
if (!movePosition) {
movePosition = [40, 40];
}
let conflictFound = false;
let i, node;
do {
conflictFound = false;
for (i = 0; i < nodes.length; i++) {
node = nodes[i];
if (!canUsePosition(node.position, targetPosition)) {
conflictFound = true;
break;
}
}
if (conflictFound === true) {
targetPosition[0] += movePosition[0];
targetPosition[1] += movePosition[1];
}
} while (conflictFound === true);
return targetPosition;
};
export const getMousePosition = (e: MouseEvent | TouchEvent): XYPosition => {
// @ts-ignore
const x = e.pageX !== undefined ? e.pageX : (e.touches && e.touches[0] && e.touches[0].pageX ? e.touches[0].pageX : 0);
// @ts-ignore
const y = e.pageY !== undefined ? e.pageY : (e.touches && e.touches[0] && e.touches[0].pageY ? e.touches[0].pageY : 0);
return [x, y];
};
export const getRelativePosition = (x: number, y: number, scale: number, offset: XYPosition): XYPosition => {
return [
(x - offset[0]) / scale,
(y - offset[1]) / scale,
];
};
export const getBackgroundStyles = (scale: number, offsetPosition: XYPosition) => {
const squareSize = GRID_SIZE * scale;
const dotSize = 1 * scale;
const dotPosition = (GRID_SIZE / 2) * scale;
const styles: object = {
'background-size': `${squareSize}px ${squareSize}px`,
'background-position': `left ${offsetPosition[0]}px top ${offsetPosition[1]}px`,
};
if (squareSize > 10.5) {
const dotColor = getStyleTokenValue('--color-canvas-dot');
return {
...styles,
'background-image': `radial-gradient(circle at ${dotPosition}px ${dotPosition}px, ${dotColor} ${dotSize}px, transparent 0)`,
};
}
return styles;
};
export const hideConnectionActions = (connection: Connection | null) => {
if (connection && connection.connector) {
hideOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID);
showOrHideItemsLabel(connection);
showOrHideMidpointArrow(connection);
}
};
export const showConectionActions = (connection: Connection | null) => {
if (connection && connection.connector) {
showOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID);
hideOverlay(connection, OVERLAY_RUN_ITEMS_ID);
if (!getOverlay(connection, OVERLAY_RUN_ITEMS_ID)) {
hideOverlay(connection, OVERLAY_MIDPOINT_ARROW_ID);
}
}
};
export const getOutputSummary = (data: ITaskData[], nodeConnections: NodeInputConnections) => {
const outputMap: {[sourceOutputIndex: string]: {[targetNodeName: string]: {[targetInputIndex: string]: {total: number, iterations: number}}}} = {};
data.forEach((run: ITaskData) => {
if (!run.data || !run.data.main) {
return;
}
run.data.main.forEach((output: INodeExecutionData[] | null, i: number) => {
if (!nodeConnections[i]) {
return;
}
nodeConnections[i]
.map((connection: IConnection) => {
const sourceOutputIndex = i;
const targetNodeName = connection.node;
const targetInputIndex = connection.index;
if (!outputMap[sourceOutputIndex]) {
outputMap[sourceOutputIndex] = {};
}
if (!outputMap[sourceOutputIndex][targetNodeName]) {
outputMap[sourceOutputIndex][targetNodeName] = {};
}
if (!outputMap[sourceOutputIndex][targetNodeName][targetInputIndex]) {
outputMap[sourceOutputIndex][targetNodeName][targetInputIndex] = {
total: 0,
iterations: 0,
};
}
outputMap[sourceOutputIndex][targetNodeName][targetInputIndex].total += output ? output.length : 0;
outputMap[sourceOutputIndex][targetNodeName][targetInputIndex].iterations += output ? 1 : 0;
});
});
});
return outputMap;
};
export const resetConnection = (connection: Connection) => {
connection.removeOverlay(OVERLAY_RUN_ITEMS_ID);
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_DEFAULT);
showOrHideMidpointArrow(connection);
if (connection.canvas) {
connection.canvas.classList.remove('success');
}
};
export const addConnectionOutputSuccess = (connection: Connection, output: {total: number, iterations: number}) => {
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_SUCCESS);
if (connection.canvas) {
connection.canvas.classList.add('success');
}
if (getOverlay(connection, OVERLAY_RUN_ITEMS_ID)) {
connection.removeOverlay(OVERLAY_RUN_ITEMS_ID);
}
let label = `${output.total}`;
label = output.total > 1 ? `${label} items` : `${label} item`;
label = output.iterations > 1 ? `${label} total` : label;
connection.addOverlay([
'Label',
{
id: OVERLAY_RUN_ITEMS_ID,
label: `<span>${label}</span>`,
cssClass: 'connection-run-items-label',
location: .5,
},
]);
showOrHideItemsLabel(connection);
showOrHideMidpointArrow(connection);
};
export const getZoomToFit = (nodes: INodeUi[]): {offset: XYPosition, zoomLevel: number} => {
const {minX, minY, maxX, maxY} = getWorkflowCorners(nodes);
const PADDING = NODE_SIZE * 4;
const editorWidth = window.innerWidth;
const diffX = maxX - minX + SIDEBAR_WIDTH + PADDING;
const scaleX = editorWidth / diffX;
const editorHeight = window.innerHeight;
const diffY = maxY - minY + HEADER_HEIGHT + PADDING;
const scaleY = editorHeight / diffY;
const zoomLevel = Math.min(scaleX, scaleY, 1);
let xOffset = (minX * -1) * zoomLevel + SIDEBAR_WIDTH; // find top right corner
xOffset += (editorWidth - SIDEBAR_WIDTH - (maxX - minX + NODE_SIZE) * zoomLevel) / 2; // add padding to center workflow
let yOffset = (minY * -1) * zoomLevel + HEADER_HEIGHT; // find top right corner
yOffset += (editorHeight - HEADER_HEIGHT - (maxY - minY + NODE_SIZE * 2) * zoomLevel) / 2; // add padding to center workflow
return {
zoomLevel,
offset: [xOffset, yOffset],
};
};
export const getUniqueNodeName = (nodes: INodeUi[], originalName: string, additinalUsedNames?: string[]) => {
// Check if node-name is unique else find one that is
additinalUsedNames = additinalUsedNames || [];
// Get all the names of the current nodes
const nodeNames = nodes.map((node: INodeUi) => {
return node.name;
});
// Check first if the current name is already unique
if (!nodeNames.includes(originalName) && !additinalUsedNames.includes(originalName)) {
return originalName;
}
const nameMatch = originalName.match(/(.*\D+)(\d*)/);
let ignore, baseName, nameIndex, uniqueName;
let index = 1;
if (nameMatch === null) {
// Name is only a number
index = parseInt(originalName, 10);
baseName = '';
uniqueName = baseName + index;
} else {
// Name is string or string/number combination
[ignore, baseName, nameIndex] = nameMatch;
if (nameIndex !== '') {
index = parseInt(nameIndex, 10);
}
uniqueName = baseName;
}
while (
nodeNames.includes(uniqueName) ||
additinalUsedNames.includes(uniqueName)
) {
uniqueName = baseName + (index++);
}
return uniqueName;
};
export const showDropConnectionState = (connection: Connection, targetEndpoint?: Endpoint) => {
if (connection && connection.connector) {
if (targetEndpoint) {
connection.connector.setTargetEndpoint(targetEndpoint);
}
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_PRIMARY);
hideOverlay(connection, OVERLAY_DROP_NODE_ID);
}
};
export const showPullConnectionState = (connection: Connection) => {
if (connection && connection.connector) {
connection.connector.resetTargetEndpoint();
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_PULL);
showOverlay(connection, OVERLAY_DROP_NODE_ID);
}
};
export const resetConnectionAfterPull = (connection: Connection) => {
if (connection && connection.connector) {
connection.connector.resetTargetEndpoint();
connection.setPaintStyle(CONNECTOR_PAINT_STYLE_DEFAULT);
}
};
export const resetInputLabelPosition = (targetEndpoint: Endpoint) => {
const inputNameOverlay = getOverlay(targetEndpoint, OVERLAY_INPUT_NAME_LABEL);
if (inputNameOverlay) {
inputNameOverlay.setLocation(OVERLAY_INPUT_NAME_LABEL_POSITION);
}
};
export const moveBackInputLabelPosition = (targetEndpoint: Endpoint) => {
const inputNameOverlay = getOverlay(targetEndpoint, OVERLAY_INPUT_NAME_LABEL);
if (inputNameOverlay) {
inputNameOverlay.setLocation(OVERLAY_INPUT_NAME_LABEL_POSITION_MOVED);
}
};
export const addConnectionActionsOverlay = (connection: Connection, onDelete: Function, onAdd: Function) => {
if (getOverlay(connection, OVERLAY_CONNECTION_ACTIONS_ID)) {
return; // avoid free floating actions when moving connection from one node to another
}
connection.addOverlay([
'Label',
{
id: OVERLAY_CONNECTION_ACTIONS_ID,
label: `<div class="add">${getIcon('plus')}</div> <div class="delete">${getIcon('trash')}</div>`,
cssClass: OVERLAY_CONNECTION_ACTIONS_ID,
visible: false,
events: {
mousedown: (overlay: Overlay, event: MouseEvent) => {
const element = event.target as HTMLElement;
if (element.classList.contains('delete') || (element.parentElement && element.parentElement.classList.contains('delete'))) {
onDelete();
}
else if (element.classList.contains('add') || (element.parentElement && element.parentElement.classList.contains('add'))) {
onAdd();
}
},
},
},
]);
};
export const getOutputEndpointUUID = (nodeIndex: string, outputIndex: number) => {
return `${nodeIndex}${OUTPUT_UUID_KEY}${outputIndex}`;
};
export const getInputEndpointUUID = (nodeIndex: string, inputIndex: number) => {
return `${nodeIndex}${INPUT_UUID_KEY}${inputIndex}`;
};

View file

@ -1,85 +0,0 @@
import { INodeUi, IZoomConfig } from "@/Interface";
interface ICorners {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export const getLeftmostTopNode = (nodes: INodeUi[]): INodeUi => {
return nodes.reduce((leftmostTop, node) => {
if (node.position[0] > leftmostTop.position[0] || node.position[1] > leftmostTop.position[1]) {
return leftmostTop;
}
return node;
});
};
export const getWorkflowCorners = (nodes: INodeUi[]): ICorners => {
return nodes.reduce((accu: ICorners, node: INodeUi) => {
if (node.position[0] < accu.minX) {
accu.minX = node.position[0];
}
if (node.position[1] < accu.minY) {
accu.minY = node.position[1];
}
if (node.position[0] > accu.maxX) {
accu.maxX = node.position[0];
}
if (node.position[1] > accu.maxY) {
accu.maxY = node.position[1];
}
return accu;
}, {
minX: nodes[0].position[0],
minY: nodes[0].position[1],
maxX: nodes[0].position[0],
maxY: nodes[0].position[1],
});
};
export const scaleSmaller = ({scale, offset: [xOffset, yOffset]}: IZoomConfig): IZoomConfig => {
scale /= 1.25;
xOffset /= 1.25;
yOffset /= 1.25;
xOffset += window.innerWidth / 10;
yOffset += window.innerHeight / 10;
return {
scale,
offset: [xOffset, yOffset],
};
};
export const scaleBigger = ({scale, offset: [xOffset, yOffset]}: IZoomConfig): IZoomConfig => {
scale *= 1.25;
xOffset -= window.innerWidth / 10;
yOffset -= window.innerHeight / 10;
xOffset *= 1.25;
yOffset *= 1.25;
return {
scale,
offset: [xOffset, yOffset],
};
};
export const scaleReset = (config: IZoomConfig): IZoomConfig => {
if (config.scale > 1) { // zoomed in
while (config.scale > 1) {
config = scaleSmaller(config);
}
}
else {
while (config.scale < 1) {
config = scaleBigger(config);
}
}
config.scale = 1;
return config;
};

View file

@ -1,6 +1,6 @@
{
"name": "n8n-node-dev",
"version": "0.30.0",
"version": "0.33.0",
"description": "CLI to simplify n8n credentials/node development",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -60,8 +60,8 @@
"change-case": "^4.1.1",
"copyfiles": "^2.1.1",
"inquirer": "^7.0.1",
"n8n-core": "~0.90.0",
"n8n-workflow": "~0.73.0",
"n8n-core": "~0.93.0",
"n8n-workflow": "~0.76.0",
"oauth-1.0a": "^2.2.6",
"replace-in-file": "^6.0.0",
"request": "^2.88.2",

View file

@ -0,0 +1,18 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class DropcontactApi implements ICredentialType {
name = 'dropcontactApi';
displayName = 'Dropcontact API';
documentationUrl = 'dropcontact';
properties = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -0,0 +1,30 @@
import {
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class MicrosoftDynamicsOAuth2Api implements ICredentialType {
name = 'microsoftDynamicsOAuth2Api';
extends = [
'microsoftOAuth2Api',
];
displayName = 'Microsoft Dynamics OAuth2 API';
documentationUrl = 'microsoft';
properties: INodeProperties[] = [
//https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent
{
displayName: 'Subdomain',
name: 'subdomain',
type: 'string',
required: true,
placeholder: 'organization',
default: '',
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: '=openid offline_access https://{{$self.subdomain}}.crm.dynamics.com/.default',
},
];
}

View file

@ -0,0 +1,19 @@
import {
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class OneSimpleApi implements ICredentialType {
name = 'oneSimpleApi';
displayName = 'One Simple API';
documentationUrl = 'oneSimpleApi';
properties: INodeProperties[] = [
{
displayName: 'API Token',
name: 'apiToken',
type: 'string',
default: '',
},
];
}

View file

@ -15,6 +15,8 @@ const userScopes = [
'reactions:write',
'stars:read',
'stars:write',
'usergroups:write',
'usergroups:read',
'users.profile:read',
'users.profile:write',
];

View file

@ -1,15 +0,0 @@
{
"node": "n8n-nodes-base.activationTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Core Nodes"
],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/nodes/n8n-nodes-base.activationTrigger/"
}
]
}
}

View file

@ -0,0 +1,20 @@
{
"node": "n8n-nodes-base.awsTextract",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Utility"
],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/aws"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/nodes/n8n-nodes-base.awsTextract/"
}
]
}
}

View file

@ -0,0 +1,20 @@
{
"node": "n8n-nodes-base.dropcontact",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Sales"
],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/dropcontact"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/nodes/n8n-nodes-base.dropcontact/"
}
]
}
}

View file

@ -0,0 +1,370 @@
import {
IExecuteFunctions,
} from 'n8n-core';
import {
ICredentialDataDecryptedObject,
ICredentialsDecrypted,
ICredentialTestFunctions,
IDataObject,
INodeExecutionData,
INodeType,
INodeTypeDescription,
NodeApiError,
NodeCredentialTestResult,
} from 'n8n-workflow';
import {
dropcontactApiRequest,
validateCrendetials,
} from './GenericFunction';
export class Dropcontact implements INodeType {
description: INodeTypeDescription = {
displayName: 'Dropcontact',
name: 'dropcontact',
icon: 'file:dropcontact.svg',
group: ['transform'],
version: 1,
description: 'Find B2B emails and enrich contacts',
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
defaults: {
name: 'Dropcontact',
color: '#0ABA9F',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'dropcontactApi',
required: true,
testedBy: 'dropcontactApiCredentialTest',
},
],
properties: [
{
displayName: 'Resource',
noDataExpression: true,
name: 'resource',
type: 'options',
options: [
{
name: 'Contact',
value: 'contact',
},
],
default: 'contact',
required: true,
},
{
displayName: 'Operation',
noDataExpression: true,
name: 'operation',
type: 'options',
options: [
{
name: 'Enrich',
value: 'enrich',
description: 'Find B2B emails and enrich your contact from his name and his website',
},
{
name: 'Fetch Request',
value: 'fetchRequest',
},
],
default: 'enrich',
required: true,
},
{
displayName: 'Request ID',
name: 'requestId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'contact',
],
operation: [
'fetchRequest',
],
},
},
default: '',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
displayOptions: {
show: {
resource: [
'contact',
],
operation: [
'enrich',
],
},
},
default: '',
},
{
displayName: 'Simplify Output (Faster)',
name: 'simplify',
type: 'boolean',
displayOptions: {
show: {
resource: [
'contact',
],
operation: [
'enrich',
],
},
},
default: false,
description: 'When off, waits for the contact data before completing. Waiting time can be adjusted with Extend Wait Time option. When on, returns a request_id that can be used later in the Fetch Request operation.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'contact',
],
operation: [
'enrich',
],
},
},
options: [
{
displayName: 'Company SIREN Number',
name: 'num_siren',
type: 'string',
default: '',
},
{
displayName: 'Company SIRET Code',
name: 'siret',
type: 'string',
default: '',
},
{
displayName: 'Company Name',
name: 'company',
type: 'string',
default: '',
},
{
displayName: 'Country',
name: 'country',
type: 'string',
default: '',
},
{
displayName: 'First Name',
name: 'first_name',
type: 'string',
default: '',
},
{
displayName: 'Full Name',
name: 'full_name',
type: 'string',
default: '',
},
{
displayName: 'Last Name',
name: 'last_name',
type: 'string',
default: '',
},
{
displayName: 'LinkedIn Profile',
name: 'linkedin',
type: 'string',
default: '',
},
{
displayName: 'Phone Number',
name: 'phone',
type: 'string',
default: '',
},
{
displayName: 'Website',
name: 'website',
type: 'string',
default: '',
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
resource: [
'contact',
],
operation: [
'enrich',
],
},
},
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Data Fetch Wait Time',
name: 'waitTime',
type: 'number',
typeOptions: {
minValue: 1,
},
displayOptions: {
show: {
'/simplify': [
false,
],
},
},
default: 45,
description: 'When not simplifying the response, data will be fetched in two steps. This parameter controls how long to wait (in seconds) before trying the second step',
},
{
displayName: 'French Company Enrich',
name: 'siren',
type: 'boolean',
default: false,
description: `Whether you want the <a href="https://en.wikipedia.org/wiki/SIREN_code" target="_blank">SIREN number</a>, NAF code, TVA number, company address and informations about the company leader.</br>
Only applies to french companies`,
},
{
displayName: 'Language',
name: 'language',
type: 'options',
options: [
{
name: 'English',
value: 'en',
},
{
name: 'French',
value: 'fr',
},
],
default: 'en',
description: 'Whether the response is in English or French',
},
],
},
],
};
methods = {
credentialTest: {
async dropcontactApiCredentialTest(this: ICredentialTestFunctions, credential: ICredentialsDecrypted): Promise<NodeCredentialTestResult> {
try {
await validateCrendetials.call(this, credential.data as ICredentialDataDecryptedObject);
} catch (error) {
return {
status: 'Error',
message: 'The API Key included in the request is invalid',
};
}
return {
status: 'OK',
message: 'Connection successful!',
};
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const entryData = this.getInputData();
const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string;
// tslint:disable-next-line: no-any
let responseData: any;
const returnData: IDataObject[] = [];
if (resource === 'contact') {
if (operation === 'enrich') {
const options = this.getNodeParameter('options', 0) as IDataObject;
const data = [];
const simplify = this.getNodeParameter('simplify', 0) as boolean;
const siren = options.siren === true ? true : false;
const language = options.language ? options.language : 'en';
for (let i = 0; i < entryData.length; i++) {
const email = this.getNodeParameter('email', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
const body: IDataObject = {};
if (email !== '') {
body.email = email;
}
Object.assign(body, additionalFields);
data.push(body);
}
responseData = await dropcontactApiRequest.call(this, 'POST', '/batch', { data, siren, language }, {}) as { request_id: string, error: string, success: boolean };
if (!responseData.success) {
if (this.continueOnFail()) {
returnData.push({ error: responseData.reason || 'invalid request' });
} else {
throw new NodeApiError(this.getNode(), { error: responseData.reason || 'invalid request' });
}
}
if (simplify === false) {
const waitTime = this.getNodeParameter('options.waitTime', 0, 45) as number;
// tslint:disable-next-line: no-any
const delay = (ms: any) => new Promise(res => setTimeout(res, ms * 1000));
await delay(waitTime);
responseData = await dropcontactApiRequest.call(this, 'GET', `/batch/${responseData.request_id}`, {}, {});
if (!responseData.success) {
if (this.continueOnFail()) {
responseData.push({ error: responseData.reason });
} else {
throw new NodeApiError(this.getNode(), {
error: responseData.reason,
description: 'Hint: Increase the Wait Time to avoid this error',
});
}
} else {
returnData.push(...responseData.data);
}
} else {
returnData.push(responseData);
}
}
if (operation === 'fetchRequest') {
for (let i = 0; i < entryData.length; i++) {
const requestId = this.getNodeParameter('requestId', i) as string;
responseData = await dropcontactApiRequest.call(this, 'GET', `/batch/${requestId}`, {}, {}) as { request_id: string, error: string, success: boolean };
if (!responseData.success) {
if (this.continueOnFail()) {
responseData.push({ error: responseData.reason || 'invalid request' });
} else {
throw new NodeApiError(this.getNode(), { error: responseData.reason || 'invalid request' });
}
}
returnData.push(...responseData.data);
}
}
}
return [this.helpers.returnJsonArray(returnData)];
}
}

View file

@ -0,0 +1,82 @@
import {
IExecuteFunctions,
IHookFunctions,
} from 'n8n-core';
import {
ICredentialDataDecryptedObject,
ICredentialTestFunctions,
IDataObject,
ILoadOptionsFunctions,
NodeApiError,
} from 'n8n-workflow';
import {
OptionsWithUri,
} from 'request';
/**
* Make an authenticated API request to Bubble.
*/
export async function dropcontactApiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
method: string,
endpoint: string,
body: IDataObject,
qs: IDataObject,
) {
const { apiKey } = await this.getCredentials('dropcontactApi') as {
apiKey: string,
};
const options: OptionsWithUri = {
headers: {
'user-agent': 'n8n',
'X-Access-Token': apiKey,
},
method,
uri: `https://api.dropcontact.io${endpoint}`,
qs,
body,
json: true,
};
if (!Object.keys(body).length) {
delete options.body;
}
if (!Object.keys(qs).length) {
delete options.qs;
}
try {
return await this.helpers.request!(options);
} catch (error) {
throw new NodeApiError(this.getNode(), error);
}
}
export async function validateCrendetials(this: ICredentialTestFunctions, decryptedCredentials: ICredentialDataDecryptedObject): Promise<any> { // tslint:disable-line:no-any
const credentials = decryptedCredentials;
const { apiKey } = credentials as {
apiKey: string,
};
const options: OptionsWithUri = {
headers: {
'user-agent': 'n8n',
'X-Access-Token': apiKey,
},
method: 'POST',
body: {
data: [{ email: '' }],
},
uri: `https://api.dropcontact.io/batch`,
json: true,
};
return this.helpers.request!(options);
}

View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.5692 16.219C28.1469 15.5371 26.5535 15.1552 24.8709 15.1552C18.8615 15.1552 13.9899 20.0268 13.9899 26.0362C13.9899 32.0456 18.8615 36.9172 24.8709 36.9172C28.9621 36.9172 32.526 34.6592 34.3843 31.3215L31.7614 29.8643C30.4154 32.2818 27.8341 33.9172 24.8709 33.9172C20.5183 33.9172 16.9899 30.3888 16.9899 26.0362C16.9899 21.6836 20.5183 18.1552 24.8709 18.1552C26.0896 18.1552 27.2437 18.4318 28.2738 18.9257L29.5692 16.219ZM34.7779 1.98444V27.7461H31.669V0.899363C29.5459 0.313171 27.3095 0 25 0C11.1929 0 0 11.1929 0 25C0 38.8071 11.1929 50 25 50C38.8071 50 50 38.8071 50 25C50 14.6626 43.7259 5.7907 34.7779 1.98444Z" fill="#0ABA9F"/>
</svg>

After

Width:  |  Height:  |  Size: 773 B

View file

@ -152,6 +152,10 @@ const nodeOperationOptions: INodeProperties[] = [
},
},
options: [
{
name: 'Circle',
value: 'circle',
},
{
name: 'Line',
value: 'line',
@ -192,6 +196,7 @@ const nodeOperationOptions: INodeProperties[] = [
'draw',
],
primitive: [
'circle',
'line',
'rectangle',
],
@ -210,6 +215,7 @@ const nodeOperationOptions: INodeProperties[] = [
'draw',
],
primitive: [
'circle',
'line',
'rectangle',
],
@ -228,6 +234,7 @@ const nodeOperationOptions: INodeProperties[] = [
'draw',
],
primitive: [
'circle',
'line',
'rectangle',
],
@ -246,6 +253,7 @@ const nodeOperationOptions: INodeProperties[] = [
'draw',
],
primitive: [
'circle',
'line',
'rectangle',
],
@ -472,6 +480,110 @@ const nodeOperationOptions: INodeProperties[] = [
},
description: 'The name of the binary property which contains the data of the image to composite on top of image which is found in Property Name.',
},
{
displayName: 'Operator',
name: 'operator',
type: 'options',
displayOptions: {
show: {
operation: [
'composite',
],
},
},
options: [
{
name: 'Add',
value: 'Add',
},
{
name: 'Atop',
value: 'Atop',
},
{
name: 'Bumpmap',
value: 'Bumpmap',
},
{
name: 'Copy',
value: 'Copy',
},
{
name: 'Copy Black',
value: 'CopyBlack',
},
{
name: 'Copy Blue',
value: 'CopyBlue',
},
{
name: 'Copy Cyan',
value: 'CopyCyan',
},
{
name: 'Copy Green',
value: 'CopyGreen',
},
{
name: 'Copy Magenta',
value: 'CopyMagenta',
},
{
name: 'Copy Opacity',
value: 'CopyOpacity',
},
{
name: 'Copy Red',
value: 'CopyRed',
},
{
name: 'Copy Yellow',
value: 'CopyYellow',
},
{
name: 'Difference',
value: 'Difference',
},
{
name: 'Divide',
value: 'Divide',
},
{
name: 'In',
value: 'In',
},
{
name: 'Minus',
value: 'Minus',
},
{
name: 'Multiply',
value: 'Multiply',
},
{
name: 'Out',
value: 'Out',
},
{
name: 'Over',
value: 'Over',
},
{
name: 'Plus',
value: 'Plus',
},
{
name: 'Subtract',
value: 'Subtract',
},
{
name: 'Xor',
value: 'Xor',
},
],
default: 'Over',
description: 'The operator to use to combine the images.',
},
{
displayName: 'Position X',
name: 'positionX',
@ -1095,6 +1207,7 @@ export class EditImage implements INodeType {
} else if (operationData.operation === 'composite') {
const positionX = operationData.positionX as number;
const positionY = operationData.positionY as number;
const operator = operationData.operator as string;
const geometryString = (positionX >= 0 ? '+' : '') + positionX + (positionY >= 0 ? '+' : '') + positionY;
@ -1109,9 +1222,9 @@ export class EditImage implements INodeType {
if (operations[0].operation === 'create') {
// It seems like if the image gets created newly we have to create a new gm instance
// else it fails for some reason
gmInstance = gm(gmInstance!.stream('png')).geometry(geometryString).composite(path);
gmInstance = gm(gmInstance!.stream('png')).compose(operator).geometry(geometryString).composite(path);
} else {
gmInstance = gmInstance!.geometry(geometryString).composite(path);
gmInstance = gmInstance!.compose(operator).geometry(geometryString).composite(path);
}
if (operations.length !== i + 1) {
@ -1131,6 +1244,8 @@ export class EditImage implements INodeType {
if (operationData.primitive === 'line') {
gmInstance = gmInstance.drawLine(operationData.startPositionX as number, operationData.startPositionY as number, operationData.endPositionX as number, operationData.endPositionY as number);
} else if (operationData.primitive === 'circle') {
gmInstance = gmInstance.drawCircle(operationData.startPositionX as number, operationData.startPositionY as number, operationData.endPositionX as number, operationData.endPositionY as number);
} else if (operationData.primitive === 'rectangle') {
gmInstance = gmInstance.drawRectangle(operationData.startPositionX as number, operationData.startPositionY as number, operationData.endPositionX as number, operationData.endPositionY as number, operationData.cornerRadius as number || undefined);
}

View file

@ -0,0 +1,20 @@
{
"node": "n8n-nodes-base.googleDriveTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Data & Storage"
],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/google"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/nodes/n8n-nodes-base.googleDriveTrigger/"
}
]
}
}

View file

@ -124,7 +124,7 @@ export class GoogleTasks implements INodeType {
body.notes = additionalFields.notes as string;
}
if (additionalFields.dueDate) {
body.dueDate = additionalFields.dueDate as string;
body.due = additionalFields.dueDate as string;
}
if (additionalFields.completed) {
@ -249,7 +249,7 @@ export class GoogleTasks implements INodeType {
}
if (updateFields.dueDate) {
body.dueDate = updateFields.dueDate as string;
body.due = updateFields.dueDate as string;
}
if (updateFields.completed) {

View file

@ -447,6 +447,13 @@ export const taskFields = [
default: false,
description: 'Flag indicating whether the task has been deleted.',
},
{
displayName: 'Due Date',
name: 'dueDate',
type: 'dateTime',
default: '',
description: 'Due date of the task.',
},
{
displayName: 'Notes',
name: 'notes',

Some files were not shown because too many files have changed in this diff Show more