Merge branch 'master' of https://github.com/n8n-io/n8n into n8n-io-master

This commit is contained in:
Ricardo Georgel 2021-07-16 11:28:08 -03:00
commit 9f4e18eb8d
252 changed files with 22348 additions and 2279 deletions

85
.github/workflows/test-workflows.yml vendored Normal file
View file

@ -0,0 +1,85 @@
name: Run test workflows
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
jobs:
run-test-workflows:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
steps:
-
name: Checkout
uses: actions/checkout@v2
with:
path: n8n
-
name: Checkout workflows repo
uses: actions/checkout@v2
with:
repository: n8n-io/test-workflows
path: test-workflows
-
name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
-
name: npm install and build
run: |
cd n8n
npm install
npm run bootstrap
npm run build --if-present
env:
CI: true
shell: bash
-
name: Import credentials
run: n8n/packages/cli/bin/n8n import:credentials --input=test-workflows/credentials.json
shell: bash
env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
-
name: Import workflows
run: n8n/packages/cli/bin/n8n import:workflow --separate --input=test-workflows/workflows
shell: bash
env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
-
name: Copy static assets
run: |
cp n8n/assets/n8n-logo.png /tmp/n8n-logo.png
cp n8n/assets/n8n-screenshot.png /tmp/n8n-screenshot.png
cp n8n/node_modules/pdf-parse/test/data/05-versions-space.pdf /tmp/05-versions-space.pdf
cp n8n/node_modules/pdf-parse/test/data/04-valid.pdf /tmp/04-valid.pdf
shell: bash
-
name: Run tests
run: n8n/packages/cli/bin/n8n executeBatch --shallow --skipList=test-workflows/skipList.txt --shortOutput --concurrency=16 --compare=test-workflows/snapshots
shell: bash
env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
-
name: Export credentials
if: always()
run: n8n/packages/cli/bin/n8n export:credentials --output=test-workflows/credentials.json --all --pretty
shell: bash
env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
-
name: Commit and push credential changes
if: always()
run: |
cd test-workflows
git config --global user.name 'n8n test bot'
git config --global user.email 'n8n-test-bot@users.noreply.github.com'
git commit -am "Automated credential update"
git push --force --quiet "https://janober:${{ secrets.TOKEN }}@github.com/n8n-io/test-workflows.git" main:main

View file

@ -7,7 +7,7 @@
"build": "lerna exec npm run build", "build": "lerna exec npm run build",
"dev": "lerna exec npm run dev --parallel", "dev": "lerna exec npm run dev --parallel",
"clean:dist": "lerna exec -- rimraf ./dist", "clean:dist": "lerna exec -- rimraf ./dist",
"optimize-svg": "find ./packages -name '*.svg' -print0 | xargs -0 -P16 -L20 npx svgo", "optimize-svg": "find ./packages -name '*.svg' ! -name 'pipedrive.svg' -print0 | xargs -0 -P16 -L20 npx svgo",
"start": "run-script-os", "start": "run-script-os",
"start:default": "cd packages/cli/bin && ./n8n", "start:default": "cd packages/cli/bin && ./n8n",
"start:windows": "cd packages/cli/bin && n8n", "start:windows": "cd packages/cli/bin && n8n",

View file

@ -2,6 +2,26 @@
This list shows all the versions which include breaking changes and how to upgrade. This list shows all the versions which include breaking changes and how to upgrade.
## 0.130.0
### What changed?
For the Taiga regular and trigger nodes, the server and cloud credentials types are now unified into a single credentials type and the `version` param has been removed. Also, the `issue:create` operation now automatically loads the tags as `multiOptions`.
### When is action necessary?
If you are using the Taiga nodes, reconnect the credentials. If you are using tags in the `issue:create` operation, reselect them.
## 0.127.0
### What changed?
For the Zoho node, the `lead:create` operation now requires a "Company" parameter, the parameter "Address" is now inside "Additional Options", and the parameters "Title" and "Is Duplicate Record" were removed. Also, the `lead:delete` operation now returns only the `id` of the deleted lead.
### When is action necessary?
If you are using `lead:create` with "Company" or "Address", reset the parameters; for the other two parameters, no action needed. If you are using the response from `lead:delete`, reselect the `id` key.
## 0.118.0 ## 0.118.0
### What changed? ### What changed?

54
packages/cli/commands/Interfaces.d.ts vendored Normal file
View file

@ -0,0 +1,54 @@
interface IResult {
totalWorkflows: number;
summary: {
failedExecutions: number,
successfulExecutions: number,
warningExecutions: number,
errors: IExecutionError[],
warnings: IExecutionError[],
};
coveredNodes: {
[nodeType: string]: number
};
executions: IExecutionResult[];
}
interface IExecutionResult {
workflowId: string | number;
workflowName: string;
executionTime: number; // Given in seconds with decimals for milisseconds
finished: boolean;
executionStatus: ExecutionStatus;
error?: string;
changes?: string;
coveredNodes: {
[nodeType: string]: number
};
}
interface IExecutionError {
workflowId: string | number;
error: string;
}
interface IWorkflowExecutionProgress {
workflowId: string | number;
status: ExecutionStatus;
}
interface INodeSpecialCases {
[nodeName: string]: INodeSpecialCase;
}
interface INodeSpecialCase {
ignoredProperties?: string[];
capResults?: number;
}
type ExecutionStatus = 'success' | 'error' | 'warning' | 'running';
declare module 'json-diff' {
interface IDiffOptions {
keysOnly?: boolean;
}
export function diff(obj1: unknown, obj2: unknown, diffOptions: IDiffOptions): string;
}

View file

@ -46,6 +46,9 @@ export class Execute extends Command {
id: flags.string({ id: flags.string({
description: 'id of the workflow to execute', description: 'id of the workflow to execute',
}), }),
rawOutput: flags.boolean({
description: 'Outputs only JSON data, with no other text',
}),
}; };
@ -183,10 +186,11 @@ export class Execute extends Command {
stack: error.stack, stack: error.stack,
}; };
} }
if (flags.rawOutput === undefined) {
console.info('Execution was successful:'); this.log('Execution was successful:');
console.info('===================================='); this.log('====================================');
console.info(JSON.stringify(data, null, 2)); }
this.log(JSON.stringify(data, null, 2));
} catch (e) { } catch (e) {
console.error('Error executing workflow. See log messages for details.'); console.error('Error executing workflow. See log messages for details.');
logger.error('\nExecution error:'); logger.error('\nExecution error:');

View file

@ -0,0 +1,796 @@
import * as fs from 'fs';
import {
Command,
flags,
} from '@oclif/command';
import {
UserSettings,
} from 'n8n-core';
import {
INode,
INodeExecutionData,
ITaskData,
} from 'n8n-workflow';
import {
ActiveExecutions,
CredentialsOverwrites,
CredentialTypes,
Db,
ExternalHooks,
IExecutionsCurrentSummary,
IWorkflowDb,
IWorkflowExecutionDataProcess,
LoadNodesAndCredentials,
NodeTypes,
WorkflowCredentials,
WorkflowRunner,
} from '../src';
import {
sep,
} from 'path';
import {
diff,
} from 'json-diff';
import {
getLogger,
} from '../src/Logger';
import {
LoggerProxy,
} from 'n8n-workflow';
export class ExecuteBatch extends Command {
static description = '\nExecutes multiple workflows once';
static cancelled = false;
static workflowExecutionsProgress: IWorkflowExecutionProgress[][];
static shallow = false;
static compare: string;
static snapshot: string;
static concurrency = 1;
static debug = false;
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`,
];
static flags = {
help: flags.help({ char: 'h' }),
debug: flags.boolean({
description: 'Toggles on displaying all errors and debug messages.',
}),
ids: flags.string({
description: 'Specifies workflow IDs to get executed, separated by a comma.',
}),
concurrency: flags.integer({
default: 1,
description: 'How many workflows can run in parallel. Defaults to 1 which means no concurrency.',
}),
output: flags.string({
description: 'Enable execution saving, You must inform an existing folder to save execution via this param',
}),
snapshot: flags.string({
description: 'Enables snapshot saving. You must inform an existing folder to save snapshots via this param.',
}),
compare: flags.string({
description: 'Compares current execution with an existing snapshot. You must inform an existing folder where the snapshots are saved.',
}),
shallow: flags.boolean({
description: 'Compares only if attributes output from node are the same, with no regards to neste JSON objects.',
}),
skipList: flags.string({
description: 'File containing a comma separated list of workflow IDs to skip.',
}),
retries: flags.integer({
description: 'Retries failed workflows up to N tries. Default is 1. Set 0 to disable.',
default: 1,
}),
shortOutput: flags.boolean({
description: 'Omits the full execution information from output, displaying only summary.',
}),
};
/**
* Gracefully handles exit.
* @param {boolean} skipExit Whether to skip exit or number according to received signal
*/
static async stopProcess(skipExit: boolean | number = false) {
if (ExecuteBatch.cancelled === true) {
process.exit(0);
}
ExecuteBatch.cancelled = true;
const activeExecutionsInstance = ActiveExecutions.getInstance();
const stopPromises = activeExecutionsInstance.getActiveExecutions().map(async execution => {
activeExecutionsInstance.stopExecution(execution.id);
});
await Promise.allSettled(stopPromises);
setTimeout(() => {
process.exit(0);
}, 30000);
let executingWorkflows = activeExecutionsInstance.getActiveExecutions() as IExecutionsCurrentSummary[];
let count = 0;
while (executingWorkflows.length !== 0) {
if (count++ % 4 === 0) {
console.log(`Waiting for ${executingWorkflows.length} active executions to finish...`);
executingWorkflows.map(execution => {
console.log(` - Execution ID ${execution.id}, workflow ID: ${execution.workflowId}`);
});
}
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
executingWorkflows = activeExecutionsInstance.getActiveExecutions();
}
// We may receive true but when called from `process.on`
// we get the signal (SIGNIT, etc.)
if (skipExit !== true) {
process.exit(0);
}
}
formatJsonOutput(data: object) {
return JSON.stringify(data, null, 2);
}
shouldBeConsideredAsWarning(errorMessage: string) {
const warningStrings = [
'refresh token is invalid',
'unable to connect to',
'econnreset',
'429',
'econnrefused',
'missing a required parameter',
];
errorMessage = errorMessage.toLowerCase();
for (let i = 0; i < warningStrings.length; i++) {
if (errorMessage.includes(warningStrings[i])) {
return true;
}
}
return false;
}
async run() {
process.on('SIGTERM', ExecuteBatch.stopProcess);
process.on('SIGINT', ExecuteBatch.stopProcess);
const logger = getLogger();
LoggerProxy.init(logger);
const { flags } = this.parse(ExecuteBatch);
ExecuteBatch.debug = flags.debug === true;
ExecuteBatch.concurrency = flags.concurrency || 1;
const ids: number[] = [];
const skipIds: number[] = [];
if (flags.snapshot !== undefined) {
if (fs.existsSync(flags.snapshot)) {
if (!fs.lstatSync(flags.snapshot).isDirectory()) {
console.log(`The parameter --snapshot must be an existing directory`);
return;
}
} else {
console.log(`The parameter --snapshot must be an existing directory`);
return;
}
ExecuteBatch.snapshot = flags.snapshot;
}
if (flags.compare !== undefined) {
if (fs.existsSync(flags.compare)) {
if (!fs.lstatSync(flags.compare).isDirectory()) {
console.log(`The parameter --compare must be an existing directory`);
return;
}
} else {
console.log(`The parameter --compare must be an existing directory`);
return;
}
ExecuteBatch.compare = flags.compare;
}
if (flags.output !== undefined) {
if (fs.existsSync(flags.output)) {
if (fs.lstatSync(flags.output).isDirectory()) {
console.log(`The parameter --output must be a writable file`);
return;
}
}
}
if (flags.ids !== undefined) {
const paramIds = flags.ids.split(',');
const re = /\d+/;
const matchedIds = paramIds.filter(id => id.match(re)).map(id => parseInt(id.trim(), 10));
if (matchedIds.length === 0) {
console.log(`The parameter --ids must be a list of numeric IDs separated by a comma.`);
return;
}
ids.push(...matchedIds);
}
if (flags.skipList !== undefined) {
if (fs.existsSync(flags.skipList)) {
const contents = fs.readFileSync(flags.skipList, { encoding: 'utf-8' });
skipIds.push(...contents.split(',').map(id => parseInt(id.trim(), 10)));
} else {
console.log('Skip list file not found. Exiting.');
return;
}
}
if (flags.shallow === true) {
ExecuteBatch.shallow = true;
}
// Start directly with the init of the database to improve startup time
const startDbInitPromise = Db.init();
// Load all node and credential types
const loadNodesAndCredentials = LoadNodesAndCredentials();
const loadNodesAndCredentialsPromise = loadNodesAndCredentials.init();
// Make sure the settings exist
await UserSettings.prepareUserSettings();
// Wait till the database is ready
await startDbInitPromise;
let allWorkflows;
const query = Db.collections!.Workflow!.createQueryBuilder('workflows');
if (ids.length > 0) {
query.andWhere(`workflows.id in (:...ids)`, { ids });
}
if (skipIds.length > 0) {
query.andWhere(`workflows.id not in (:...skipIds)`, { skipIds });
}
allWorkflows = await query.getMany() as IWorkflowDb[];
if (ExecuteBatch.debug === true) {
process.stdout.write(`Found ${allWorkflows.length} workflows to execute.\n`);
}
// Wait till the n8n-packages have been read
await loadNodesAndCredentialsPromise;
// Load the credentials overwrites if any exist
await CredentialsOverwrites().init();
// Load all external hooks
const externalHooks = ExternalHooks();
await externalHooks.init();
// Add the found types to an instance other parts of the application can use
const nodeTypes = NodeTypes();
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);
const credentialTypes = CredentialTypes();
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
// Send a shallow copy of allWorkflows so we still have all workflow data.
const results = await this.runTests([...allWorkflows]);
let { retries } = flags;
while (retries > 0 && (results.summary.warningExecutions + results.summary.failedExecutions > 0) && ExecuteBatch.cancelled === false) {
const failedWorkflowIds = results.summary.errors.map(execution => execution.workflowId);
failedWorkflowIds.push(...results.summary.warnings.map(execution => execution.workflowId));
const newWorkflowList = allWorkflows.filter(workflow => failedWorkflowIds.includes(workflow.id));
const retryResults = await this.runTests(newWorkflowList);
this.mergeResults(results, retryResults);
// By now, `results` has been updated with the new successful executions.
retries--;
}
if (flags.output !== undefined) {
fs.writeFileSync(flags.output, this.formatJsonOutput(results));
console.log('\nExecution finished.');
console.log('Summary:');
console.log(`\tSuccess: ${results.summary.successfulExecutions}`);
console.log(`\tFailures: ${results.summary.failedExecutions}`);
console.log(`\tWarnings: ${results.summary.warningExecutions}`);
console.log('\nNodes successfully tested:');
Object.entries(results.coveredNodes).forEach(([nodeName, nodeCount]) => {
console.log(`\t${nodeName}: ${nodeCount}`);
});
console.log('\nCheck the JSON file for more details.');
} else {
if (flags.shortOutput === true) {
console.log(this.formatJsonOutput({ ...results, executions: results.executions.filter(execution => execution.executionStatus !== 'success') }));
} else {
console.log(this.formatJsonOutput(results));
}
}
await ExecuteBatch.stopProcess(true);
if (results.summary.failedExecutions > 0) {
this.exit(1);
}
this.exit(0);
}
mergeResults(results: IResult, retryResults: IResult) {
if (retryResults.summary.successfulExecutions === 0) {
// Nothing to replace.
return;
}
// Find successful executions and replace them on previous result.
retryResults.executions.forEach(newExecution => {
if (newExecution.executionStatus === 'success') {
// Remove previous execution from list.
results.executions = results.executions.filter(previousExecutions => previousExecutions.workflowId !== newExecution.workflowId);
const errorIndex = results.summary.errors.findIndex(summaryInformation => summaryInformation.workflowId === newExecution.workflowId);
if (errorIndex !== -1) {
// This workflow errored previously. Decrement error count.
results.summary.failedExecutions--;
// Remove from the list of errors.
results.summary.errors.splice(errorIndex, 1);
}
const warningIndex = results.summary.warnings.findIndex(summaryInformation => summaryInformation.workflowId === newExecution.workflowId);
if (warningIndex !== -1) {
// This workflow errored previously. Decrement error count.
results.summary.warningExecutions--;
// Remove from the list of errors.
results.summary.warnings.splice(warningIndex, 1);
}
// Increment successful executions count and push it to all executions array.
results.summary.successfulExecutions++;
results.executions.push(newExecution);
}
});
}
async runTests(allWorkflows: IWorkflowDb[]): Promise<IResult> {
const result: IResult = {
totalWorkflows: allWorkflows.length,
summary: {
failedExecutions: 0,
warningExecutions: 0,
successfulExecutions: 0,
errors: [],
warnings: [],
},
coveredNodes: {},
executions: [],
};
if (ExecuteBatch.debug) {
this.initializeLogs();
}
return new Promise(async (res) => {
const promisesArray = [];
for (let i = 0; i < ExecuteBatch.concurrency; i++) {
const promise = new Promise(async (resolve) => {
let workflow: IWorkflowDb | undefined;
while (allWorkflows.length > 0) {
workflow = allWorkflows.shift();
if (ExecuteBatch.cancelled === true) {
process.stdout.write(`Thread ${i + 1} resolving and quitting.`);
resolve(true);
break;
}
// This if shouldn't be really needed
// but it's a concurrency precaution.
if (workflow === undefined) {
resolve(true);
return;
}
if (ExecuteBatch.debug) {
ExecuteBatch.workflowExecutionsProgress[i].push({
workflowId: workflow.id,
status: 'running',
});
this.updateStatus();
}
await this.startThread(workflow).then((executionResult) => {
if (ExecuteBatch.debug) {
ExecuteBatch.workflowExecutionsProgress[i].pop();
}
result.executions.push(executionResult);
if (executionResult.executionStatus === 'success') {
if (ExecuteBatch.debug) {
ExecuteBatch.workflowExecutionsProgress[i].push({
workflowId: workflow!.id,
status: 'success',
});
this.updateStatus();
}
result.summary.successfulExecutions++;
const nodeNames = Object.keys(executionResult.coveredNodes);
nodeNames.map(nodeName => {
if (result.coveredNodes[nodeName] === undefined) {
result.coveredNodes[nodeName] = 0;
}
result.coveredNodes[nodeName] += executionResult.coveredNodes[nodeName];
});
} else if (executionResult.executionStatus === 'warning') {
result.summary.warningExecutions++;
result.summary.warnings.push({
workflowId: executionResult.workflowId,
error: executionResult.error!,
});
if (ExecuteBatch.debug) {
ExecuteBatch.workflowExecutionsProgress[i].push({
workflowId: workflow!.id,
status: 'warning',
});
this.updateStatus();
}
} else if (executionResult.executionStatus === 'error') {
result.summary.failedExecutions++;
result.summary.errors.push({
workflowId: executionResult.workflowId,
error: executionResult.error!,
});
if (ExecuteBatch.debug) {
ExecuteBatch.workflowExecutionsProgress[i].push({
workflowId: workflow!.id,
status: 'error',
});
this.updateStatus();
}
} else {
throw new Error('Wrong execution status - cannot proceed');
}
});
}
resolve(true);
});
promisesArray.push(promise);
}
await Promise.allSettled(promisesArray);
res(result);
});
}
updateStatus() {
if (ExecuteBatch.cancelled === true) {
return;
}
if (process.stdout.isTTY === true) {
process.stdout.moveCursor(0, - (ExecuteBatch.concurrency));
process.stdout.cursorTo(0);
process.stdout.clearLine(0);
}
ExecuteBatch.workflowExecutionsProgress.map((concurrentThread, index) => {
let message = `${index + 1}: `;
concurrentThread.map((executionItem, workflowIndex) => {
let openColor = '\x1b[0m';
const closeColor = '\x1b[0m';
switch (executionItem.status) {
case 'success':
openColor = '\x1b[32m';
break;
case 'error':
openColor = '\x1b[31m';
break;
case 'warning':
openColor = '\x1b[33m';
break;
default:
break;
}
message += (workflowIndex > 0 ? ', ' : '') + `${openColor}${executionItem.workflowId}${closeColor}`;
});
if (process.stdout.isTTY === true) {
process.stdout.cursorTo(0);
process.stdout.clearLine(0);
}
process.stdout.write(message + '\n');
});
}
initializeLogs() {
process.stdout.write('**********************************************\n');
process.stdout.write(' n8n test workflows\n');
process.stdout.write('**********************************************\n');
process.stdout.write('\n');
process.stdout.write('Batch number:\n');
ExecuteBatch.workflowExecutionsProgress = [];
for (let i = 0; i < ExecuteBatch.concurrency; i++) {
ExecuteBatch.workflowExecutionsProgress.push([]);
process.stdout.write(`${i + 1}: \n`);
}
}
startThread(workflowData: IWorkflowDb): Promise<IExecutionResult> {
// This will be the object returned by the promise.
// It will be updated according to execution progress below.
const executionResult: IExecutionResult = {
workflowId: workflowData.id,
workflowName: workflowData.name,
executionTime: 0,
finished: false,
executionStatus: 'running',
coveredNodes: {},
};
const requiredNodeTypes = ['n8n-nodes-base.start'];
let startNode: INode | undefined = undefined;
for (const node of workflowData.nodes) {
if (requiredNodeTypes.includes(node.type)) {
startNode = node;
break;
}
}
// We have a cool feature here.
// On each node, on the Settings tab in the node editor you can change
// the `Notes` field to add special cases for comparison and snapshots.
// You need to set one configuration per line with the following possible keys:
// CAP_RESULTS_LENGTH=x where x is a number. Cap the number of rows from this node to x.
// This means if you set CAP_RESULTS_LENGTH=1 we will have only 1 row in the output
// IGNORED_PROPERTIES=x,y,z where x, y and z are JSON property names. Removes these
// properties from the JSON object (useful for optional properties that can
// cause the comparison to detect changes when not true).
const nodeEdgeCases = {} as INodeSpecialCases;
workflowData.nodes.forEach(node => {
executionResult.coveredNodes[node.type] = (executionResult.coveredNodes[node.type] || 0) + 1;
if (node.notes !== undefined && node.notes !== '') {
node.notes.split('\n').forEach(note => {
const parts = note.split('=');
if (parts.length === 2) {
if (nodeEdgeCases[node.name] === undefined) {
nodeEdgeCases[node.name] = {} as INodeSpecialCase;
}
if (parts[0] === 'CAP_RESULTS_LENGTH') {
nodeEdgeCases[node.name].capResults = parseInt(parts[1], 10);
} else if (parts[0] === 'IGNORED_PROPERTIES') {
nodeEdgeCases[node.name].ignoredProperties = parts[1].split(',').map(property => property.trim());
}
}
});
}
});
return new Promise(async (resolve) => {
if (startNode === undefined) {
// If the workflow does not contain a start-node we can not know what
// should be executed and with which data to start.
executionResult.error = 'Workflow cannot be started as it does not contain a "Start" node.';
executionResult.executionStatus = 'warning';
resolve(executionResult);
}
let gotCancel = false;
// Timeouts execution after 5 minutes.
const timeoutTimer = setTimeout(() => {
gotCancel = true;
executionResult.error = 'Workflow execution timed out.';
executionResult.executionStatus = 'warning';
resolve(executionResult);
}, ExecuteBatch.executionTimeout);
try {
const credentials = await WorkflowCredentials(workflowData!.nodes);
const runData: IWorkflowExecutionDataProcess = {
credentials,
executionMode: 'cli',
startNodes: [startNode!.name],
workflowData: workflowData!,
};
const workflowRunner = new WorkflowRunner();
const executionId = await workflowRunner.run(runData);
const activeExecutions = ActiveExecutions.getInstance();
const data = await activeExecutions.getPostExecutePromise(executionId);
if (gotCancel || ExecuteBatch.cancelled === true) {
clearTimeout(timeoutTimer);
// The promise was settled already so we simply ignore.
return;
}
if (data === undefined) {
executionResult.error = 'Workflow did not return any data.';
executionResult.executionStatus = 'error';
} else {
executionResult.executionTime = (Date.parse(data.stoppedAt as unknown as string) - Date.parse(data.startedAt as unknown as string)) / 1000;
executionResult.finished = (data?.finished !== undefined) as boolean;
if (data.data.resultData.error) {
executionResult.error =
data.data.resultData.error.hasOwnProperty('description') ?
// @ts-ignore
data.data.resultData.error.description : data.data.resultData.error.message;
if (data.data.resultData.lastNodeExecuted !== undefined) {
executionResult.error += ` on node ${data.data.resultData.lastNodeExecuted}`;
}
executionResult.executionStatus = 'error';
if (this.shouldBeConsideredAsWarning(executionResult.error || '')) {
executionResult.executionStatus = 'warning';
}
} else {
if (ExecuteBatch.shallow === true) {
// What this does is guarantee that top-level attributes
// from the JSON are kept and the are the same type.
// We convert nested JSON objects to a simple {object:true}
// and we convert nested arrays to ['json array']
// This reduces the chance of false positives but may
// result in not detecting deeper changes.
Object.keys(data.data.resultData.runData).map((nodeName: string) => {
data.data.resultData.runData[nodeName].map((taskData: ITaskData) => {
if (taskData.data === undefined) {
return;
}
Object.keys(taskData.data).map(connectionName => {
const connection = taskData.data![connectionName] as Array<INodeExecutionData[] | null>;
connection.map(executionDataArray => {
if (executionDataArray === null) {
return;
}
if (nodeEdgeCases[nodeName] !== undefined && nodeEdgeCases[nodeName].capResults !== undefined) {
executionDataArray.splice(nodeEdgeCases[nodeName].capResults!);
}
executionDataArray.map(executionData => {
if (executionData.json === undefined) {
return;
}
if (nodeEdgeCases[nodeName] !== undefined && nodeEdgeCases[nodeName].ignoredProperties !== undefined) {
nodeEdgeCases[nodeName].ignoredProperties!.forEach(ignoredProperty => delete executionData.json[ignoredProperty]);
}
const jsonProperties = executionData.json;
const nodeOutputAttributes = Object.keys(jsonProperties);
nodeOutputAttributes.map(attributeName => {
if (Array.isArray(jsonProperties[attributeName])) {
jsonProperties[attributeName] = ['json array'];
} else if (typeof jsonProperties[attributeName] === 'object') {
jsonProperties[attributeName] = { object: true };
}
});
});
});
});
});
});
} else {
// If not using shallow comparison then we only treat nodeEdgeCases.
const specialCases = Object.keys(nodeEdgeCases);
specialCases.forEach(nodeName => {
data.data.resultData.runData[nodeName].map((taskData: ITaskData) => {
if (taskData.data === undefined) {
return;
}
Object.keys(taskData.data).map(connectionName => {
const connection = taskData.data![connectionName] as Array<INodeExecutionData[] | null>;
connection.map(executionDataArray => {
if (executionDataArray === null) {
return;
}
if (nodeEdgeCases[nodeName].capResults !== undefined) {
executionDataArray.splice(nodeEdgeCases[nodeName].capResults!);
}
if (nodeEdgeCases[nodeName].ignoredProperties !== undefined) {
executionDataArray.map(executionData => {
if (executionData.json === undefined) {
return;
}
nodeEdgeCases[nodeName].ignoredProperties!.forEach(ignoredProperty => delete executionData.json[ignoredProperty]);
});
}
});
});
});
});
}
const serializedData = this.formatJsonOutput(data);
if (ExecuteBatch.compare === undefined) {
executionResult.executionStatus = 'success';
} else {
const fileName = (ExecuteBatch.compare.endsWith(sep) ? ExecuteBatch.compare : ExecuteBatch.compare + sep) + `${workflowData.id}-snapshot.json`;
if (fs.existsSync(fileName) === true) {
const contents = fs.readFileSync(fileName, { encoding: 'utf-8' });
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';
} else {
executionResult.executionStatus = 'success';
}
} else {
executionResult.error = 'Snapshot for not found.';
executionResult.executionStatus = 'warning';
}
}
// Save snapshots only after comparing - this is to make sure we're updating
// After comparing to existing verion.
if (ExecuteBatch.snapshot !== undefined) {
const fileName = (ExecuteBatch.snapshot.endsWith(sep) ? ExecuteBatch.snapshot : ExecuteBatch.snapshot + sep) + `${workflowData.id}-snapshot.json`;
fs.writeFileSync(fileName, serializedData);
}
}
}
} catch (e) {
executionResult.error = 'Workflow failed to execute.';
executionResult.executionStatus = 'error';
}
clearTimeout(timeoutTimer);
resolve(executionResult);
});
}
}

View file

@ -65,6 +65,9 @@ export class ImportCredentialsCommand extends Command {
try { try {
await Db.init(); await Db.init();
// Make sure the settings exist
await UserSettings.prepareUserSettings();
let i; let i;
const encryptionKey = await UserSettings.getEncryptionKey(); const encryptionKey = await UserSettings.getEncryptionKey();

View file

@ -18,6 +18,9 @@ import {
import * as fs from 'fs'; import * as fs from 'fs';
import * as glob from 'glob-promise'; import * as glob from 'glob-promise';
import * as path from 'path'; import * as path from 'path';
import {
UserSettings,
} from 'n8n-core';
export class ImportWorkflowsCommand extends Command { export class ImportWorkflowsCommand extends Command {
static description = 'Import workflows'; static description = 'Import workflows';
@ -60,6 +63,9 @@ export class ImportWorkflowsCommand extends Command {
try { try {
await Db.init(); await Db.init();
// Make sure the settings exist
await UserSettings.prepareUserSettings();
let i; let i;
if (flags.separate) { if (flags.separate) {
const files = await glob((flags.input.endsWith(path.sep) ? flags.input : flags.input + path.sep) + '*.json'); const files = await glob((flags.input.endsWith(path.sep) ? flags.input : flags.input + path.sep) + '*.json');

View file

@ -0,0 +1,67 @@
import {
Command,
flags,
} from '@oclif/command';
import {
IDataObject
} from 'n8n-workflow';
import {
Db,
} from "../../src";
export class ListWorkflowCommand extends Command {
static description = '\nList workflows';
static examples = [
'$ n8n list:workflow',
'$ n8n list:workflow --active=true --onlyId',
'$ n8n list:workflow --active=false',
];
static flags = {
help: flags.help({ char: 'h' }),
active: flags.string({
description: 'Filters workflows by active status. Can be true or false',
}),
onlyId: flags.boolean({
description: 'Outputs workflow IDs only, one per line.',
}),
};
async run() {
const { flags } = this.parse(ListWorkflowCommand);
if (flags.active !== undefined && !['true', 'false'].includes(flags.active)) {
this.error('The --active flag has to be passed using true or false');
}
try {
await Db.init();
const findQuery: IDataObject = {};
if (flags.active !== undefined) {
findQuery.active = flags.active === 'true';
}
const workflows = await Db.collections.Workflow!.find(findQuery);
if (flags.onlyId) {
workflows.forEach(workflow => console.log(workflow.id));
} else {
workflows.forEach(workflow => console.log(workflow.id + "|" + workflow.name));
}
} catch (e) {
console.error('\nGOT ERROR');
console.log('====================================');
console.error(e.message);
console.error(e.stack);
this.exit(1);
}
this.exit();
}
}

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "0.126.0", "version": "0.129.0",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -82,6 +82,7 @@
"dependencies": { "dependencies": {
"@oclif/command": "^1.5.18", "@oclif/command": "^1.5.18",
"@oclif/errors": "^1.2.2", "@oclif/errors": "^1.2.2",
"@types/json-diff": "^0.5.1",
"@types/jsonwebtoken": "^8.5.2", "@types/jsonwebtoken": "^8.5.2",
"basic-auth": "^2.0.1", "basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@ -101,15 +102,16 @@
"glob-promise": "^3.4.0", "glob-promise": "^3.4.0",
"google-timezones-json": "^1.0.2", "google-timezones-json": "^1.0.2",
"inquirer": "^7.0.1", "inquirer": "^7.0.1",
"json-diff": "^0.5.4",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"jwks-rsa": "~1.12.1", "jwks-rsa": "~1.12.1",
"localtunnel": "^2.0.0", "localtunnel": "^2.0.0",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"mysql2": "~2.2.0", "mysql2": "~2.2.0",
"n8n-core": "~0.75.0", "n8n-core": "~0.77.0",
"n8n-editor-ui": "~0.96.0", "n8n-editor-ui": "~0.98.0",
"n8n-nodes-base": "~0.123.0", "n8n-nodes-base": "~0.126.0",
"n8n-workflow": "~0.62.0", "n8n-workflow": "~0.63.0",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"open": "^7.0.0", "open": "^7.0.0",
"pg": "^8.3.0", "pg": "^8.3.0",

View file

@ -188,6 +188,7 @@ export interface IExecutionsListResponse {
count: number; count: number;
// results: IExecutionShortResponse[]; // results: IExecutionShortResponse[];
results: IExecutionsSummary[]; results: IExecutionsSummary[];
estimated: boolean;
} }
export interface IExecutionsStopData { export interface IExecutionsStopData {

View file

@ -33,6 +33,7 @@ import {
CredentialsHelper, CredentialsHelper,
CredentialsOverwrites, CredentialsOverwrites,
CredentialTypes, CredentialTypes,
DatabaseType,
Db, Db,
ExternalHooks, ExternalHooks,
GenericHelpers, GenericHelpers,
@ -88,6 +89,7 @@ import {
IRunData, IRunData,
IWorkflowBase, IWorkflowBase,
IWorkflowCredentials, IWorkflowCredentials,
LoggerProxy,
Workflow, Workflow,
WorkflowExecuteMode, WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -1612,8 +1614,7 @@ class App {
executingWorkflowIds.push(...this.activeExecutionsInstance.getActiveExecutions().map(execution => execution.id.toString()) as string[]); executingWorkflowIds.push(...this.activeExecutionsInstance.getActiveExecutions().map(execution => execution.id.toString()) as string[]);
const countFilter = JSON.parse(JSON.stringify(filter)); const countFilter = JSON.parse(JSON.stringify(filter));
countFilter.select = ['id']; countFilter.id = Not(In(executingWorkflowIds));
countFilter.where = {id: Not(In(executingWorkflowIds))};
const resultsQuery = await Db.collections.Execution! const resultsQuery = await Db.collections.Execution!
.createQueryBuilder("execution") .createQueryBuilder("execution")
@ -1645,10 +1646,10 @@ class App {
const resultsPromise = resultsQuery.getMany(); const resultsPromise = resultsQuery.getMany();
const countPromise = Db.collections.Execution!.count(countFilter); const countPromise = getExecutionsCount(countFilter);
const results: IExecutionFlattedDb[] = await resultsPromise; const results: IExecutionFlattedDb[] = await resultsPromise;
const count = await countPromise; const countedObjects = await countPromise;
const returnResults: IExecutionsSummary[] = []; const returnResults: IExecutionsSummary[] = [];
@ -1667,8 +1668,9 @@ class App {
} }
return { return {
count, count: countedObjects.count,
results: returnResults, results: returnResults,
estimated: countedObjects.estimate,
}; };
})); }));
@ -2161,3 +2163,35 @@ export async function start(): Promise<void> {
await app.externalHooks.run('n8n.ready', [app]); await app.externalHooks.run('n8n.ready', [app]);
}); });
} }
async function getExecutionsCount(countFilter: IDataObject): Promise<{ count: number; estimate: boolean; }> {
const dbType = await GenericHelpers.getConfigValue('database.type') as DatabaseType;
const filteredFields = Object.keys(countFilter).filter(field => field !== 'id');
// Do regular count for other databases than pgsql and
// if we are filtering based on workflowId or finished fields.
if (dbType !== 'postgresdb' || filteredFields.length > 0) {
const count = await Db.collections.Execution!.count(countFilter);
return { count, estimate: false };
}
try {
// Get an estimate of rows count.
const estimateRowsNumberSql = "SELECT n_live_tup FROM pg_stat_all_tables WHERE relname = 'execution_entity';";
const rows: Array<{ n_live_tup: string }> = await Db.collections.Execution!.query(estimateRowsNumberSql);
const estimate = parseInt(rows[0].n_live_tup, 10);
// If over 100k, return just an estimate.
if (estimate > 100000) {
// if less than 100k, we get the real count as even a full
// table scan should not take so long.
return { count: estimate, estimate: true };
}
} catch (err) {
LoggerProxy.warn('Unable to get executions count from postgres: ' + err);
}
const count = await Db.collections.Execution!.count(countFilter);
return { count, estimate: false };
}

View file

@ -608,13 +608,12 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi
executionId = parentExecutionId !== undefined ? parentExecutionId : await ActiveExecutions.getInstance().add(runData); executionId = parentExecutionId !== undefined ? parentExecutionId : await ActiveExecutions.getInstance().add(runData);
} }
const runExecutionData = runData.executionData as IRunExecutionData; let data;
try {
// Get the needed credentials for the current workflow as they will differ to the ones of the // Get the needed credentials for the current workflow as they will differ to the ones of the
// calling workflow. // calling workflow.
const credentials = await WorkflowCredentials(workflowData!.nodes); const credentials = await WorkflowCredentials(workflowData!.nodes);
// Create new additionalData to have different workflow loaded and to call // Create new additionalData to have different workflow loaded and to call
// different webooks // different webooks
const additionalDataIntegrated = await getBase(credentials); const additionalDataIntegrated = await getBase(credentials);
@ -634,6 +633,7 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi
additionalDataIntegrated.executionTimeoutTimestamp = subworkflowTimeout; additionalDataIntegrated.executionTimeoutTimestamp = subworkflowTimeout;
const runExecutionData = runData.executionData as IRunExecutionData;
// Execute the workflow // Execute the workflow
const workflowExecute = new WorkflowExecute(additionalDataIntegrated, runData.executionMode, runExecutionData); const workflowExecute = new WorkflowExecute(additionalDataIntegrated, runData.executionMode, runExecutionData);
@ -645,7 +645,41 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi
workflowExecute, workflowExecute,
}; };
} }
const data = await workflowExecute.processRunExecutionData(workflow); data = await workflowExecute.processRunExecutionData(workflow);
} catch (error) {
const fullRunData: IRun = {
data: {
resultData: {
error,
runData: {},
},
},
finished: false,
mode: 'integrated',
startedAt: new Date(),
stoppedAt: new Date(),
};
// When failing, we might not have finished the execution
// Therefore, database might not contain finished errors.
// Force an update to db as there should be no harm doing this
const fullExecutionData: IExecutionDb = {
data: fullRunData.data,
mode: fullRunData.mode,
finished: fullRunData.finished ? fullRunData.finished : false,
startedAt: fullRunData.startedAt,
stoppedAt: fullRunData.stoppedAt,
workflowData,
};
const executionData = ResponseHelper.flattenExecutionData(fullExecutionData);
await Db.collections.Execution!.update(executionId, executionData as IExecutionFlattedDb);
throw {
...error,
stack: error!.stack,
};
}
await externalHooks.run('workflow.postExecute', [data, workflowData]); await externalHooks.run('workflow.postExecute', [data, workflowData]);

View file

@ -8,6 +8,7 @@ import {
IBullJobResponse, IBullJobResponse,
ICredentialsOverwrite, ICredentialsOverwrite,
ICredentialsTypeData, ICredentialsTypeData,
IExecutionDb,
IExecutionFlattedDb, IExecutionFlattedDb,
IExecutionResponse, IExecutionResponse,
IProcessMessageDataHook, IProcessMessageDataHook,
@ -29,6 +30,7 @@ import {
import { import {
ExecutionError, ExecutionError,
IRun, IRun,
IWorkflowBase,
LoggerProxy as Logger, LoggerProxy as Logger,
Workflow, Workflow,
WorkflowExecuteMode, WorkflowExecuteMode,
@ -85,11 +87,15 @@ export class WorkflowRunner {
* @param {string} executionId * @param {string} executionId
* @memberof WorkflowRunner * @memberof WorkflowRunner
*/ */
processError(error: ExecutionError, startedAt: Date, executionMode: WorkflowExecuteMode, executionId: string) { async processError(error: ExecutionError, startedAt: Date, executionMode: WorkflowExecuteMode, executionId: string, hooks?: WorkflowHooks) {
const fullRunData: IRun = { const fullRunData: IRun = {
data: { data: {
resultData: { resultData: {
error, error: {
...error,
message: error.message,
stack: error.stack,
},
runData: {}, runData: {},
}, },
}, },
@ -102,6 +108,10 @@ export class WorkflowRunner {
// Remove from active execution with empty data. That will // Remove from active execution with empty data. That will
// set the execution to failed. // set the execution to failed.
this.activeExecutions.remove(executionId, fullRunData); this.activeExecutions.remove(executionId, fullRunData);
if (hooks) {
await hooks.executeHookFunctions('workflowExecuteAfter', [fullRunData]);
}
} }
/** /**
@ -159,7 +169,6 @@ export class WorkflowRunner {
const nodeTypes = NodeTypes(); const nodeTypes = NodeTypes();
// Soft timeout to stop workflow execution after current running node // Soft timeout to stop workflow execution after current running node
// Changes were made by adding the `workflowTimeout` to the `additionalData` // Changes were made by adding the `workflowTimeout` to the `additionalData`
// So that the timeout will also work for executions with nested workflows. // So that the timeout will also work for executions with nested workflows.
@ -178,13 +187,13 @@ export class WorkflowRunner {
// Register the active execution // Register the active execution
const executionId = await this.activeExecutions.add(data, undefined); const executionId = await this.activeExecutions.add(data, undefined);
Logger.verbose(`Execution for workflow ${data.workflowData.name} was assigned id ${executionId}`, {executionId}); let workflowExecution: PCancelable<IRun>;
try {
Logger.verbose(`Execution for workflow ${data.workflowData.name} was assigned id ${executionId}`, { executionId });
additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId, true); additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId, true);
additionalData.sendMessageToUI = WorkflowExecuteAdditionalData.sendMessageToUI.bind({sessionId: data.sessionId}); additionalData.sendMessageToUI = WorkflowExecuteAdditionalData.sendMessageToUI.bind({sessionId: data.sessionId});
let workflowExecution: PCancelable<IRun>;
if (data.executionData !== undefined) { if (data.executionData !== undefined) {
Logger.debug(`Execution ID ${executionId} had Execution data. Running with payload.`, {executionId}); Logger.debug(`Execution ID ${executionId} had Execution data. Running with payload.`, {executionId});
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode, data.executionData); const workflowExecute = new WorkflowExecute(additionalData, data.executionMode, data.executionData);
@ -218,8 +227,16 @@ export class WorkflowRunner {
fullRunData.finished = false; fullRunData.finished = false;
} }
this.activeExecutions.remove(executionId, fullRunData); this.activeExecutions.remove(executionId, fullRunData);
}).catch((error) => {
this.processError(error, new Date(), data.executionMode, executionId, additionalData.hooks);
}); });
} catch (error) {
await this.processError(error, new Date(), data.executionMode, executionId, additionalData.hooks);
throw error;
}
return executionId; return executionId;
} }
@ -247,39 +264,48 @@ export class WorkflowRunner {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
}; };
const job = await this.jobQueue.add(jobData, jobOptions); let job: Bull.Job;
let hooks: WorkflowHooks;
try {
job = await this.jobQueue.add(jobData, jobOptions);
console.log('Started with ID: ' + job.id.toString()); console.log('Started with ID: ' + job.id.toString());
const hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerMain(data.executionMode, executionId, data.workflowData, { retryOf: data.retryOf ? data.retryOf.toString() : undefined }); hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerMain(data.executionMode, executionId, data.workflowData, { retryOf: data.retryOf ? data.retryOf.toString() : undefined });
// Normally also workflow should be supplied here but as it only used for sending // Normally also workflow should be supplied here but as it only used for sending
// data to editor-UI is not needed. // data to editor-UI is not needed.
hooks.executeHookFunctions('workflowExecuteBefore', []); hooks.executeHookFunctions('workflowExecuteBefore', []);
} catch (error) {
// We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
// "workflowExecuteAfter" which we require.
const hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter(data.executionMode, executionId, data.workflowData, { retryOf: data.retryOf ? data.retryOf.toString() : undefined });
await this.processError(error, new Date(), data.executionMode, executionId, hooks);
throw error;
}
const workflowExecution: PCancelable<IRun> = new PCancelable(async (resolve, reject, onCancel) => { const workflowExecution: PCancelable<IRun> = new PCancelable(async (resolve, reject, onCancel) => {
onCancel.shouldReject = false; onCancel.shouldReject = false;
onCancel(async () => { onCancel(async () => {
await Queue.getInstance().stopJob(job); await Queue.getInstance().stopJob(job);
const fullRunData :IRun = { // We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
data: { // "workflowExecuteAfter" which we require.
resultData: { const hooksWorker = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter(data.executionMode, executionId, data.workflowData, { retryOf: data.retryOf ? data.retryOf.toString() : undefined });
error: new WorkflowOperationError('Workflow has been canceled!'),
runData: {}, const error = new WorkflowOperationError('Workflow-Execution has been canceled!');
}, await this.processError(error, new Date(), data.executionMode, executionId, hooksWorker);
},
mode: data.executionMode, reject(error);
startedAt: new Date(),
stoppedAt: new Date(),
};
this.activeExecutions.remove(executionId, fullRunData);
resolve(fullRunData);
}); });
const jobData: Promise<IBullJobResponse> = job.finished(); const jobData: Promise<IBullJobResponse> = job.finished();
const queueRecoveryInterval = config.get('queue.bull.queueRecoveryInterval') as number; const queueRecoveryInterval = config.get('queue.bull.queueRecoveryInterval') as number;
const racingPromises: Array<Promise<IBullJobResponse | object>> = [jobData];
let clearWatchdogInterval;
if (queueRecoveryInterval > 0) { if (queueRecoveryInterval > 0) {
/************************************************* /*************************************************
* Long explanation about what this solves: * * Long explanation about what this solves: *
@ -295,7 +321,7 @@ export class WorkflowRunner {
*************************************************/ *************************************************/
let watchDogInterval: NodeJS.Timeout | undefined; let watchDogInterval: NodeJS.Timeout | undefined;
const watchDog = new Promise((res) => { const watchDog: Promise<object> = new Promise((res) => {
watchDogInterval = setInterval(async () => { watchDogInterval = setInterval(async () => {
const currentJob = await this.jobQueue.getJob(job.id); const currentJob = await this.jobQueue.getJob(job.id);
// When null means job is finished (not found in queue) // When null means job is finished (not found in queue)
@ -306,22 +332,33 @@ export class WorkflowRunner {
}, queueRecoveryInterval * 1000); }, queueRecoveryInterval * 1000);
}); });
racingPromises.push(watchDog);
const clearWatchdogInterval = () => { clearWatchdogInterval = () => {
if (watchDogInterval) { if (watchDogInterval) {
clearInterval(watchDogInterval); clearInterval(watchDogInterval);
watchDogInterval = undefined; watchDogInterval = undefined;
} }
}; };
await Promise.race([jobData, watchDog]);
clearWatchdogInterval();
} else {
await jobData;
} }
try {
await Promise.race(racingPromises);
if (clearWatchdogInterval !== undefined) {
clearWatchdogInterval();
}
} catch (error) {
// We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
// "workflowExecuteAfter" which we require.
const hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter(data.executionMode, executionId, data.workflowData, { retryOf: data.retryOf ? data.retryOf.toString() : undefined });
Logger.error(`Problem with execution ${executionId}: ${error.message}. Aborting.`);
if (clearWatchdogInterval !== undefined) {
clearWatchdogInterval();
}
await this.processError(error, new Date(), data.executionMode, executionId, hooks);
reject(error);
}
const executionDb = await Db.collections.Execution!.findOne(executionId) as IExecutionFlattedDb; const executionDb = await Db.collections.Execution!.findOne(executionId) as IExecutionFlattedDb;
const fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb) as IExecutionResponse; const fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb) as IExecutionResponse;
@ -333,7 +370,6 @@ export class WorkflowRunner {
stoppedAt: fullExecutionData.stoppedAt, stoppedAt: fullExecutionData.stoppedAt,
} as IRun; } as IRun;
this.activeExecutions.remove(executionId, runData); this.activeExecutions.remove(executionId, runData);
// Normally also static data should be supplied here but as it only used for sending // Normally also static data should be supplied here but as it only used for sending
// data to editor-UI is not needed. // data to editor-UI is not needed.
@ -427,8 +463,13 @@ export class WorkflowRunner {
const workflowHooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId); const workflowHooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId);
try {
// Send all data to subprocess it needs to run the workflow // Send all data to subprocess it needs to run the workflow
subprocess.send({ type: 'startWorkflow', data } as IProcessMessage); subprocess.send({ type: 'startWorkflow', data } as IProcessMessage);
} catch (error) {
await this.processError(error, new Date(), data.executionMode, executionId, workflowHooks);
return executionId;
}
// Start timeout for the execution // Start timeout for the execution
let executionTimeout: NodeJS.Timeout; let executionTimeout: NodeJS.Timeout;
@ -476,14 +517,14 @@ export class WorkflowRunner {
} else if (message.type === 'processError') { } else if (message.type === 'processError') {
clearTimeout(executionTimeout); clearTimeout(executionTimeout);
const executionError = message.data.executionError as ExecutionError; const executionError = message.data.executionError as ExecutionError;
this.processError(executionError, startedAt, data.executionMode, executionId); await this.processError(executionError, startedAt, data.executionMode, executionId, workflowHooks);
} else if (message.type === 'processHook') { } else if (message.type === 'processHook') {
this.processHookMessage(workflowHooks, message.data as IProcessMessageDataHook); this.processHookMessage(workflowHooks, message.data as IProcessMessageDataHook);
} else if (message.type === 'timeout') { } else if (message.type === 'timeout') {
// Execution timed out and its process has been terminated // Execution timed out and its process has been terminated
const timeoutError = new WorkflowOperationError('Workflow execution timed out!'); const timeoutError = new WorkflowOperationError('Workflow execution timed out!');
// No need to add hook here as the subprocess takes care of calling the hooks
this.processError(timeoutError, startedAt, data.executionMode, executionId); this.processError(timeoutError, startedAt, data.executionMode, executionId);
} else if (message.type === 'startExecution') { } else if (message.type === 'startExecution') {
const executionId = await this.activeExecutions.add(message.data.runData); const executionId = await this.activeExecutions.add(message.data.runData);
@ -506,13 +547,13 @@ export class WorkflowRunner {
// Execution timed out and its process has been terminated // Execution timed out and its process has been terminated
const timeoutError = new WorkflowOperationError('Workflow execution timed out!'); const timeoutError = new WorkflowOperationError('Workflow execution timed out!');
this.processError(timeoutError, startedAt, data.executionMode, executionId); await this.processError(timeoutError, startedAt, data.executionMode, executionId, workflowHooks);
} else if (code !== 0) { } else if (code !== 0) {
Logger.debug(`Subprocess for execution ID ${executionId} finished with error code ${code}.`, {executionId}); Logger.debug(`Subprocess for execution ID ${executionId} finished with error code ${code}.`, {executionId});
// Process did exit with error code, so something went wrong. // Process did exit with error code, so something went wrong.
const executionError = new WorkflowOperationError('Workflow execution process did crash for an unknown reason!'); const executionError = new WorkflowOperationError('Workflow execution process did crash for an unknown reason!');
this.processError(executionError, startedAt, data.executionMode, executionId); await this.processError(executionError, startedAt, data.executionMode, executionId, workflowHooks);
} }
for(const executionId of childExecutionIds) { for(const executionId of childExecutionIds) {

View file

@ -30,6 +30,7 @@ import {
IWorkflowExecuteHooks, IWorkflowExecuteHooks,
LoggerProxy, LoggerProxy,
Workflow, Workflow,
WorkflowExecuteMode,
WorkflowHooks, WorkflowHooks,
WorkflowOperationError, WorkflowOperationError,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -315,7 +316,7 @@ process.on('message', async (message: IProcessMessage) => {
for (const executionId of executionIds) { for (const executionId of executionIds) {
const childWorkflowExecute = workflowRunner.childExecutions[executionId]; const childWorkflowExecute = workflowRunner.childExecutions[executionId];
runData = childWorkflowExecute.workflowExecute.getFullRunData(workflowRunner.childExecutions[executionId].startedAt); runData = childWorkflowExecute.workflowExecute.getFullRunData(workflowRunner.childExecutions[executionId].startedAt);
const timeOutError = message.type === 'timeout' ? new WorkflowOperationError('Workflow execution timed out!') : undefined; const timeOutError = message.type === 'timeout' ? new WorkflowOperationError('Workflow execution timed out!') : new WorkflowOperationError('Workflow-Execution has been canceled!');
// If there is any data send it to parent process, if execution timedout add the error // If there is any data send it to parent process, if execution timedout add the error
await childWorkflowExecute.workflowExecute.processSuccessExecution(workflowRunner.childExecutions[executionId].startedAt, childWorkflowExecute.workflow, timeOutError); await childWorkflowExecute.workflowExecute.processSuccessExecution(workflowRunner.childExecutions[executionId].startedAt, childWorkflowExecute.workflow, timeOutError);
@ -324,7 +325,7 @@ process.on('message', async (message: IProcessMessage) => {
// Workflow started already executing // Workflow started already executing
runData = workflowRunner.workflowExecute.getFullRunData(workflowRunner.startedAt); runData = workflowRunner.workflowExecute.getFullRunData(workflowRunner.startedAt);
const timeOutError = message.type === 'timeout' ? new WorkflowOperationError('Workflow execution timed out!') : undefined; const timeOutError = message.type === 'timeout' ? new WorkflowOperationError('Workflow execution timed out!') : new WorkflowOperationError('Workflow-Execution has been canceled!');
// If there is any data send it to parent process, if execution timedout add the error // If there is any data send it to parent process, if execution timedout add the error
await workflowRunner.workflowExecute.processSuccessExecution(workflowRunner.startedAt, workflowRunner.workflow!, timeOutError); await workflowRunner.workflowExecute.processSuccessExecution(workflowRunner.startedAt, workflowRunner.workflow!, timeOutError);
@ -336,8 +337,8 @@ process.on('message', async (message: IProcessMessage) => {
runData: {}, runData: {},
}, },
}, },
finished: message.type !== 'timeout', finished: false,
mode: workflowRunner.data!.executionMode, mode: workflowRunner.data ? workflowRunner.data!.executionMode : 'own' as WorkflowExecuteMode,
startedAt: workflowRunner.startedAt, startedAt: workflowRunner.startedAt,
stoppedAt: new Date(), stoppedAt: new Date(),
}; };

View file

@ -1,7 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [
"es2017" "es2017",
"ES2020.Promise"
], ],
"types": [ "types": [
"node", "node",

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-core", "name": "n8n-core",
"version": "0.75.0", "version": "0.77.0",
"description": "Core functionality of n8n", "description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -47,7 +47,7 @@
"file-type": "^14.6.2", "file-type": "^14.6.2",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"mime-types": "^2.1.27", "mime-types": "^2.1.27",
"n8n-workflow": "~0.62.0", "n8n-workflow": "~0.63.0",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0", "p-cancelable": "^2.0.0",
"request": "^2.88.2", "request": "^2.88.2",

View file

@ -807,7 +807,7 @@ export class WorkflowExecute {
})() })()
.then(async () => { .then(async () => {
if (gotCancel && executionError === undefined) { if (gotCancel && executionError === undefined) {
return this.processSuccessExecution(startedAt, workflow, new WorkflowOperationError('Workflow has been canceled!')); return this.processSuccessExecution(startedAt, workflow, new WorkflowOperationError('Workflow has been canceled or timed out!'));
} }
return this.processSuccessExecution(startedAt, workflow, executionError); return this.processSuccessExecution(startedAt, workflow, executionError);
}) })
@ -844,7 +844,11 @@ export class WorkflowExecute {
if (executionError !== undefined) { if (executionError !== undefined) {
Logger.verbose(`Workflow execution finished with error`, { error: executionError, workflowId: workflow.id }); Logger.verbose(`Workflow execution finished with error`, { error: executionError, workflowId: workflow.id });
fullRunData.data.resultData.error = executionError; fullRunData.data.resultData.error = {
...executionError,
message: executionError.message,
stack: executionError.stack,
} as ExecutionError;
} else { } else {
Logger.verbose(`Workflow execution finished successfully`, { workflowId: workflow.id }); Logger.verbose(`Workflow execution finished successfully`, { workflowId: workflow.id });
fullRunData.finished = true; fullRunData.finished = true;

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "0.96.0", "version": "0.98.0",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -68,7 +68,7 @@
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"lodash.set": "^4.3.2", "lodash.set": "^4.3.2",
"n8n-workflow": "~0.62.0", "n8n-workflow": "~0.63.0",
"node-sass": "^4.12.0", "node-sass": "^4.12.0",
"normalize-wheel": "^1.0.1", "normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1", "prismjs": "^1.17.1",

View file

@ -202,7 +202,7 @@ export interface IVariableSelectorOption {
// Simple version of n8n-workflow.Workflow // Simple version of n8n-workflow.Workflow
export interface IWorkflowData { export interface IWorkflowData {
id?: string; id?: string | number;
name?: string; name?: string;
active?: boolean; active?: boolean;
nodes: INode[]; nodes: INode[];
@ -212,7 +212,7 @@ export interface IWorkflowData {
} }
export interface IWorkflowDataUpdate { export interface IWorkflowDataUpdate {
id?: string; id?: string | number;
name?: string; name?: string;
nodes?: INode[]; nodes?: INode[];
connections?: IConnections; connections?: IConnections;
@ -325,6 +325,7 @@ export interface IExecutionShortResponse {
export interface IExecutionsListResponse { export interface IExecutionsListResponse {
count: number; count: number;
results: IExecutionsSummary[]; results: IExecutionsSummary[];
estimated: boolean;
} }
export interface IExecutionsCurrentSummaryExtended { export interface IExecutionsCurrentSummaryExtended {

View file

@ -14,6 +14,10 @@
<div v-if="!binaryData"> <div v-if="!binaryData">
Data to display did not get found Data to display did not get found
</div> </div>
<video v-else-if="binaryData.mimeType && binaryData.mimeType.startsWith('video/')" controls autoplay>
<source :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" :type="binaryData.mimeType">
Your browser does not support the video element. Kindly update it to latest version.
</video>
<embed v-else :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" class="binary-data" :class="embedClass"/> <embed v-else :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" class="binary-data" :class="embedClass"/>
</div> </div>

View file

@ -1,6 +1,6 @@
<template> <template>
<span> <span>
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`Workflow Executions (${combinedExecutions.length}/${combinedExecutionsCount})`" :before-close="closeDialog"> <el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`Workflow Executions ${combinedExecutions.length}/${finishedExecutionsCountEstimated === true ? '~' : ''}${combinedExecutionsCount}`" :before-close="closeDialog">
<div class="filters"> <div class="filters">
<el-row> <el-row>
<el-col :span="4" class="filter-headline"> <el-col :span="4" class="filter-headline">
@ -38,7 +38,7 @@
<div class="selection-options"> <div class="selection-options">
<span v-if="checkAll === true || isIndeterminate === true"> <span v-if="checkAll === true || isIndeterminate === true">
Selected: {{numSelected}}/{{finishedExecutionsCount}} Selected: {{numSelected}} / <span v-if="finishedExecutionsCountEstimated === true">~</span>{{finishedExecutionsCount}}
<el-button type="danger" title="Delete Selected" icon="el-icon-delete" size="mini" @click="handleDeleteSelected" circle></el-button> <el-button type="danger" title="Delete Selected" icon="el-icon-delete" size="mini" @click="handleDeleteSelected" circle></el-button>
</span> </span>
</div> </div>
@ -142,7 +142,7 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="load-more" v-if="finishedExecutionsCount > finishedExecutions.length"> <div class="load-more" v-if="finishedExecutionsCount > finishedExecutions.length || finishedExecutionsCountEstimated === true">
<el-button title="Load More" @click="loadMore()" size="small" :disabled="isDataLoading"> <el-button title="Load More" @click="loadMore()" size="small" :disabled="isDataLoading">
<font-awesome-icon icon="sync" /> Load More <font-awesome-icon icon="sync" /> Load More
</el-button> </el-button>
@ -200,6 +200,7 @@ export default mixins(
return { return {
finishedExecutions: [] as IExecutionsSummary[], finishedExecutions: [] as IExecutionsSummary[],
finishedExecutionsCount: 0, finishedExecutionsCount: 0,
finishedExecutionsCountEstimated: false,
checkAll: false, checkAll: false,
autoRefresh: true, autoRefresh: true,
@ -256,7 +257,7 @@ export default mixins(
return returnData; return returnData;
}, },
combinedExecutionsCount (): number { combinedExecutionsCount (): number {
return this.activeExecutions.length + this.finishedExecutionsCount; return 0 + this.activeExecutions.length + this.finishedExecutionsCount;
}, },
numSelected (): number { numSelected (): number {
if (this.checkAll === true) { if (this.checkAll === true) {
@ -489,16 +490,19 @@ export default mixins(
} }
this.finishedExecutions = this.finishedExecutions.filter(execution => !gaps.includes(parseInt(execution.id, 10)) && lastId >= parseInt(execution.id, 10)); this.finishedExecutions = this.finishedExecutions.filter(execution => !gaps.includes(parseInt(execution.id, 10)) && lastId >= parseInt(execution.id, 10));
this.finishedExecutionsCount = results[0].count; this.finishedExecutionsCount = results[0].count;
this.finishedExecutionsCountEstimated = results[0].estimated;
}, },
async loadFinishedExecutions (): Promise<void> { async loadFinishedExecutions (): Promise<void> {
if (this.filter.status === 'running') { if (this.filter.status === 'running') {
this.finishedExecutions = []; this.finishedExecutions = [];
this.finishedExecutionsCount = 0; this.finishedExecutionsCount = 0;
this.finishedExecutionsCountEstimated = false;
return; return;
} }
const data = await this.restApi().getPastExecutions(this.workflowFilterPast, this.requestItemsPerRequest); const data = await this.restApi().getPastExecutions(this.workflowFilterPast, this.requestItemsPerRequest);
this.finishedExecutions = data.results; this.finishedExecutions = data.results;
this.finishedExecutionsCount = data.count; this.finishedExecutionsCount = data.count;
this.finishedExecutionsCountEstimated = data.estimated;
}, },
async loadMore () { async loadMore () {
if (this.filter.status === 'running') { if (this.filter.status === 'running') {
@ -526,6 +530,7 @@ export default mixins(
this.finishedExecutions.push.apply(this.finishedExecutions, data.results); this.finishedExecutions.push.apply(this.finishedExecutions, data.results);
this.finishedExecutionsCount = data.count; this.finishedExecutionsCount = data.count;
this.finishedExecutionsCountEstimated = data.estimated;
this.isDataLoading = false; this.isDataLoading = false;
}, },

View file

@ -10,7 +10,7 @@
@keydown.esc="onEscape" @keydown.esc="onEscape"
ref="input" ref="input"
size="4" size="4"
v-click-outside="onBlur" v-click-outside="onClickOutside"
/> />
</ExpandableInputBase> </ExpandableInputBase>
</template> </template>
@ -47,8 +47,10 @@ export default Vue.extend({
onEnter() { onEnter() {
this.$emit('enter', (this.$refs.input as HTMLInputElement).value); this.$emit('enter', (this.$refs.input as HTMLInputElement).value);
}, },
onBlur() { onClickOutside(e: Event) {
if (e.type === 'click') {
this.$emit('blur', (this.$refs.input as HTMLInputElement).value); this.$emit('blur', (this.$refs.input as HTMLInputElement).value);
}
}, },
onEscape() { onEscape() {
this.$emit('esc'); this.$emit('esc');

View file

@ -408,6 +408,9 @@ export default mixins(
const workflowData = await this.getWorkflowDataToSave(); const workflowData = await this.getWorkflowDataToSave();
const {tags, ...data} = workflowData; const {tags, ...data} = workflowData;
if (data.id && typeof data.id === 'string') {
data.id = parseInt(data.id, 10);
}
const blob = new Blob([JSON.stringify(data, null, 2)], { const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8', type: 'application/json;charset=utf-8',
}); });

View file

@ -8,7 +8,7 @@
@before-leave="beforeLeave" @before-leave="beforeLeave"
@leave="leave" @leave="leave"
> >
<div v-for="(item, index) in elements" :key="item.key" :class="item.type"> <div v-for="(item, index) in elements" :key="item.key" :class="item.type" :data-key="item.key">
<CreatorItem <CreatorItem
:item="item" :item="item"
:active="activeIndex === index && !disabled" :active="activeIndex === index && !disabled"

View file

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<SlideTransition> <SlideTransition>
<div class="node-creator" v-if="active" v-click-outside="closeCreator"> <div class="node-creator" v-if="active" v-click-outside="onClickOutside">
<MainPanel @nodeTypeSelected="nodeTypeSelected" :categorizedItems="categorizedItems" :categoriesWithNodes="categoriesWithNodes" :searchItems="searchItems"></MainPanel> <MainPanel @nodeTypeSelected="nodeTypeSelected" :categorizedItems="categorizedItems" :categoriesWithNodes="categoriesWithNodes" :searchItems="searchItems"></MainPanel>
</div> </div>
</SlideTransition> </SlideTransition>
@ -29,9 +29,17 @@ export default Vue.extend({
props: [ props: [
'active', 'active',
], ],
data() {
return {
allNodeTypes: [],
};
},
computed: { computed: {
nodeTypes(): INodeTypeDescription[] {
return this.$store.getters.allNodeTypes;
},
visibleNodeTypes(): INodeTypeDescription[] { visibleNodeTypes(): INodeTypeDescription[] {
return this.$store.getters.allNodeTypes return this.allNodeTypes
.filter((nodeType: INodeTypeDescription) => { .filter((nodeType: INodeTypeDescription) => {
return !HIDDEN_NODES.includes(nodeType.name); return !HIDDEN_NODES.includes(nodeType.name);
}); });
@ -64,13 +72,22 @@ export default Vue.extend({
}, },
}, },
methods: { methods: {
closeCreator () { onClickOutside (e: Event) {
if (e.type === 'click') {
this.$emit('closeNodeCreator'); this.$emit('closeNodeCreator');
}
}, },
nodeTypeSelected (nodeTypeName: string) { nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName); this.$emit('nodeTypeSelected', nodeTypeName);
}, },
}, },
watch: {
nodeTypes(newList, prevList) {
if (prevList.length === 0) {
this.allNodeTypes = newList;
}
},
},
}); });
</script> </script>

View file

@ -1,5 +1,5 @@
<template> <template>
<div :class="{'tags-container': true, focused}" @keydown.stop v-click-outside="onBlur"> <div :class="{'tags-container': true, focused}" @keydown.stop v-click-outside="onClickOutside">
<el-select <el-select
:popperAppendToBody="false" :popperAppendToBody="false"
:value="appliedTags" :value="appliedTags"
@ -215,8 +215,10 @@ export default mixins(showMessage).extend({
this.focusOnInput(); this.focusOnInput();
}); });
}, },
onBlur() { onClickOutside(e: Event) {
if (e.type === 'click') {
this.$emit('blur'); this.$emit('blur');
}
}, },
}, },
watch: { watch: {

View file

@ -32,7 +32,7 @@
<template slot-scope="scope"> <template slot-scope="scope">
<div :key="scope.row.id"> <div :key="scope.row.id">
<span class="name">{{scope.row.name}}</span> <span class="name">{{scope.row.name}}</span>
<TagsContainer class="hidden-sm-and-down" :tagIds="getIds(scope.row.tags)" :limit="3" /> <TagsContainer class="hidden-sm-and-down" :tagIds="getIds(scope.row.tags)" :limit="3" @click="onTagClick" :clickable="true" :hoverable="true" />
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@ -124,6 +124,11 @@ export default mixins(
updateTagsFilter(tags: string[]) { updateTagsFilter(tags: string[]) {
this.filterTagIds = tags; this.filterTagIds = tags;
}, },
onTagClick(tagId: string) {
if (tagId !== 'count' && !this.filterTagIds.includes(tagId)) {
this.filterTagIds.push(tagId);
}
},
async openWorkflow (data: IWorkflowShortResponse, column: any) { // tslint:disable-line:no-any async openWorkflow (data: IWorkflowShortResponse, column: any) { // tslint:disable-line:no-any
if (column.label !== 'Active') { if (column.label !== 'Active') {

View file

@ -213,33 +213,16 @@ export const pushConnection = mixins(
const runDataExecuted = pushData.data; const runDataExecuted = pushData.data;
let runDataExecutedErrorMessage; const runDataExecutedErrorMessage = this.$getExecutionError(runDataExecuted.data.resultData.error);
// @ts-ignore // @ts-ignore
const workflow = this.getWorkflow(); const workflow = this.getWorkflow();
if (runDataExecuted.finished !== true) { if (runDataExecuted.finished !== true) {
// There was a problem with executing the workflow
let errorMessage = 'There was a problem executing the workflow!';
if (runDataExecuted.data.resultData.error && runDataExecuted.data.resultData.error.message) {
let nodeName: string | undefined;
if (runDataExecuted.data.resultData.error.node) {
nodeName = typeof runDataExecuted.data.resultData.error.node === 'string'
? runDataExecuted.data.resultData.error.node
: runDataExecuted.data.resultData.error.node.name;
}
const receivedError = nodeName
? `${nodeName}: ${runDataExecuted.data.resultData.error.message}`
: runDataExecuted.data.resultData.error.message;
errorMessage = `There was a problem executing the workflow:<br /><strong>"${receivedError}"</strong>`;
}
runDataExecutedErrorMessage = errorMessage;
this.$titleSet(workflow.name as string, 'ERROR'); this.$titleSet(workflow.name as string, 'ERROR');
this.$showMessage({ this.$showMessage({
title: 'Problem executing workflow', title: 'Problem executing workflow',
message: errorMessage, message: runDataExecutedErrorMessage,
type: 'error', type: 'error',
}); });
} else { } else {

View file

@ -3,6 +3,7 @@ import { ElNotificationOptions } from 'element-ui/types/notification';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
import { ExecutionError } from 'n8n-workflow';
export const showMessage = mixins(externalHooks).extend({ export const showMessage = mixins(externalHooks).extend({
methods: { methods: {
@ -15,6 +16,27 @@ export const showMessage = mixins(externalHooks).extend({
return Notification(messageData); return Notification(messageData);
}, },
$getExecutionError(error?: ExecutionError) {
// There was a problem with executing the workflow
let errorMessage = 'There was a problem executing the workflow!';
if (error && error.message) {
let nodeName: string | undefined;
if (error.node) {
nodeName = typeof error.node === 'string'
? error.node
: error.node.name;
}
const receivedError = nodeName
? `${nodeName}: ${error.message}`
: error.message;
errorMessage = `There was a problem executing the workflow:<br /><strong>"${receivedError}"</strong>`;
}
return errorMessage;
},
$showError(error: Error, title: string, message?: string) { $showError(error: Error, title: string, message?: string) {
const messageLine = message ? `${message}<br/>` : ''; const messageLine = message ? `${message}<br/>` : '';
this.$showMessage({ this.$showMessage({

View file

@ -31,9 +31,6 @@ const module: Module<IUiState, IRootState> = {
isModalActive: (state: IUiState) => { isModalActive: (state: IUiState) => {
return (name: string) => state.modalStack.length > 0 && name === state.modalStack[0]; return (name: string) => state.modalStack.length > 0 && name === state.modalStack[0];
}, },
anyModalsOpen: (state: IUiState) => {
return state.modalStack.length > 0;
},
sidebarMenuCollapsed: (state: IUiState): boolean => state.sidebarMenuCollapsed, sidebarMenuCollapsed: (state: IUiState): boolean => state.sidebarMenuCollapsed,
}, },
mutations: { mutations: {

View file

@ -375,6 +375,39 @@ export default mixins(
}); });
this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId }); this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId });
if (data.finished !== true && data.data.resultData.error) {
// Check if any node contains an error
let nodeErrorFound = false;
if (data.data.resultData.runData) {
const runData = data.data.resultData.runData;
errorCheck:
for (const nodeName of Object.keys(runData)) {
for (const taskData of runData[nodeName]) {
if (taskData.error) {
nodeErrorFound = true;
break errorCheck;
}
}
}
}
if (nodeErrorFound === false) {
const errorMessage = this.$getExecutionError(data.data.resultData.error);
this.$showMessage({
title: 'Failed execution',
message: errorMessage,
type: 'error',
});
if (data.data.resultData.error.stack) {
// Display some more information for now in console to make debugging easier
// TODO: Improve this in the future by displaying in UI
console.error(`Execution ${executionId} error:`); // eslint-disable-line no-console
console.error(data.data.resultData.error.stack); // eslint-disable-line no-console
}
}
}
}, },
async openWorkflowTemplate (templateId: string) { async openWorkflowTemplate (templateId: string) {
this.setLoadingText('Loading template'); this.setLoadingText('Loading template');
@ -514,15 +547,29 @@ export default mixins(
// else which should ignore the default keybindings // else which should ignore the default keybindings
for (let index = 0; index < path.length; index++) { for (let index = 0; index < path.length; index++) {
if (path[index].className && typeof path[index].className === 'string' && ( if (path[index].className && typeof path[index].className === 'string' && (
path[index].className.includes('el-message-box') ||
path[index].className.includes('el-select') ||
path[index].className.includes('ignore-key-press') path[index].className.includes('ignore-key-press')
)) { )) {
return; return;
} }
} }
const anyModalsOpen = this.$store.getters['ui/anyModalsOpen'];
if (anyModalsOpen) { // el-dialog or el-message-box element is open
if (window.document.body.classList.contains('el-popup-parent--hidden')) {
return;
}
if (e.key === 'Escape') {
this.createNodeActive = false;
if (this.activeNode) {
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.$store.commit('setActiveNode', null);
}
return;
}
// node modal is open
if (this.activeNode) {
return; return;
} }
@ -533,15 +580,12 @@ export default mixins(
e.preventDefault(); e.preventDefault();
this.callDebounced('deleteSelectedNodes', 500); this.callDebounced('deleteSelectedNodes', 500);
} else if (e.key === 'Escape') {
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.createNodeActive = false;
this.$store.commit('setActiveNode', null);
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
this.createNodeActive = !this.createNodeActive && !this.isReadOnly; this.createNodeActive = !this.createNodeActive && !this.isReadOnly;
} else if (e.key === this.controlKeyCode) { } else if (e.key === this.controlKeyCode) {
this.ctrlKeyPressed = true; this.ctrlKeyPressed = true;
} else if (e.key === 'F2') { } else if (e.key === 'F2' && !this.isReadOnly) {
const lastSelectedNode = this.lastSelectedNode; const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode !== null) { if (lastSelectedNode !== null) {
this.callDebounced('renameNodePrompt', 1500, lastSelectedNode.name); this.callDebounced('renameNodePrompt', 1500, lastSelectedNode.name);

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-node-dev", "name": "n8n-node-dev",
"version": "0.15.0", "version": "0.17.0",
"description": "CLI to simplify n8n credentials/node development", "description": "CLI to simplify n8n credentials/node development",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -59,8 +59,8 @@
"change-case": "^4.1.1", "change-case": "^4.1.1",
"copyfiles": "^2.1.1", "copyfiles": "^2.1.1",
"inquirer": "^7.0.1", "inquirer": "^7.0.1",
"n8n-core": "~0.75.0", "n8n-core": "~0.77.0",
"n8n-workflow": "~0.62.0", "n8n-workflow": "~0.63.0",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"replace-in-file": "^6.0.0", "replace-in-file": "^6.0.0",
"request": "^2.88.2", "request": "^2.88.2",

View file

@ -3,11 +3,18 @@ import {
INodeProperties, INodeProperties,
} from 'n8n-workflow'; } from 'n8n-workflow';
export class TaigaServerApi implements ICredentialType { // https://api.baserow.io/api/redoc/#section/Authentication
name = 'taigaServerApi';
displayName = 'Taiga Server API'; export class BaserowApi implements ICredentialType {
documentationUrl = 'taiga'; name = 'baserowApi';
displayName = 'Baserow API';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{
displayName: 'Host',
name: 'host',
type: 'string',
default: 'https://api.baserow.io',
},
{ {
displayName: 'Username', displayName: 'Username',
name: 'username', name: 'username',
@ -19,13 +26,9 @@ export class TaigaServerApi implements ICredentialType {
name: 'password', name: 'password',
type: 'string', type: 'string',
default: '', default: '',
typeOptions: {
password: true,
}, },
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
placeholder: 'https://taiga.yourdomain.com',
}, },
]; ];
} }

View file

@ -0,0 +1,36 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class HomeAssistantApi implements ICredentialType {
name = 'homeAssistantApi';
displayName = 'Home Assistant API';
documentationUrl = 'homeAssistant';
properties = [
{
displayName: 'Host',
name: 'host',
type: 'string' as NodePropertyTypes,
default: '',
},
{
displayName: 'Port',
name: 'port',
type: 'number' as NodePropertyTypes,
default: 8123,
},
{
displayName: 'SSL',
name: 'ssl',
type: 'boolean' as NodePropertyTypes,
default: false,
},
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -57,5 +57,12 @@ export class MicrosoftSql implements ICredentialType {
default: 15000, default: 15000,
description: 'Connection timeout in ms.', description: 'Connection timeout in ms.',
}, },
{
displayName: 'Request Timeout',
name: 'requestTimeout',
type: 'number',
default: 15000,
description: ' Request timeout in ms.',
},
]; ];
} }

View file

@ -11,19 +11,35 @@ export class SalesforceOAuth2Api implements ICredentialType {
displayName = 'Salesforce OAuth2 API'; displayName = 'Salesforce OAuth2 API';
documentationUrl = 'salesforce'; documentationUrl = 'salesforce';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{
displayName: 'Environment Type',
name: 'environment',
type: 'options',
options: [
{
name: 'Production',
value: 'production',
},
{
name: 'Sandbox',
value: 'sandbox',
},
],
default: 'production',
},
{ {
displayName: 'Authorization URL', displayName: 'Authorization URL',
name: 'authUrl', name: 'authUrl',
type: 'hidden', type: 'hidden',
default: 'https://login.salesforce.com/services/oauth2/authorize',
required: true, required: true,
default: '={{ $self["environment"] === "sandbox" ? "https://test.salesforce.com/services/oauth2/authorize" : "https://login.salesforce.com/services/oauth2/authorize" }}',
}, },
{ {
displayName: 'Access Token URL', displayName: 'Access Token URL',
name: 'accessTokenUrl', name: 'accessTokenUrl',
type: 'string', type: 'hidden',
default: 'https://yourcompany.salesforce.com/services/oauth2/token',
required: true, required: true,
default: '={{ $self["environment"] === "sandbox" ? "https://test.salesforce.com/services/oauth2/token" : "https://login.salesforce.com/services/oauth2/token" }}',
}, },
{ {
displayName: 'Scope', displayName: 'Scope',

View file

@ -0,0 +1,62 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class ServiceNowOAuth2Api implements ICredentialType {
name = 'serviceNowOAuth2Api';
extends = [
'oAuth2Api',
];
displayName = 'ServiceNow OAuth2 API';
documentationUrl = 'serviceNow';
properties = [
{
displayName: 'Subdomain',
name: 'subdomain',
type: 'string' as NodePropertyTypes,
default: '',
placeholder: 'n8n',
description: 'The subdomain of your ServiceNow environment',
required: true,
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden' as NodePropertyTypes,
default: '=https://{{$self["subdomain"]}}.service-now.com/oauth_auth.do',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden' as NodePropertyTypes,
default: '=https://{{$self["subdomain"]}}.service-now.com/oauth_token.do',
required: true,
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden' as NodePropertyTypes,
default: 'useraccount',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden' as NodePropertyTypes,
default: 'response_type=code',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden' as NodePropertyTypes,
default: 'grant_type=authorization_code',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden' as NodePropertyTypes,
default: 'header',
},
];
}

View file

@ -6,7 +6,7 @@ import {
export class StripeApi implements ICredentialType { export class StripeApi implements ICredentialType {
name = 'stripeApi'; name = 'stripeApi';
displayName = 'Stripe Api'; displayName = 'Stripe API';
documentationUrl = 'stripe'; documentationUrl = 'stripe';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
// The credentials to get from user and save encrypted. // The credentials to get from user and save encrypted.

View file

@ -0,0 +1,54 @@
import {
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class TaigaApi implements ICredentialType {
name = 'taigaApi';
displayName = 'Taiga API';
documentationUrl = 'taiga';
properties: INodeProperties[] = [
{
displayName: 'Username',
name: 'username',
type: 'string',
default: '',
},
{
displayName: 'Password',
name: 'password',
type: 'string',
default: '',
},
{
displayName: 'Environment',
name: 'environment',
type: 'options',
default: 'cloud',
options: [
{
name: 'Cloud',
value: 'cloud',
},
{
name: 'Self-Hosted',
value: 'selfHosted',
},
],
},
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
placeholder: 'https://taiga.yourdomain.com',
displayOptions: {
show: {
environment: [
'selfHosted',
],
},
},
},
];
}

View file

@ -1,24 +0,0 @@
import {
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class TaigaCloudApi implements ICredentialType {
name = 'taigaCloudApi';
displayName = 'Taiga Cloud API';
documentationUrl = 'taiga';
properties: INodeProperties[] = [
{
displayName: 'Username',
name: 'username',
type: 'string',
default: '',
},
{
displayName: 'Password',
name: 'password',
type: 'string',
default: '',
},
];
}

View file

@ -163,7 +163,7 @@ export function activeCampaignDefaultGetAllProperties(resource: string, operatio
description: 'How many results to return.', description: 'How many results to return.',
}, },
{ {
displayName: 'Simple', displayName: 'Simplify Response',
name: 'simple', name: 'simple',
type: 'boolean', type: 'boolean',
displayOptions: { displayOptions: {
@ -177,7 +177,7 @@ export function activeCampaignDefaultGetAllProperties(resource: string, operatio
}, },
}, },
default: true, default: true,
description: 'When set to true a simplify version of the response will be used else the raw data.', description: 'Return a simplified version of the response instead of the raw data.',
}, },
]; ];
} }

View file

@ -221,7 +221,7 @@ export class Airtable implements INodeType {
}, },
}, },
default: '', default: '',
description: `Name of the fields of type 'attachment' that should be downloaded. Multiple ones can be defined separated by comma. Case sensitive.`, description: `Name of the fields of type 'attachment' that should be downloaded. Multiple ones can be defined separated by comma. Case sensitive and cannot include spaces after a comma.`,
}, },
{ {
displayName: 'Additional Options', displayName: 'Additional Options',
@ -390,7 +390,7 @@ export class Airtable implements INodeType {
}, },
// ---------------------------------- // ----------------------------------
// append + update // append + delete + update
// ---------------------------------- // ----------------------------------
{ {
displayName: 'Options', displayName: 'Options',
@ -401,12 +401,24 @@ export class Airtable implements INodeType {
show: { show: {
operation: [ operation: [
'append', 'append',
'delete',
'update', 'update',
], ],
}, },
}, },
default: {}, default: {},
options: [ options: [
{
displayName: 'Bulk Size',
name: 'bulkSize',
type: 'number',
typeOptions: {
minValue: 1,
maxValue: 10,
},
default: 10,
description: `Number of records to process at once.`,
},
{ {
displayName: 'Ignore Fields', displayName: 'Ignore Fields',
name: 'ignoreFields', name: 'ignoreFields',
@ -428,6 +440,14 @@ export class Airtable implements INodeType {
displayName: 'Typecast', displayName: 'Typecast',
name: 'typecast', name: 'typecast',
type: 'boolean', type: 'boolean',
displayOptions: {
show: {
'/operation': [
'append',
'update',
],
},
},
default: false, default: false,
description: 'If the Airtable API should attempt mapping of string values for linked records & select options.', description: 'If the Airtable API should attempt mapping of string values for linked records & select options.',
}, },
@ -465,41 +485,65 @@ export class Airtable implements INodeType {
let fields: string[]; let fields: string[];
let options: IDataObject; let options: IDataObject;
const rows: IDataObject[] = [];
let bulkSize = 10;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
addAllFields = this.getNodeParameter('addAllFields', i) as boolean; addAllFields = this.getNodeParameter('addAllFields', i) as boolean;
options = this.getNodeParameter('options', i, {}) as IDataObject; options = this.getNodeParameter('options', i, {}) as IDataObject;
bulkSize = options.bulkSize as number || bulkSize;
const row: IDataObject = {};
if (addAllFields === true) { if (addAllFields === true) {
// Add all the fields the item has // Add all the fields the item has
body.fields = items[i].json; row.fields = { ...items[i].json };
// tslint:disable-next-line: no-any
delete (row.fields! as any).id;
} else { } else {
// Add only the specified fields // Add only the specified fields
body.fields = {} as IDataObject; row.fields = {} as IDataObject;
fields = this.getNodeParameter('fields', i, []) as string[]; fields = this.getNodeParameter('fields', i, []) as string[];
for (const fieldName of fields) { for (const fieldName of fields) {
// @ts-ignore // @ts-ignore
body.fields[fieldName] = items[i].json[fieldName]; row.fields[fieldName] = items[i].json[fieldName];
} }
} }
rows.push(row);
if (rows.length === bulkSize || i === items.length - 1) {
if (options.typecast === true) { if (options.typecast === true) {
body['typecast'] = true; body['typecast'] = true;
} }
body['records'] = rows;
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
returnData.push(responseData); returnData.push(...responseData.records);
// empty rows
rows.length = 0;
}
} }
} else if (operation === 'delete') { } else if (operation === 'delete') {
requestMethod = 'DELETE'; requestMethod = 'DELETE';
let id: string; const rows: string[] = [];
const options = this.getNodeParameter('options', 0, {}) as IDataObject;
const bulkSize = options.bulkSize as number || 10;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
let id: string;
id = this.getNodeParameter('id', i) as string; id = this.getNodeParameter('id', i) as string;
rows.push(id);
if (rows.length === bulkSize || i === items.length - 1) {
endpoint = `${application}/${table}`; endpoint = `${application}/${table}`;
// Make one request after another. This is slower but makes // Make one request after another. This is slower but makes
@ -508,11 +552,14 @@ export class Airtable implements INodeType {
// functionality in core should make it easy to make requests // functionality in core should make it easy to make requests
// according to specific rules like not more than 5 requests // according to specific rules like not more than 5 requests
// per seconds. // per seconds.
qs.records = [id]; qs.records = rows;
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
returnData.push(...responseData.records); returnData.push(...responseData.records);
// empty rows
rows.length = 0;
}
} }
} else if (operation === 'list') { } else if (operation === 'list') {
@ -585,41 +632,51 @@ export class Airtable implements INodeType {
requestMethod = 'PATCH'; requestMethod = 'PATCH';
let id: string;
let updateAllFields: boolean; let updateAllFields: boolean;
let fields: string[]; let fields: string[];
let options: IDataObject; let options: IDataObject;
const rows: IDataObject[] = [];
let bulkSize = 10;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
updateAllFields = this.getNodeParameter('updateAllFields', i) as boolean; updateAllFields = this.getNodeParameter('updateAllFields', i) as boolean;
options = this.getNodeParameter('options', i, {}) as IDataObject; options = this.getNodeParameter('options', i, {}) as IDataObject;
bulkSize = options.bulkSize as number || bulkSize;
const row: IDataObject = {};
row.fields = {} as IDataObject;
if (updateAllFields === true) { if (updateAllFields === true) {
// Update all the fields the item has // Update all the fields the item has
body.fields = items[i].json; row.fields = { ...items[i].json };
// remove id field
// tslint:disable-next-line: no-any
delete (row.fields! as any).id;
if (options.ignoreFields && options.ignoreFields !== '') { if (options.ignoreFields && options.ignoreFields !== '') {
const ignoreFields = (options.ignoreFields as string).split(',').map(field => field.trim()).filter(field => !!field); const ignoreFields = (options.ignoreFields as string).split(',').map(field => field.trim()).filter(field => !!field);
if (ignoreFields.length) { if (ignoreFields.length) {
// From: https://stackoverflow.com/questions/17781472/how-to-get-a-subset-of-a-javascript-objects-properties // From: https://stackoverflow.com/questions/17781472/how-to-get-a-subset-of-a-javascript-objects-properties
body.fields = Object.entries(items[i].json) row.fields = Object.entries(items[i].json)
.filter(([key]) => !ignoreFields.includes(key)) .filter(([key]) => !ignoreFields.includes(key))
.reduce((obj, [key, val]) => Object.assign(obj, { [key]: val }), {}); .reduce((obj, [key, val]) => Object.assign(obj, { [key]: val }), {});
} }
} }
} else { } else {
// Update only the specified fields
body.fields = {} as IDataObject;
fields = this.getNodeParameter('fields', i, []) as string[]; fields = this.getNodeParameter('fields', i, []) as string[];
for (const fieldName of fields) { for (const fieldName of fields) {
// @ts-ignore // @ts-ignore
body.fields[fieldName] = items[i].json[fieldName]; row.fields[fieldName] = items[i].json[fieldName];
} }
} }
id = this.getNodeParameter('id', i) as string; row.id = this.getNodeParameter('id', i) as string;
rows.push(row);
if (rows.length === bulkSize || i === items.length - 1) {
endpoint = `${application}/${table}`; endpoint = `${application}/${table}`;
// Make one request after another. This is slower but makes // Make one request after another. This is slower but makes
@ -629,11 +686,15 @@ export class Airtable implements INodeType {
// according to specific rules like not more than 5 requests // according to specific rules like not more than 5 requests
// per seconds. // per seconds.
const data = { records: [{ id, fields: body.fields }], typecast: (options.typecast) ? true : false }; const data = { records: rows, typecast: (options.typecast) ? true : false };
responseData = await apiRequest.call(this, requestMethod, endpoint, data, qs); responseData = await apiRequest.call(this, requestMethod, endpoint, data, qs);
returnData.push(...responseData.records); returnData.push(...responseData.records);
// empty rows
rows.length = 0;
}
} }
} else { } else {

View file

@ -158,7 +158,7 @@ export class AwsComprehend implements INodeType {
description: 'The text to send.', description: 'The text to send.',
}, },
{ {
displayName: 'Simple', displayName: 'Simplify Response',
name: 'simple', name: 'simple',
type: 'boolean', type: 'boolean',
displayOptions: { displayOptions: {
@ -172,7 +172,7 @@ export class AwsComprehend implements INodeType {
}, },
}, },
default: true, default: true,
description: 'When set to true a simplify version of the response will be used else the raw data.', description: 'Return a simplified version of the response instead of the raw data.',
}, },
{ {
displayName: 'Additional Fields', displayName: 'Additional Fields',

View file

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

View file

@ -0,0 +1,318 @@
import {
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import {
IExecuteFunctions,
} from 'n8n-core';
import {
baserowApiRequest,
baserowApiRequestAllItems,
getJwtToken,
TableFieldMapper,
toOptions,
} from './GenericFunctions';
import {
operationFields
} from './OperationDescription';
import {
BaserowCredentials,
FieldsUiValues,
GetAllAdditionalOptions,
LoadedResource,
Operation,
Row,
} from './types';
export class Baserow implements INodeType {
description: INodeTypeDescription = {
displayName: 'Baserow',
name: 'baserow',
icon: 'file:baserow.svg',
group: ['output'],
version: 1,
description: 'Consume the Baserow API',
subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}',
defaults: {
name: 'Baserow',
color: '#00a2ce',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'baserowApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{
name: 'Row',
value: 'row',
},
],
default: 'row',
description: 'Operation to perform',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'row',
],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a row',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a row',
},
{
name: 'Get',
value: 'get',
description: 'Retrieve a row',
},
{
name: 'Get All',
value: 'getAll',
description: 'Retrieve all rows',
},
{
name: 'Update',
value: 'update',
description: 'Update a row',
},
],
default: 'getAll',
description: 'Operation to perform',
},
...operationFields,
],
};
methods = {
loadOptions: {
async getDatabaseIds(this: ILoadOptionsFunctions) {
const credentials = this.getCredentials('baserowApi') as BaserowCredentials;
const jwtToken = await getJwtToken.call(this, credentials);
const endpoint = '/api/applications/';
const databases = await baserowApiRequest.call(this, 'GET', endpoint, {}, {}, jwtToken) as LoadedResource[];
return toOptions(databases);
},
async getTableIds(this: ILoadOptionsFunctions) {
const credentials = this.getCredentials('baserowApi') as BaserowCredentials;
const jwtToken = await getJwtToken.call(this, credentials);
const databaseId = this.getNodeParameter('databaseId', 0) as string;
const endpoint = `/api/database/tables/database/${databaseId}`;
const tables = await baserowApiRequest.call(this, 'GET', endpoint, {}, {}, jwtToken) as LoadedResource[];
return toOptions(tables);
},
async getTableFields(this: ILoadOptionsFunctions) {
const credentials = this.getCredentials('baserowApi') as BaserowCredentials;
const jwtToken = await getJwtToken.call(this, credentials);
const tableId = this.getNodeParameter('tableId', 0) as string;
const endpoint = `/api/database/fields/table/${tableId}/`;
const fields = await baserowApiRequest.call(this, 'GET', endpoint, {}, {}, jwtToken) as LoadedResource[];
return toOptions(fields);
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const mapper = new TableFieldMapper();
const returnData: IDataObject[] = [];
const operation = this.getNodeParameter('operation', 0) as Operation;
const tableId = this.getNodeParameter('tableId', 0) as string;
const credentials = this.getCredentials('baserowApi') as BaserowCredentials;
const jwtToken = await getJwtToken.call(this, credentials);
const fields = await mapper.getTableFields.call(this, tableId, jwtToken);
mapper.createMappings(fields);
for (let i = 0; i < items.length; i++) {
try {
if (operation === 'getAll') {
// ----------------------------------
// getAll
// ----------------------------------
// https://api.baserow.io/api/redoc/#operation/list_database_table_rows
const { order, filters, filterType, search } = this.getNodeParameter('additionalOptions', 0) as GetAllAdditionalOptions;
const qs: IDataObject = {};
if (order?.fields) {
qs['order_by'] = order.fields
.map(({ field, direction }) => `${direction}${mapper.setField(field)}`)
.join(',');
}
if (filters?.fields) {
filters.fields.forEach(({ field, operator, value }) => {
qs[`filter__field_${mapper.setField(field)}__${operator}`] = value;
});
}
if (filterType) {
qs.filter_type = filterType;
}
if (search) {
qs.search = search;
}
const endpoint = `/api/database/rows/table/${tableId}/`;
const rows = await baserowApiRequestAllItems.call(this, 'GET', endpoint, {}, qs, jwtToken) as Row[];
rows.forEach(row => mapper.idsToNames(row));
returnData.push(...rows);
} else if (operation === 'get') {
// ----------------------------------
// get
// ----------------------------------
// https://api.baserow.io/api/redoc/#operation/get_database_table_row
const rowId = this.getNodeParameter('rowId', i) as string;
const endpoint = `/api/database/rows/table/${tableId}/${rowId}/`;
const row = await baserowApiRequest.call(this, 'GET', endpoint, {}, {}, jwtToken);
mapper.idsToNames(row);
returnData.push(row);
} else if (operation === 'create') {
// ----------------------------------
// create
// ----------------------------------
// https://api.baserow.io/api/redoc/#operation/create_database_table_row
const body: IDataObject = {};
const dataToSend = this.getNodeParameter('dataToSend', 0) as 'defineBelow' | 'autoMapColumns';
if (dataToSend === 'autoMapColumns') {
const incomingKeys = Object.keys(items[i].json);
const rawInputsToIgnore = this.getNodeParameter('inputDataToIgnore', i) as string;
const inputDataToIgnore = rawInputsToIgnore.split(',').map(c => c.trim());
for (const key of incomingKeys) {
if (inputDataToIgnore.includes(key)) continue;
body[key] = items[i].json[key];
mapper.namesToIds(body);
}
} else {
const fields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as FieldsUiValues;
for (const field of fields) {
body[`field_${field.fieldId}`] = field.fieldValue;
}
}
const endpoint = `/api/database/rows/table/${tableId}/`;
const createdRow = await baserowApiRequest.call(this, 'POST', endpoint, body, {}, jwtToken);
mapper.idsToNames(createdRow);
returnData.push(createdRow);
} else if (operation === 'update') {
// ----------------------------------
// update
// ----------------------------------
// https://api.baserow.io/api/redoc/#operation/update_database_table_row
const rowId = this.getNodeParameter('rowId', i) as string;
const body: IDataObject = {};
const dataToSend = this.getNodeParameter('dataToSend', 0) as 'defineBelow' | 'autoMapInputData';
if (dataToSend === 'autoMapInputData') {
const incomingKeys = Object.keys(items[i].json);
const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string;
const inputsToIgnore = rawInputsToIgnore.split(',').map(c => c.trim());
for (const key of incomingKeys) {
if (inputsToIgnore.includes(key)) continue;
body[key] = items[i].json[key];
mapper.namesToIds(body);
}
} else {
const fields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as FieldsUiValues;
for (const field of fields) {
body[`field_${field.fieldId}`] = field.fieldValue;
}
}
const endpoint = `/api/database/rows/table/${tableId}/${rowId}/`;
const updatedRow = await baserowApiRequest.call(this, 'PATCH', endpoint, body, {}, jwtToken);
mapper.idsToNames(updatedRow);
returnData.push(updatedRow);
} else if (operation === 'delete') {
// ----------------------------------
// delete
// ----------------------------------
// https://api.baserow.io/api/redoc/#operation/delete_database_table_row
const rowId = this.getNodeParameter('rowId', i) as string;
const endpoint = `/api/database/rows/table/${tableId}/${rowId}/`;
await baserowApiRequest.call(this, 'DELETE', endpoint, {}, {}, jwtToken);
returnData.push({ success: true });
}
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ error: error.message });
continue;
}
throw error;
}
}
return [this.helpers.returnJsonArray(returnData)];
}
}

View file

@ -0,0 +1,198 @@
import {
IExecuteFunctions,
} from 'n8n-core';
import {
OptionsWithUri,
} from 'request';
import {
IDataObject,
ILoadOptionsFunctions,
NodeApiError,
NodeOperationError,
} from 'n8n-workflow';
import {
Accumulator,
BaserowCredentials,
LoadedResource,
} from './types';
/**
* Make a request to Baserow API.
*/
export async function baserowApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: string,
endpoint: string,
body: IDataObject = {},
qs: IDataObject = {},
jwtToken: string,
) {
const credentials = this.getCredentials('baserowApi') as BaserowCredentials;
if (credentials === undefined) {
throw new NodeOperationError(this.getNode(), 'No credentials got returned!');
}
const options: OptionsWithUri = {
headers: {
Authorization: `JWT ${jwtToken}`,
},
method,
body,
qs,
uri: `${credentials.host}${endpoint}`,
json: true,
};
if (Object.keys(qs).length === 0) {
delete options.qs;
}
if (Object.keys(body).length === 0) {
delete options.body;
}
try {
return await this.helpers.request!(options);
} catch (error) {
throw new NodeApiError(this.getNode(), error);
}
}
/**
* Get all results from a paginated query to Baserow API.
*/
export async function baserowApiRequestAllItems(
this: IExecuteFunctions,
method: string,
endpoint: string,
body: IDataObject,
qs: IDataObject = {},
jwtToken: string,
): Promise<IDataObject[]> {
const returnData: IDataObject[] = [];
let responseData;
qs.page = 1;
qs.size = 100;
const returnAll = this.getNodeParameter('returnAll', 0, false) as boolean;
const limit = this.getNodeParameter('limit', 0, 0) as number;
do {
responseData = await baserowApiRequest.call(this, method, endpoint, body, qs, jwtToken);
returnData.push(...responseData.results);
if (!returnAll && returnData.length > limit) {
return returnData.slice(0, limit);
}
qs.page += 1;
} while (responseData.next !== null);
return returnData;
}
/**
* Get a JWT token based on Baserow account username and password.
*/
export async function getJwtToken(
this: IExecuteFunctions | ILoadOptionsFunctions,
{ username, password, host }: BaserowCredentials,
) {
const options: OptionsWithUri = {
method: 'POST',
body: {
username,
password,
},
uri: `${host}/api/user/token-auth/`,
json: true,
};
try {
const { token } = await this.helpers.request!(options) as { token: string };
return token;
} catch (error) {
throw new NodeApiError(this.getNode(), error);
}
}
export async function getFieldNamesAndIds(
this: IExecuteFunctions,
tableId: string,
jwtToken: string,
) {
const endpoint = `/api/database/fields/table/${tableId}/`;
const response = await baserowApiRequest.call(this, 'GET', endpoint, {}, {}, jwtToken) as LoadedResource[];
return {
names: response.map((field) => field.name),
ids: response.map((field) => `field_${field.id}`),
};
}
export const toOptions = (items: LoadedResource[]) =>
items.map(({ name, id }) => ({ name, value: id }));
/**
* Responsible for mapping field IDs `field_n` to names and vice versa.
*/
export class TableFieldMapper {
nameToIdMapping: Record<string, string> = {};
idToNameMapping: Record<string, string> = {};
mapIds = true;
async getTableFields(
this: IExecuteFunctions,
table: string,
jwtToken: string,
): Promise<LoadedResource[]> {
const endpoint = `/api/database/fields/table/${table}/`;
return await baserowApiRequest.call(this, 'GET', endpoint, {}, {}, jwtToken);
}
createMappings(tableFields: LoadedResource[]) {
this.nameToIdMapping = this.createNameToIdMapping(tableFields);
this.idToNameMapping = this.createIdToNameMapping(tableFields);
}
private createIdToNameMapping(responseData: LoadedResource[]) {
return responseData.reduce<Accumulator>((acc, cur) => {
acc[`field_${cur.id}`] = cur.name;
return acc;
}, {});
}
private createNameToIdMapping(responseData: LoadedResource[]) {
return responseData.reduce<Accumulator>((acc, cur) => {
acc[cur.name] = `field_${cur.id}`;
return acc;
}, {});
}
setField(field: string) {
return this.mapIds ? field : this.nameToIdMapping[field] ?? field;
}
idsToNames(obj: Record<string, unknown>) {
Object.entries(obj).forEach(([key, value]) => {
if (this.idToNameMapping[key] !== undefined) {
delete obj[key];
obj[this.idToNameMapping[key]] = value;
}
});
}
namesToIds(obj: Record<string, unknown>) {
Object.entries(obj).forEach(([key, value]) => {
if (this.nameToIdMapping[key] !== undefined) {
delete obj[key];
obj[this.nameToIdMapping[key]] = value;
}
});
}
}

View file

@ -0,0 +1,455 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const operationFields = [
// ----------------------------------
// shared
// ----------------------------------
{
displayName: 'Database',
name: 'databaseId',
type: 'options',
default: '',
required: true,
description: 'Database to operate on',
typeOptions: {
loadOptionsMethod: 'getDatabaseIds',
},
},
{
displayName: 'Table',
name: 'tableId',
type: 'options',
default: '',
required: true,
description: 'Table to operate on',
typeOptions: {
loadOptionsDependsOn: [
'databaseId',
],
loadOptionsMethod: 'getTableIds',
},
},
// ----------------------------------
// get
// ----------------------------------
{
displayName: 'Row ID',
name: 'rowId',
type: 'string',
displayOptions: {
show: {
operation: [
'get',
],
},
},
default: '',
required: true,
description: 'ID of the row to return',
},
// ----------------------------------
// update
// ----------------------------------
{
displayName: 'Row ID',
name: 'rowId',
type: 'string',
displayOptions: {
show: {
operation: [
'update',
],
},
},
default: '',
required: true,
description: 'ID of the row to update',
},
// ----------------------------------
// create/update
// ----------------------------------
{
displayName: 'Data to Send',
name: 'dataToSend',
type: 'options',
options: [
{
name: 'Auto-map Input Data to Columns',
value: 'autoMapInputData',
description: 'Use when node input properties match destination column names',
},
{
name: 'Define Below for Each Column',
value: 'defineBelow',
description: 'Set the value for each destination column',
},
],
displayOptions: {
show: {
operation: [
'create',
'update',
],
},
},
default: 'defineBelow',
description: 'Whether to insert the input data this node receives in the new row',
},
{
displayName: 'Inputs to Ignore',
name: 'inputsToIgnore',
type: 'string',
displayOptions: {
show: {
operation: [
'create',
'update',
],
dataToSend: [
'autoMapInputData',
],
},
},
default: '',
required: false,
description: 'List of input properties to avoid sending, separated by commas. Leave empty to send all properties.',
placeholder: 'Enter properties...',
},
{
displayName: 'Fields to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValueButtonText: 'Add Field to Send',
multipleValues: true,
},
displayOptions: {
show: {
operation: [
'create',
'update',
],
dataToSend: [
'defineBelow',
],
},
},
default: {},
options: [
{
displayName: 'Field',
name: 'fieldValues',
values: [
{
displayName: 'Field ID',
name: 'fieldId',
type: 'options',
typeOptions: {
loadOptionsDependsOn: [
'tableId',
],
loadOptionsMethod: 'getTableFields',
},
default: '',
},
{
displayName: 'Field Value',
name: 'fieldValue',
type: 'string',
default: '',
},
],
},
],
},
// ----------------------------------
// delete
// ----------------------------------
{
displayName: 'Row ID',
name: 'rowId',
type: 'string',
displayOptions: {
show: {
operation: [
'delete',
],
},
},
default: '',
required: true,
description: 'ID of the row to delete',
},
// ----------------------------------
// getAll
// ----------------------------------
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
default: false,
description: 'Whether to return all results or only up to a given limit',
displayOptions: {
show: {
operation: [
'getAll',
],
},
},
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
default: 50,
description: 'How many results to return',
typeOptions: {
minValue: 1,
maxValue: 100,
},
displayOptions: {
show: {
operation: [
'getAll',
],
returnAll: [
false,
],
},
},
},
{
displayName: 'Options',
name: 'additionalOptions',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
operation: [
'getAll',
],
},
},
options: [
{
displayName: 'Filters',
name: 'filters',
placeholder: 'Add Filter',
description: 'Filter rows based on comparison operators',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'fields',
displayName: 'Field',
values: [
{
displayName: 'Field',
name: 'field',
type: 'options',
default: '',
description: 'Field to compare',
typeOptions: {
loadOptionsDependsOn: [
'tableId',
],
loadOptionsMethod: 'getTableFields',
},
},
{
displayName: 'Filter',
name: 'operator',
description: 'Operator to compare field and value with',
type: 'options',
options: [
{
name: 'Equal',
value: 'equal',
description: 'Field is equal to value',
},
{
name: 'Not Equal',
value: 'not_equal',
description: 'Field is not equal to value',
},
{
name: 'Date Equal',
value: 'date_equal',
description: 'Field is date. Format: \'YYYY-MM-DD\'',
},
{
name: 'Date Not Equal',
value: 'date_not_equal',
description: 'Field is not date. Format: \'YYYY-MM-DD\'',
},
{
name: 'Date Equals Today',
value: 'date_equals_today',
description: 'Field is today. Format: string',
},
{
name: 'Date Equals Month',
value: 'date_equals_month',
description: 'Field in this month. Format: string',
},
{
name: 'Date Equals Year',
value: 'date_equals_year',
description: 'Field in this year. Format: string',
},
{
name: 'Contains',
value: 'contains',
description: 'Field contains value',
},
{
name: 'File Name Contains',
value: 'filename_contains',
description: 'Field filename contains value',
},
{
name: 'Contains Not',
value: 'contains_not',
description: 'Field does not contain value',
},
{
name: 'Higher Than',
value: 'higher_than',
description: 'Field is higher than value',
},
{
name: 'Lower Than',
value: 'lower_than',
description: 'Field is lower than value',
},
{
name: 'Single Select Equal',
value: 'single_select_equal',
description: 'Field selected option is value',
},
{
name: 'Single Select Not Equal',
value: 'single_select_not_equal',
description: 'Field selected option is not value',
},
{
name: 'Is True',
value: 'boolean',
description: 'Boolean field is true',
},
{
name: 'Is Empty',
value: 'empty',
description: 'Field is empty',
},
{
name: 'Not Empty',
value: 'not_empty',
description: 'Field is not empty',
},
],
default: 'equal',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value to compare to',
},
],
},
],
},
{
displayName: 'Filter Type',
name: 'filterType',
type: 'options',
options: [
{
name: 'AND',
value: 'AND',
description: 'Indicates that the rows must match all the provided filters',
},
{
name: 'OR',
value: 'OR',
description: 'Indicates that the rows only have to match one of the filters',
},
],
default: 'AND',
description: 'This works only if two or more filters are provided. Defaults to <code>AND</code>',
},
{
displayName: 'Search Term',
name: 'search',
type: 'string',
default: '',
description: 'Text to match (can be in any column)',
},
{
displayName: 'Sorting',
name: 'order',
placeholder: 'Add Sort Order',
description: 'Set the sort order of the result rows',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'Fields',
displayName: 'Field',
values: [
{
displayName: 'Field Name',
name: 'field',
type: 'options',
default: '',
description: 'Field name to sort by',
typeOptions: {
loadOptionsDependsOn: [
'tableId',
],
loadOptionsMethod: 'getTableFields',
},
},
{
displayName: 'Direction',
name: 'direction',
type: 'options',
options: [
{
name: 'ASC',
value: '',
description: 'Sort in ascending order',
},
{
name: 'DESC',
value: '-',
description: 'Sort in descending order',
},
],
default: '',
description: 'Sort direction, either ascending or descending',
},
],
},
],
},
],
},
] as INodeProperties[];

View file

@ -0,0 +1,143 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg:svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="60"
height="60"
viewBox="0 0 60 60.000001"
version="1.1"
id="svg14"
sodipodi:docname="baserow.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<svg:metadata
id="metadata20">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</svg:metadata>
<svg:defs
id="defs18" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1848"
inkscape:window-height="1016"
id="namedview16"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="2.5579745"
inkscape:cx="-29.906474"
inkscape:cy="40.001009"
inkscape:window-x="1512"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="Group_163" />
<svg:g
id="Group_163"
data-name="Group 163"
transform="translate(-451.976,-540.719)">
<svg:g
id="Group_162"
data-name="Group 162"
transform="matrix(1.8935807,0,0,1.8935807,451.976,543.89927)"
style="stroke-width:0.52810001">
<svg:g
id="Group_161"
data-name="Group 161"
style="stroke-width:0.52810001">
<svg:rect
id="Rectangle_257"
data-name="Rectangle 257"
width="21.863001"
height="3.9920001"
transform="translate(6.654,24.335)"
x="0"
y="0"
style="fill:#566270;stroke-width:0.52810001" />
<svg:rect
id="Rectangle_258"
data-name="Rectangle 258"
width="23.955"
height="3.9920001"
transform="translate(0,18.251)"
x="0"
y="0"
style="fill:#4570a9;stroke-width:0.52810001" />
<svg:rect
id="Rectangle_259"
data-name="Rectangle 259"
width="6.4640002"
height="3.9920001"
transform="translate(25.222,18.251)"
x="0"
y="0"
style="fill:#4570a9;stroke-width:0.52810001" />
<svg:rect
id="Rectangle_260"
data-name="Rectangle 260"
width="11.597"
height="3.9920001"
transform="translate(4.563,12.167)"
x="0"
y="0"
style="fill:#00a2ce;stroke-width:0.52810001" />
<svg:rect
id="Rectangle_261"
data-name="Rectangle 261"
width="5.2280002"
height="3.9920001"
transform="translate(17.728,12.167)"
x="0"
y="0"
style="fill:#00a2ce;stroke-width:0.52810001" />
<svg:rect
id="Rectangle_262"
data-name="Rectangle 262"
width="19.392"
height="3.9920001"
transform="translate(8.365,6.084)"
x="0"
y="0"
style="fill:#6acaff;stroke-width:0.52810001" />
<svg:rect
id="Rectangle_263"
data-name="Rectangle 263"
width="16.35"
height="3.9920001"
transform="translate(7.319)"
x="0"
y="0"
style="fill:#9addff;stroke-width:0.52810001" />
<svg:rect
id="Rectangle_264"
data-name="Rectangle 264"
width="5.2280002"
height="3.9920001"
transform="translate(0.285)"
x="0"
y="0"
style="fill:#9addff;stroke-width:0.52810001" />
</svg:g>
</svg:g>
</svg:g>
<script />
</svg:svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,41 @@
export type BaserowCredentials = {
username: string;
password: string;
host: string;
}
export type GetAllAdditionalOptions = {
order?: {
fields: Array<{
field: string;
direction: string;
}>
};
filters?: {
fields: Array<{
field: string;
operator: string;
value: string;
}>;
};
filterType: string,
search: string,
};
export type LoadedResource = {
id: number;
name: string;
}
export type Accumulator = {
[key: string]: string;
}
export type Row = Record<string, string>
export type FieldsUiValues = Array<{
fieldId: string;
fieldValue: string;
}>;
export type Operation = 'create' | 'delete' | 'update' | 'get' | 'getAll';

View file

@ -29,6 +29,10 @@ import {
import * as moment from 'moment-timezone'; import * as moment from 'moment-timezone';
import {
noCase,
} from 'change-case';
export class Box implements INodeType { export class Box implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'Box', displayName: 'Box',
@ -81,6 +85,7 @@ export class Box implements INodeType {
const length = items.length as unknown as number; const length = items.length as unknown as number;
const qs: IDataObject = {}; const qs: IDataObject = {};
let responseData; let responseData;
const timezone = this.getTimezone();
const resource = this.getNodeParameter('resource', 0) as string; const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string; const operation = this.getNodeParameter('operation', 0) as string;
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
@ -199,6 +204,51 @@ export class Box implements INodeType {
} }
returnData.push.apply(returnData, responseData as IDataObject[]); returnData.push.apply(returnData, responseData as IDataObject[]);
} }
// https://developer.box.com/reference/post-collaborations/
if (operation === 'share') {
const fileId = this.getNodeParameter('fileId', i) as string;
const role = this.getNodeParameter('role', i) as string;
const accessibleBy = this.getNodeParameter('accessibleBy', i) as string;
const options = this.getNodeParameter('options', i) as IDataObject;
// tslint:disable-next-line: no-any
const body: { accessible_by: IDataObject, [key: string]: any } = {
accessible_by: {},
item: {
id: fileId,
type: 'file',
},
role: (role === 'coOwner') ? 'co-owner' : noCase(role),
...options,
};
if (body.fields) {
qs.fields = body.fields;
delete body.fields;
}
if (body.expires_at) {
body.expires_at = moment.tz(body.expires_at, timezone).format();
}
if (body.notify) {
qs.notify = body.notify;
delete body.notify;
}
if (accessibleBy === 'user') {
const useEmail = this.getNodeParameter('useEmail', i) as boolean;
if (useEmail) {
body.accessible_by['login'] = this.getNodeParameter('email', i) as string;
} else {
body.accessible_by['id'] = this.getNodeParameter('userId', i) as string;
}
} else {
body.accessible_by['id'] = this.getNodeParameter('groupId', i) as string;
}
responseData = await boxApiRequest.call(this, 'POST', `/collaborations`, body, qs);
returnData.push(responseData as IDataObject);
}
// https://developer.box.com/reference/post-files-content // https://developer.box.com/reference/post-files-content
if (operation === 'upload') { if (operation === 'upload') {
const parentId = this.getNodeParameter('parentId', i) as string; const parentId = this.getNodeParameter('parentId', i) as string;
@ -356,6 +406,79 @@ export class Box implements INodeType {
} }
returnData.push.apply(returnData, responseData as IDataObject[]); returnData.push.apply(returnData, responseData as IDataObject[]);
} }
// https://developer.box.com/reference/post-collaborations/
if (operation === 'share') {
const folderId = this.getNodeParameter('folderId', i) as string;
const role = this.getNodeParameter('role', i) as string;
const accessibleBy = this.getNodeParameter('accessibleBy', i) as string;
const options = this.getNodeParameter('options', i) as IDataObject;
// tslint:disable-next-line: no-any
const body: { accessible_by: IDataObject, [key: string]: any } = {
accessible_by: {},
item: {
id: folderId,
type: 'folder',
},
role: (role === 'coOwner') ? 'co-owner' : noCase(role),
...options,
};
if (body.fields) {
qs.fields = body.fields;
delete body.fields;
}
if (body.expires_at) {
body.expires_at = moment.tz(body.expires_at, timezone).format();
}
if (body.notify) {
qs.notify = body.notify;
delete body.notify;
}
if (accessibleBy === 'user') {
const useEmail = this.getNodeParameter('useEmail', i) as boolean;
if (useEmail) {
body.accessible_by['login'] = this.getNodeParameter('email', i) as string;
} else {
body.accessible_by['id'] = this.getNodeParameter('userId', i) as string;
}
} else {
body.accessible_by['id'] = this.getNodeParameter('groupId', i) as string;
}
responseData = await boxApiRequest.call(this, 'POST', `/collaborations`, body, qs);
returnData.push(responseData as IDataObject);
}
//https://developer.box.com/guides/folders/single/move/
if (operation === 'update') {
const folderId = this.getNodeParameter('folderId', i) as string;
const updateFields = this.getNodeParameter('updateFields', i) as IDataObject;
if (updateFields.fields) {
qs.fields = updateFields.fields;
delete updateFields.fields;
}
const body = {
...updateFields,
} as IDataObject;
if (body.parentId) {
body.parent = {
id: body.parentId,
};
delete body.parentId;
}
if (body.tags) {
body.tags = (body.tags as string).split(',');
}
responseData = await boxApiRequest.call(this, 'PUT', `/folders/${folderId}`, body, qs);
returnData.push(responseData as IDataObject);
}
} }
} }
if (resource === 'file' && operation === 'download') { if (resource === 'file' && operation === 'download') {

View file

@ -21,7 +21,7 @@ export class BoxTrigger implements INodeType {
icon: 'file:box.png', icon: 'file:box.png',
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
description: 'Starts the workflow when a Box events occurs.', description: 'Starts the workflow when Box events occur',
defaults: { defaults: {
name: 'Box Trigger', name: 'Box Trigger',
color: '#00aeef', color: '#00aeef',

View file

@ -40,6 +40,11 @@ export const fileOperations = [
value: 'search', value: 'search',
description: 'Search files', description: 'Search files',
}, },
{
name: 'Share',
value: 'share',
description: 'Share a file',
},
{ {
name: 'Upload', name: 'Upload',
value: 'upload', value: 'upload',
@ -496,6 +501,242 @@ export const fileFields = [
], ],
}, },
/* -------------------------------------------------------------------------- */
/* file:share */
/* -------------------------------------------------------------------------- */
{
displayName: 'File ID',
name: 'fileId',
type: 'string',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'file',
],
},
},
default: '',
description: 'The ID of the file to share.',
},
{
displayName: 'Accessible By',
name: 'accessibleBy',
type: 'options',
options: [
{
name: 'Group',
value: 'group',
},
{
name: 'User',
value: 'user',
},
],
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'file',
],
},
},
default: '',
description: 'The type of object the file will be shared with.',
},
{
displayName: 'Use Email',
name: 'useEmail',
type: 'boolean',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'file',
],
accessibleBy: [
'user',
],
},
},
default: true,
description: 'Whether identify the user by email or ID.',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'file',
],
useEmail: [
true,
],
accessibleBy: [
'user',
],
},
},
default: '',
description: `The user's email address to share the file with.`,
},
{
displayName: 'User ID',
name: 'userId',
type: 'string',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'file',
],
useEmail: [
false,
],
accessibleBy: [
'user',
],
},
},
default: '',
description: `The user's ID to share the file with.`,
},
{
displayName: 'Group ID',
name: 'groupId',
type: 'string',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'file',
],
accessibleBy: [
'group',
],
},
},
default: '',
description: `The group's ID to share the file with.`,
},
{
displayName: 'Role',
name: 'role',
type: 'options',
options: [
{
name: 'Co-Owner',
value: 'coOwner',
description: 'A Co-owner has all of functional read/write access that an editor does',
},
{
name: 'Editor',
value: 'editor',
description: 'An editor has full read/write access to a folder or file',
},
{
name: 'Previewer',
value: 'previewer',
description: 'A previewer has limited read access',
},
{
name: 'Previewer Uploader',
value: 'previewerUploader',
description: 'This access level is a combination of Previewer and Uploader',
},
{
name: 'Uploader',
value: 'uploader',
description: 'An uploader has limited write access',
},
{
name: 'Viewer',
value: 'viewer',
description: 'A viewer has read access to a folder or file',
},
{
name: 'Viewer Uploader',
value: 'viewerUploader',
description: 'This access level is a combination of Viewer and Uploader',
},
],
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'file',
],
},
},
default: 'editor',
description: 'The level of access granted.',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'file',
],
},
},
default: {},
options: [
{
displayName: 'Can View Path',
name: 'can_view_path',
type: 'boolean',
default: false,
description: `Whether the invited users can see the entire parent path to the associated folder.</br>
The user will not gain privileges in any parent folder and therefore cannot see content the user is not collaborated on.`,
},
{
displayName: 'Expires At',
name: 'expires_at',
type: 'dateTime',
default: '',
description: 'Set the expiration date for the collaboration. At this date, the collaboration will be automatically removed from the item.',
},
{
displayName: 'Fields',
name: 'fields',
type: 'string',
default: '',
description: 'A comma-separated list of attributes to include in the response. This can be used to request fields that are not normally returned in a standard response.',
},
{
displayName: 'Notify',
name: 'notify',
type: 'boolean',
default: false,
description: 'Whether if users should receive email notification for the action performed.',
},
],
},
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* file:upload */ /* file:upload */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */

View file

@ -35,6 +35,16 @@ export const folderOperations = [
value: 'search', value: 'search',
description: 'Search files', description: 'Search files',
}, },
{
name: 'Share',
value: 'share',
description: 'Share a folder',
},
{
name: 'Update',
value: 'update',
description: 'Update folder',
},
], ],
default: 'create', default: 'create',
description: 'The operation to perform.', description: 'The operation to perform.',
@ -147,6 +157,7 @@ export const folderFields = [
default: '', default: '',
description: 'Folder ID', description: 'Folder ID',
}, },
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* folder:delete */ /* folder:delete */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -441,4 +452,417 @@ export const folderFields = [
}, },
], ],
}, },
/* -------------------------------------------------------------------------- */
/* folder:share */
/* -------------------------------------------------------------------------- */
{
displayName: 'Folder ID',
name: 'folderId',
type: 'string',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'folder',
],
},
},
default: '',
description: 'The ID of the folder to share.',
},
{
displayName: 'Accessible By',
name: 'accessibleBy',
type: 'options',
options: [
{
name: 'User',
value: 'user',
},
{
name: 'Group',
value: 'group',
},
],
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'folder',
],
},
},
default: 'user',
description: 'The type of object the file will be shared with.',
},
{
displayName: 'Use Email',
name: 'useEmail',
type: 'boolean',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'folder',
],
accessibleBy: [
'user',
],
},
},
default: true,
description: 'Whether identify the user by email or ID.',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'folder',
],
accessibleBy: [
'user',
],
useEmail: [
true,
],
},
},
default: '',
description: `The user's email address to share the folder with.`,
},
{
displayName: 'User ID',
name: 'userId',
type: 'string',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'folder',
],
accessibleBy: [
'user',
],
useEmail: [
false,
],
},
},
default: '',
description: `The user's ID to share the folder with.`,
},
{
displayName: 'Group ID',
name: 'groupId',
type: 'string',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'folder',
],
accessibleBy: [
'group',
],
},
},
default: '',
description: `The group's ID to share the folder with.`,
},
{
displayName: 'Role',
name: 'role',
type: 'options',
options: [
{
name: 'Co-Owner',
value: 'coOwner',
description: 'A Co-owner has all of functional read/write access that an editor does',
},
{
name: 'Editor',
value: 'editor',
description: 'An editor has full read/write access to a folder or file',
},
{
name: 'Previewer',
value: 'previewer',
description: 'A previewer has limited read access',
},
{
name: 'Previewer Uploader',
value: 'previewerUploader',
description: 'This access level is a combination of Previewer and Uploader',
},
{
name: 'Uploader',
value: 'uploader',
description: 'An uploader has limited write access',
},
{
name: 'Viewer',
value: 'viewer',
description: 'A viewer has read access to a folder or file',
},
{
name: 'Viewer Uploader',
value: 'viewerUploader',
description: 'This access level is a combination of Viewer and Uploader',
},
],
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'folder',
],
},
},
default: 'editor',
description: 'The level of access granted.',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
displayOptions: {
show: {
operation: [
'share',
],
resource: [
'folder',
],
},
},
default: {},
options: [
{
displayName: 'Can View Path',
name: 'can_view_path',
type: 'boolean',
default: false,
description: `Whether the invited users can see the entire parent path to the associated folder.</br>
The user will not gain privileges in any parent folder and therefore cannot see content the user is not collaborated on.`,
},
{
displayName: 'Expires At',
name: 'expires_at',
type: 'dateTime',
default: '',
description: 'Set the expiration date for the collaboration. At this date, the collaboration will be automatically removed from the item.',
},
{
displayName: 'Fields',
name: 'fields',
type: 'string',
default: '',
description: 'A comma-separated list of attributes to include in the response. This can be used to request fields that are not normally returned in a standard response.',
},
{
displayName: 'Notify',
name: 'notify',
type: 'boolean',
default: false,
description: 'Whether if users should receive email notification for the action performed.',
},
],
},
/* -------------------------------------------------------------------------- */
/* folder:update */
/* -------------------------------------------------------------------------- */
{
displayName: 'Folder ID',
name: 'folderId',
required: true,
type: 'string',
displayOptions: {
show: {
operation: [
'update',
],
resource: [
'folder',
],
},
},
default: '',
description: 'Folder ID',
},
{
displayName: 'Update Fields',
name: 'updateFields',
type: 'collection',
displayOptions: {
show: {
operation: [
'update',
],
resource: [
'folder',
],
},
},
default: {},
placeholder: 'Add Field',
options: [
{
displayName: 'Can Non-Owners Invite',
name: 'can_non_owners_invite',
type: 'boolean',
default: false,
description: 'Specifies if users who are not the owner of the folder can invite new collaborators to the folder.',
},
{
displayName: 'Can Non-Owners View Colaborators',
name: 'can_non_owners_view_collaborators',
type: 'boolean',
default: false,
description: 'Restricts collaborators who are not the owner of this folder from viewing other collaborations on this folder.',
},
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
description: 'The optional description of this folder.',
},
{
displayName: 'Fields',
name: 'fields',
type: 'string',
default: '',
description: 'A comma-separated list of attributes to include in the response. This can be used to request fields that are not normally returned in a standard response.',
},
{
displayName: 'Is Collaboration Restricted To Enterprise',
name: 'is_collaboration_restricted_to_enterprise',
type: 'boolean',
default: false,
description: 'Specifies if new invites to this folder are restricted to users within the enterprise. This does not affect existing collaborations.',
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'The optional new name for this folder.',
},
{
displayName: 'Parent ID',
name: 'parentId',
type: 'string',
default: '',
description: 'The parent folder for this folder. Use this to move the folder or to restore it out of the trash.',
},
{
displayName: 'Shared Link',
name: 'shared_link',
type: 'collection',
typeOptions: {
multipleValues: false,
},
description: 'Share link information.',
placeholder: 'Add Shared Link Config',
default: {},
options: [
{
displayName: 'Access',
name: 'access',
type: 'options',
options: [
{
name: 'Collaborators',
value: 'collaborators',
description: 'Only those who have been invited to the folder',
},
{
name: 'Company',
value: 'company',
description: 'only people within the company',
},
{
name: 'Open',
value: 'open',
description: 'Anyone with the link',
},
],
default: 'open',
},
{
displayName: 'Password',
name: 'password',
type: 'string',
displayOptions: {
show: {
access: [
'open',
],
},
},
default: '',
description: 'The password required to access the shared link. Set the password to null to remove it.',
},
{
displayName: 'Permissions',
name: 'permissions',
type: 'collection',
placeholder: 'Add Permition',
default: {},
options: [
{
displayName: 'Can Download',
name: 'can_download',
type: 'boolean',
default: false,
description: 'If the shared link allows for downloading of files.',
},
{
displayName: 'Unshared At',
name: 'unshared_at',
type: 'dateTime',
default: '',
description: 'The timestamp at which this shared link will expire.',
},
{
displayName: 'Vanity Name',
name: 'vanity_name',
type: 'string',
default: '',
description: 'Defines a custom vanity name to use in the shared link URL, for example https://app.box.com/v/my-shared-link.',
},
],
},
{
displayName: 'Tags',
name: 'tags',
type: 'string',
default: '',
description: 'The tags for this item. These tags are shown in the Box web app and mobile apps next to an item.',
},
],
},
],
},
] as INodeProperties[]; ] as INodeProperties[];

View file

@ -20,7 +20,7 @@ export class CalendlyTrigger implements INodeType {
icon: 'file:calendly.svg', icon: 'file:calendly.svg',
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
description: 'Starts the workflow when Calendly events occur.', description: 'Starts the workflow when Calendly events occur',
defaults: { defaults: {
name: 'Calendly Trigger', name: 'Calendly Trigger',
color: '#374252', color: '#374252',

View file

@ -17,7 +17,7 @@ export class ChargebeeTrigger implements INodeType {
icon: 'file:chargebee.png', icon: 'file:chargebee.png',
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
description: 'Starts the workflow when Chargebee events occur.', description: 'Starts the workflow when Chargebee events occur',
defaults: { defaults: {
name: 'Chargebee Trigger', name: 'Chargebee Trigger',
color: '#559922', color: '#559922',

View file

@ -26,7 +26,7 @@ export class ClockifyTrigger implements INodeType {
name: 'clockifyTrigger', name: 'clockifyTrigger',
group: [ 'trigger' ], group: [ 'trigger' ],
version: 1, version: 1,
description: 'Watches Clockify For Events', description: 'Listens to Clockify events',
defaults: { defaults: {
name: 'Clockify Trigger', name: 'Clockify Trigger',
color: '#000000', color: '#000000',

View file

@ -48,7 +48,7 @@ export class ConvertKit implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume ConvertKit API.', description: 'Consume ConvertKit API',
defaults: { defaults: {
name: 'ConvertKit', name: 'ConvertKit',
color: '#fb6970', color: '#fb6970',

View file

@ -25,7 +25,7 @@ export class CrateDb implements INodeType {
icon: 'file:cratedb.png', icon: 'file:cratedb.png',
group: ['input'], group: ['input'],
version: 1, version: 1,
description: 'Add and update data in CrateDB.', description: 'Add and update data in CrateDB',
defaults: { defaults: {
name: 'CrateDB', name: 'CrateDB',
color: '#47889f', color: '#47889f',

View file

@ -31,7 +31,7 @@ export class CustomerIoTrigger implements INodeType {
group: ['trigger'], group: ['trigger'],
icon: 'file:customerio.svg', icon: 'file:customerio.svg',
version: 1, version: 1,
description: 'Starts the workflow on a Customer.io update. (Beta)', description: 'Starts the workflow on a Customer.io update (Beta)',
defaults: { defaults: {
name: 'Customer.io Trigger', name: 'Customer.io Trigger',
color: '#ffcd00', color: '#ffcd00',

View file

@ -55,7 +55,7 @@ export class Discourse implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Discourse API.', description: 'Consume Discourse API',
defaults: { defaults: {
name: 'Discourse', name: 'Discourse',
color: '#000000', color: '#000000',

View file

@ -50,7 +50,7 @@ export const searchFields: INodeProperties[] = [
description: 'Term to search for.', description: 'Term to search for.',
}, },
{ {
displayName: 'Simple', displayName: 'Simplify Response',
name: 'simple', name: 'simple',
type: 'boolean', type: 'boolean',
displayOptions: { displayOptions: {
@ -64,6 +64,6 @@ export const searchFields: INodeProperties[] = [
}, },
}, },
default: true, default: true,
description: 'When set to true a simplify version of the response will be used else the raw data.', description: 'Return a simplified version of the response instead of the raw data.',
}, },
]; ];

View file

@ -515,7 +515,7 @@ export class Egoi implements INodeType {
description: 'How many results to return.', description: 'How many results to return.',
}, },
{ {
displayName: 'Simple', displayName: 'Simplify Response',
name: 'simple', name: 'simple',
type: 'boolean', type: 'boolean',
displayOptions: { displayOptions: {
@ -530,7 +530,7 @@ export class Egoi implements INodeType {
}, },
}, },
default: true, default: true,
description: 'When set to true a simple version of the response will be returned else the RAW data.', description: 'Return a simplified version of the response instead of the raw data.',
}, },
], ],
}; };

View file

@ -35,7 +35,7 @@ export class EmailReadImap implements INodeType {
icon: 'fa:inbox', icon: 'fa:inbox',
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
description: 'Triggers the workflow when a new email gets received', description: 'Triggers the workflow when a new email is received',
defaults: { defaults: {
name: 'IMAP Email', name: 'IMAP Email',
color: '#44AA22', color: '#44AA22',

View file

@ -51,7 +51,7 @@ export class ExecuteCommand implements INodeType {
icon: 'fa:terminal', icon: 'fa:terminal',
group: ['transform'], group: ['transform'],
version: 1, version: 1,
description: 'Executes a command on the host.', description: 'Executes a command on the host',
defaults: { defaults: {
name: 'Execute Command', name: 'Execute Command',
color: '#886644', color: '#886644',

View file

@ -33,7 +33,7 @@ export class FacebookTrigger implements INodeType {
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
subtitle: '={{$parameter["appId"] +"/"+ $parameter["object"]}}', subtitle: '={{$parameter["appId"] +"/"+ $parameter["object"]}}',
description: 'Starts the workflow when a Facebook events occurs.', description: 'Starts the workflow when Facebook events occur',
defaults: { defaults: {
name: 'Facebook Trigger', name: 'Facebook Trigger',
color: '#3B5998', color: '#3B5998',

View file

@ -30,7 +30,7 @@ export class FileMaker implements INodeType {
icon: 'file:filemaker.png', icon: 'file:filemaker.png',
group: ['input'], group: ['input'],
version: 1, version: 1,
description: 'Retrieve data from FileMaker data API.', description: 'Retrieve data from the FileMaker data API',
defaults: { defaults: {
name: 'FileMaker', name: 'FileMaker',
color: '#665533', color: '#665533',

View file

@ -45,7 +45,7 @@ export class Ftp implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["protocol"] + ": " + $parameter["operation"]}}', subtitle: '={{$parameter["protocol"] + ": " + $parameter["operation"]}}',
description: 'Transfers files via FTP or SFTP.', description: 'Transfers files via FTP or SFTP',
defaults: { defaults: {
name: 'FTP', name: 'FTP',
color: '#303050', color: '#303050',

View file

@ -15,7 +15,7 @@ export class Function implements INodeType {
icon: 'fa:code', icon: 'fa:code',
group: ['transform'], group: ['transform'],
version: 1, version: 1,
description: 'Run custom function code which gets executed once and allows to add, remove, change and replace items.', description: 'Run custom function code which gets executed once and allows you to add, remove, change and replace items',
defaults: { defaults: {
name: 'Function', name: 'Function',
color: '#FF9922', color: '#FF9922',

View file

@ -17,7 +17,7 @@ export class FunctionItem implements INodeType {
icon: 'fa:code', icon: 'fa:code',
group: ['transform'], group: ['transform'],
version: 1, version: 1,
description: 'Run custom function code which gets executed once per item.', description: 'Run custom function code which gets executed once per item',
defaults: { defaults: {
name: 'FunctionItem', name: 'FunctionItem',
color: '#ddbb33', color: '#ddbb33',

View file

@ -31,7 +31,7 @@ export class GetResponse implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume GetResponse API.', description: 'Consume GetResponse API',
defaults: { defaults: {
name: 'GetResponse', name: 'GetResponse',
color: '#00afec', color: '#00afec',

View file

@ -26,7 +26,7 @@ export class GetResponseTrigger implements INodeType {
icon: 'file:getResponse.png', icon: 'file:getResponse.png',
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
description: 'Starts the workflow when GetResponse events occur.', description: 'Starts the workflow when GetResponse events occur',
defaults: { defaults: {
name: 'GetResponse Trigger', name: 'GetResponse Trigger',
color: '#00afec', color: '#00afec',

View file

@ -33,7 +33,7 @@ export class Ghost implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Ghost API.', description: 'Consume Ghost API',
defaults: { defaults: {
name: 'Ghost', name: 'Ghost',
color: '#15212a', color: '#15212a',

View file

@ -28,7 +28,7 @@ export class Github implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume GitHub API.', description: 'Consume GitHub API',
defaults: { defaults: {
name: 'GitHub', name: 'GitHub',
color: '#000000', color: '#000000',

View file

@ -25,7 +25,7 @@ export class GithubTrigger implements INodeType {
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
subtitle: '={{$parameter["owner"] + "/" + $parameter["repository"] + ": " + $parameter["events"].join(", ")}}', subtitle: '={{$parameter["owner"] + "/" + $parameter["repository"] + ": " + $parameter["events"].join(", ")}}',
description: 'Starts the workflow when a Github events occurs.', description: 'Starts the workflow when Github events occur',
defaults: { defaults: {
name: 'Github Trigger', name: 'Github Trigger',
color: '#000000', color: '#000000',

View file

@ -23,7 +23,7 @@ export class Gitlab implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Retrieve data from GitLab API.', description: 'Retrieve data from GitLab API',
defaults: { defaults: {
name: 'Gitlab', name: 'Gitlab',
color: '#FC6D27', color: '#FC6D27',

View file

@ -24,7 +24,7 @@ export class GitlabTrigger implements INodeType {
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
subtitle: '={{$parameter["owner"] + "/" + $parameter["repository"] + ": " + $parameter["events"].join(", ")}}', subtitle: '={{$parameter["owner"] + "/" + $parameter["repository"] + ": " + $parameter["events"].join(", ")}}',
description: 'Starts the workflow when a GitLab event occurs.', description: 'Starts the workflow when GitLab events occur',
defaults: { defaults: {
name: 'Gitlab Trigger', name: 'Gitlab Trigger',
color: '#FC6D27', color: '#FC6D27',

View file

@ -91,7 +91,7 @@ export const reportFields = [
description: 'How many results to return.', description: 'How many results to return.',
}, },
{ {
displayName: 'Simple', displayName: 'Simplify Response',
name: 'simple', name: 'simple',
type: 'boolean', type: 'boolean',
displayOptions: { displayOptions: {
@ -105,7 +105,7 @@ export const reportFields = [
}, },
}, },
default: true, default: true,
description: 'When set to true a simplify version of the response will be used else the raw data.', description: 'Return a simplified version of the response instead of the raw data.',
}, },
{ {
displayName: 'Additional Fields', displayName: 'Additional Fields',

View file

@ -32,7 +32,7 @@ export class GoogleBigQuery implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google BigQuery API.', description: 'Consume Google BigQuery API',
defaults: { defaults: {
name: 'Google BigQuery', name: 'Google BigQuery',
color: '#3E87E4', color: '#3E87E4',

View file

@ -287,7 +287,7 @@ export const recordFields = [
description: 'How many results to return.', description: 'How many results to return.',
}, },
{ {
displayName: 'Simple', displayName: 'Simplify Response',
name: 'simple', name: 'simple',
type: 'boolean', type: 'boolean',
displayOptions: { displayOptions: {

View file

@ -30,6 +30,7 @@ export async function googleApiRequest(this: IExecuteFunctions | IExecuteSingleF
if (Object.keys(body).length === 0) { if (Object.keys(body).length === 0) {
delete options.body; delete options.body;
} }
console.log(options);
//@ts-ignore //@ts-ignore
return await this.helpers.requestOAuth2.call(this, 'googleCalendarOAuth2Api', options); return await this.helpers.requestOAuth2.call(this, 'googleCalendarOAuth2Api', options);
} catch (error) { } catch (error) {

View file

@ -44,7 +44,7 @@ export class GoogleCalendar implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Calendar API.', description: 'Consume Google Calendar API',
defaults: { defaults: {
name: 'Google Calendar', name: 'Google Calendar',
color: '#3E87E4', color: '#3E87E4',

View file

@ -33,7 +33,7 @@ export class GoogleContacts implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Contacts API.', description: 'Consume Google Contacts API',
defaults: { defaults: {
name: 'Google Contacts', name: 'Google Contacts',
color: '#1a73e8', color: '#1a73e8',

View file

@ -24,7 +24,7 @@
// group: ['trigger'], // group: ['trigger'],
// version: 1, // version: 1,
// subtitle: '={{$parameter["owner"] + "/" + $parameter["repository"] + ": " + $parameter["events"].join(", ")}}', // subtitle: '={{$parameter["owner"] + "/" + $parameter["repository"] + ": " + $parameter["events"].join(", ")}}',
// description: 'Starts the workflow when a file on Google Drive got changed.', // description: 'Starts the workflow when a file on Google Drive is changed',
// defaults: { // defaults: {
// name: 'Google Drive Trigger', // name: 'Google Drive Trigger',
// color: '#3f87f2', // color: '#3f87f2',

View file

@ -137,7 +137,7 @@ export const documentFields = [
placeholder: 'productId, modelName, description', placeholder: 'productId, modelName, description',
}, },
{ {
displayName: 'Simple', displayName: 'Simplify Response',
name: 'simple', name: 'simple',
type: 'boolean', type: 'boolean',
displayOptions: { displayOptions: {
@ -151,7 +151,7 @@ export const documentFields = [
}, },
}, },
default: true, default: true,
description: 'When set to true a simplify version of the response will be used else the raw data.', description: 'Return a simplified version of the response instead of the raw data.',
}, },
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -233,7 +233,7 @@ export const documentFields = [
required: true, required: true,
}, },
{ {
displayName: 'Simple', displayName: 'Simplify Response',
name: 'simple', name: 'simple',
type: 'boolean', type: 'boolean',
displayOptions: { displayOptions: {
@ -247,7 +247,7 @@ export const documentFields = [
}, },
}, },
default: true, default: true,
description: 'When set to true a simplify version of the response will be used else the raw data.', description: 'Return a simplified version of the response instead of the raw data.',
}, },
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -353,7 +353,7 @@ export const documentFields = [
description: 'How many results to return.', description: 'How many results to return.',
}, },
{ {
displayName: 'Simple', displayName: 'Simplify Response',
name: 'simple', name: 'simple',
type: 'boolean', type: 'boolean',
displayOptions: { displayOptions: {
@ -367,7 +367,7 @@ export const documentFields = [
}, },
}, },
default: true, default: true,
description: 'When set to true a simplify version of the response will be used else the raw data.', description: 'Return a simplified version of the response instead of the raw data.',
}, },
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -726,7 +726,7 @@ export const documentFields = [
placeholder: '{"structuredQuery": {"where": {"fieldFilter": {"field": {"fieldPath": "age"},"op": "EQUAL", "value": {"integerValue": 28}}}, "from": [{"collectionId": "users-collection"}]}}', placeholder: '{"structuredQuery": {"where": {"fieldFilter": {"field": {"fieldPath": "age"},"op": "EQUAL", "value": {"integerValue": 28}}}, "from": [{"collectionId": "users-collection"}]}}',
}, },
{ {
displayName: 'Simple', displayName: 'Simplify Response',
name: 'simple', name: 'simple',
type: 'boolean', type: 'boolean',
displayOptions: { displayOptions: {
@ -740,6 +740,6 @@ export const documentFields = [
}, },
}, },
default: true, default: true,
description: 'When set to true a simplify version of the response will be used else the raw data.', description: 'Return a simplified version of the response instead of the raw data.',
}, },
] as INodeProperties[]; ] as INodeProperties[];

View file

@ -486,8 +486,9 @@ export class GoogleSheet {
inputData.forEach((item) => { inputData.forEach((item) => {
rowData = []; rowData = [];
keyColumnOrder.forEach((key) => { keyColumnOrder.forEach((key) => {
if (item.hasOwnProperty(key) && item[key]) { const data = item[key];
rowData.push(item[key]!.toString()); if (item.hasOwnProperty(key) && data !== null && typeof data !== 'undefined') {
rowData.push(data.toString());
} else { } else {
rowData.push(''); rowData.push('');
} }

View file

@ -29,7 +29,7 @@ export class GoogleTasks implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Tasks API.', description: 'Consume Google Tasks API',
defaults: { defaults: {
name: 'Google Tasks', name: 'Google Tasks',
color: '#3E87E4', color: '#3E87E4',

View file

@ -55,7 +55,7 @@ export class YouTube implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume YouTube API.', description: 'Consume YouTube API',
defaults: { defaults: {
name: 'YouTube', name: 'YouTube',
color: '#FF0000', color: '#FF0000',

View file

@ -22,7 +22,7 @@ export class Gotify implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Gotify API.', description: 'Consume Gotify API',
defaults: { defaults: {
name: 'Gotify', name: 'Gotify',
color: '#71c8ec', color: '#71c8ec',

View file

@ -64,7 +64,7 @@ export class HelpScout implements INodeType {
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Help Scout API.', description: 'Consume HelpScout API',
defaults: { defaults: {
name: 'HelpScout', name: 'HelpScout',
color: '#1392ee', color: '#1392ee',

View file

@ -26,7 +26,7 @@ export class HelpScoutTrigger implements INodeType {
icon: 'file:helpScout.svg', icon: 'file:helpScout.svg',
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
description: 'Starts the workflow when HelpScout events occur.', description: 'Starts the workflow when HelpScout events occur',
defaults: { defaults: {
name: 'HelpScout Trigger', name: 'HelpScout Trigger',
color: '#1392ee', color: '#1392ee',

View file

@ -0,0 +1,69 @@
import {
INodeProperties
} from 'n8n-workflow';
export const cameraProxyOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'cameraProxy',
],
},
},
options: [
{
name: 'Get Screenshot',
value: 'getScreenshot',
description: 'Get the camera screenshot',
},
],
default: 'getScreenshot',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const cameraProxyFields = [
/* -------------------------------------------------------------------------- */
/* cameraProxy:getScreenshot */
/* -------------------------------------------------------------------------- */
{
displayName: 'Camera Entity ID',
name: 'cameraEntityId',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
operation: [
'getScreenshot',
],
resource: [
'cameraProxy',
],
},
},
description: 'The camera entity ID.',
},
{
displayName: 'Binary Property',
name: 'binaryPropertyName',
type: 'string',
required: true,
default: 'data',
displayOptions: {
show: {
operation: [
'getScreenshot',
],
resource: [
'cameraProxy',
],
},
},
description: 'Name of the binary property to which to<br />write the data of the read file.',
},
] as INodeProperties[];

View file

@ -0,0 +1,32 @@
import {
INodeProperties
} from 'n8n-workflow';
export const configOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'config',
],
},
},
options: [
{
name: 'Get',
value: 'get',
description: 'Get the configuration',
},
{
name: 'Check Configuration',
value: 'check',
description: 'Check the configuration',
},
],
default: 'get',
description: 'The operation to perform.',
},
] as INodeProperties[];

View file

@ -0,0 +1,144 @@
import {
INodeProperties
} from 'n8n-workflow';
export const eventOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'event',
],
},
},
options: [
{
name: 'Get All',
value: 'getAll',
description: 'Get all events',
},
{
name: 'Post',
value: 'post',
description: 'Post an event',
},
],
default: 'getAll',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const eventFields = [
/* -------------------------------------------------------------------------- */
/* event:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'event',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'event',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
description: 'How many results to return.',
},
/* -------------------------------------------------------------------------- */
/* event:post */
/* -------------------------------------------------------------------------- */
{
displayName: 'Event Type',
name: 'eventType',
type: 'string',
displayOptions: {
show: {
operation: [
'post',
],
resource: [
'event',
],
},
},
required: true,
default: '',
description: 'The Entity ID for which an event will be created.',
},
{
displayName: 'Event Attributes',
name: 'eventAttributes',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Attribute',
default: {},
displayOptions: {
show: {
resource: [
'event',
],
operation: [
'post',
],
},
},
options: [
{
displayName: 'Attributes',
name: 'attributes',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the attribute.',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the attribute.',
},
],
},
],
},
] as INodeProperties[];

View file

@ -0,0 +1,42 @@
import {
OptionsWithUri
} from 'request';
import {
IExecuteFunctions,
} from 'n8n-core';
import {
IDataObject,
NodeApiError,
NodeOperationError,
} from 'n8n-workflow';
export async function homeAssistantApiRequest(this: IExecuteFunctions, method: string, resource: string, body: IDataObject = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}) {
const credentials = this.getCredentials('homeAssistantApi');
if (credentials === undefined) {
throw new NodeOperationError(this.getNode(), 'No credentials got returned!');
}
let options: OptionsWithUri = {
headers: {
Authorization: `Bearer ${credentials.accessToken}`,
},
method,
qs,
body,
uri: uri ?? `${credentials.ssl === true ? 'https' : 'http'}://${credentials.host}:${credentials.port}/api${resource}`,
json: true,
};
options = Object.assign({}, options, option);
if (Object.keys(options.body).length === 0) {
delete options.body;
}
try {
return await this.helpers.request(options);
} catch (error) {
throw new NodeApiError(this.getNode(), error);
}
}

View file

@ -0,0 +1,128 @@
import {
INodeProperties
} from 'n8n-workflow';
export const historyOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'history',
],
},
},
options: [
{
name: 'Get All',
value: 'getAll',
description: 'Get all state changes',
},
],
default: 'getAll',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const historyFields = [
/* -------------------------------------------------------------------------- */
/* history:getLogbookEntries */
/* -------------------------------------------------------------------------- */
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'history',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'history',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
description: 'How many results to return.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'history',
],
operation: [
'getAll',
],
},
},
options: [
{
displayName: 'End Time',
name: 'endTime',
type: 'dateTime',
default: '',
description: 'The end of the period.',
},
{
displayName: 'Entity IDs',
name: 'entityIds',
type: 'string',
default: '',
description: 'The entities IDs separated by comma.',
},
{
displayName: 'Minimal Response',
name: 'minimalResponse',
type: 'boolean',
default: false,
description: 'To only return <code>last_changed</code> and state for states.',
},
{
displayName: 'Significant Changes Only',
name: 'significantChangesOnly',
type: 'boolean',
default: false,
description: 'Only return significant state changes.',
},
{
displayName: 'Start Time',
name: 'startTime',
type: 'dateTime',
default: '',
description: 'The beginning of the period.',
},
],
},
] as INodeProperties[];

View file

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

View file

@ -0,0 +1,377 @@
import {
IExecuteFunctions,
} from 'n8n-core';
import {
IDataObject,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import {
configOperations,
} from './ConfigDescription';
import {
serviceFields,
serviceOperations,
} from './ServiceDescription';
import {
stateFields,
stateOperations,
} from './StateDescription';
import {
eventFields,
eventOperations,
} from './EventDescription';
import {
logFields,
logOperations,
} from './LogDescription';
import {
templateFields,
templateOperations,
} from './TemplateDescription';
import {
historyFields,
historyOperations,
} from './HistoryDescription';
import {
cameraProxyFields,
cameraProxyOperations,
} from './CameraProxyDescription';
import {
homeAssistantApiRequest,
} from './GenericFunctions';
export class HomeAssistant implements INodeType {
description: INodeTypeDescription = {
displayName: 'Home Assistant',
name: 'homeAssistant',
icon: 'file:homeAssistant.svg',
group: ['output'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Home Assistant API',
defaults: {
name: 'Home Assistant',
color: '#3578e5',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'homeAssistantApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{
name: 'Camera Proxy',
value: 'cameraProxy',
},
{
name: 'Config',
value: 'config',
},
// {
// name: 'Event',
// value: 'event',
// },
// {
// name: 'History',
// value: 'history',
// },
{
name: 'Log',
value: 'log',
},
{
name: 'Service',
value: 'service',
},
{
name: 'State',
value: 'state',
},
{
name: 'Template',
value: 'template',
},
],
default: 'config',
description: 'Resource to consume.',
},
...cameraProxyOperations,
...cameraProxyFields,
...configOperations,
...eventOperations,
...eventFields,
...historyOperations,
...historyFields,
...logOperations,
...logFields,
...serviceOperations,
...serviceFields,
...stateOperations,
...stateFields,
...templateOperations,
...templateFields,
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: IDataObject[] = [];
const length = items.length;
const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string;
const qs: IDataObject = {};
let responseData;
for (let i = 0; i < length; i++) {
try {
if (resource === 'config') {
if (operation === 'get') {
responseData = await homeAssistantApiRequest.call(this, 'GET', '/config');
} else if (operation === 'check') {
responseData = await homeAssistantApiRequest.call(this, 'POST', '/config/core/check_config');
}
} else if (resource === 'service') {
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
responseData = await homeAssistantApiRequest.call(this, 'GET', '/services') as IDataObject[];
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
} else if (operation === 'call') {
const domain = this.getNodeParameter('domain', i) as string;
const service = this.getNodeParameter('service', i) as string;
const serviceAttributes = this.getNodeParameter('serviceAttributes', i) as {
attributes: IDataObject[],
};
const body: IDataObject = {};
if (Object.entries(serviceAttributes).length) {
if (serviceAttributes.attributes !== undefined) {
serviceAttributes.attributes.map(
attribute => {
// @ts-ignore
body[attribute.name as string] = attribute.value;
},
);
}
}
responseData = await homeAssistantApiRequest.call(this, 'POST', `/services/${domain}/${service}`, body);
if (Array.isArray(responseData) && responseData.length === 0) {
responseData = {};
}
}
} else if (resource === 'state') {
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
responseData = await homeAssistantApiRequest.call(this, 'GET', '/states') as IDataObject[];
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
} else if (operation === 'get') {
const entityId = this.getNodeParameter('entityId', i) as string;
responseData = await homeAssistantApiRequest.call(this, 'GET', `/states/${entityId}`);
} else if (operation === 'upsert') {
const entityId = this.getNodeParameter('entityId', i) as string;
const state = this.getNodeParameter('state', i) as string;
const stateAttributes = this.getNodeParameter('stateAttributes', i) as {
attributes: IDataObject[],
};
const body = {
state,
attributes: {},
};
if (Object.entries(stateAttributes).length) {
if (stateAttributes.attributes !== undefined) {
stateAttributes.attributes.map(
attribute => {
// @ts-ignore
body.attributes[attribute.name as string] = attribute.value;
},
);
}
}
responseData = await homeAssistantApiRequest.call(this, 'POST', `/states/${entityId}`, body);
}
} else if (resource === 'event') {
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
responseData = await homeAssistantApiRequest.call(this, 'GET', '/events') as IDataObject[];
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
} else if (operation === 'post') {
const eventType = this.getNodeParameter('eventType', i) as string;
const eventAttributes = this.getNodeParameter('eventAttributes', i) as {
attributes: IDataObject[],
};
const body = {};
if (Object.entries(eventAttributes).length) {
if (eventAttributes.attributes !== undefined) {
eventAttributes.attributes.map(
attribute => {
// @ts-ignore
body[attribute.name as string] = attribute.value;
},
);
}
}
responseData = await homeAssistantApiRequest.call(this, 'POST', `/events/${eventType}`, body);
}
} else if (resource === 'log') {
if (operation === 'getErroLogs') {
responseData = await homeAssistantApiRequest.call(this, 'GET', '/error_log');
if (responseData) {
responseData = {
errorLog: responseData,
};
}
} else if (operation === 'getLogbookEntries') {
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
let endpoint = '/logbook';
if (Object.entries(additionalFields).length) {
if (additionalFields.startTime) {
endpoint = `/logbook/${additionalFields.startTime}`;
}
if (additionalFields.endTime) {
qs.end_time = additionalFields.endTime;
}
if (additionalFields.entityId) {
qs.entity = additionalFields.entityId;
}
}
responseData = await homeAssistantApiRequest.call(this, 'GET', endpoint, {}, qs);
}
} else if (resource === 'template') {
if (operation === 'create') {
const body = {
template: this.getNodeParameter('template', i) as string,
};
responseData = await homeAssistantApiRequest.call(this, 'POST', '/template', body);
if (responseData) {
responseData = { renderedTemplate: responseData };
}
}
} else if (resource === 'history') {
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
let endpoint = '/history/period';
if (Object.entries(additionalFields).length) {
if (additionalFields.startTime) {
endpoint = `/history/period/${additionalFields.startTime}`;
}
if (additionalFields.endTime) {
qs.end_time = additionalFields.endTime;
}
if (additionalFields.entityIds) {
qs.filter_entity_id = additionalFields.entityIds;
}
if (additionalFields.minimalResponse === true) {
qs.minimal_response = additionalFields.minimalResponse;
}
if (additionalFields.significantChangesOnly === true) {
qs.significant_changes_only = additionalFields.significantChangesOnly;
}
}
responseData = await homeAssistantApiRequest.call(this, 'GET', endpoint, {}, qs) as IDataObject[];
if (!returnAll) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.slice(0, limit);
}
}
} else if (resource === 'cameraProxy') {
if (operation === 'getScreenshot') {
const cameraEntityId = this.getNodeParameter('cameraEntityId', i) as string;
const dataPropertyNameDownload = this.getNodeParameter('binaryPropertyName', i) as string;
const endpoint = `/camera_proxy/${cameraEntityId}`;
let mimeType: string | undefined;
responseData = await homeAssistantApiRequest.call(this, 'GET', endpoint, {}, {}, undefined, {
encoding: null,
resolveWithFullResponse: true,
});
const newItem: INodeExecutionData = {
json: items[i].json,
binary: {},
};
if (mimeType === undefined && responseData.headers['content-type']) {
mimeType = responseData.headers['content-type'];
}
if (items[i].binary !== undefined) {
// Create a shallow copy of the binary data so that the old
// data references which do not get changed still stay behind
// but the incoming data does not get changed.
Object.assign(newItem.binary, items[i].binary);
}
items[i] = newItem;
const data = Buffer.from(responseData.body as string);
items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData(data, 'screenshot.jpg', mimeType);
}
}
} catch (error) {
if (this.continueOnFail()) {
if (resource === 'cameraProxy' && operation === 'get') {
items[i].json = { error: error.message };
} else {
returnData.push({ error: error.message });
}
continue;
}
throw error;
}
Array.isArray(responseData)
? returnData.push(...responseData)
: returnData.push(responseData);
}
if (resource === 'cameraProxy' && operation === 'getScreenshot') {
return this.prepareOutputData(items);
} else {
return [this.helpers.returnJsonArray(returnData)];
}
}
}

View file

@ -0,0 +1,78 @@
import {
INodeProperties
} from 'n8n-workflow';
export const logOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'log',
],
},
},
options: [
{
name: 'Get Error Logs',
value: 'getErroLogs',
description: 'Get a log for a specific entity',
},
{
name: 'Get Logbook Entries',
value: 'getLogbookEntries',
description: 'Get all logs',
},
],
default: 'getErroLogs',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const logFields = [
/* -------------------------------------------------------------------------- */
/* log:getLogbookEntries */
/* -------------------------------------------------------------------------- */
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'log',
],
operation: [
'getLogbookEntries',
],
},
},
options: [
{
displayName: 'End Time',
name: 'endTime',
type: 'dateTime',
default: '',
description: 'The end of the period.',
},
{
displayName: 'Entity ID',
name: 'entityId',
type: 'string',
default: '',
description: 'The entity ID.',
},
{
displayName: 'Start Time',
name: 'startTime',
type: 'dateTime',
default: '',
description: 'The beginning of the period.',
},
],
},
] as INodeProperties[];

View file

@ -0,0 +1,159 @@
import {
INodeProperties
} from 'n8n-workflow';
export const serviceOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'service',
],
},
},
options: [
{
name: 'Call',
value: 'call',
description: 'Call a service within a specific domain',
},
{
name: 'Get All',
value: 'getAll',
description: 'Get all services',
},
],
default: 'getAll',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const serviceFields = [
/* -------------------------------------------------------------------------- */
/* service:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'service',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'service',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
description: 'How many results to return.',
},
/* -------------------------------------------------------------------------- */
/* service:Call */
/* -------------------------------------------------------------------------- */
{
displayName: 'Domain',
name: 'domain',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: [
'service',
],
operation: [
'call',
],
},
},
},
{
displayName: 'Service',
name: 'service',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: [
'service',
],
operation: [
'call',
],
},
},
},
{
displayName: 'Service Attributes',
name: 'serviceAttributes',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Attribute',
default: {},
displayOptions: {
show: {
resource: [
'service',
],
operation: [
'call',
],
},
},
options: [
{
name: 'attributes',
displayName: 'Attributes',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the field.',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the field.',
},
],
},
],
},
] as INodeProperties[];

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