mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-02 07:01:30 -08:00
Merge branch 'master' of https://github.com/n8n-io/n8n into n8n-io-master
This commit is contained in:
commit
9f4e18eb8d
85
.github/workflows/test-workflows.yml
vendored
Normal file
85
.github/workflows/test-workflows.yml
vendored
Normal 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
|
|
@ -7,7 +7,7 @@
|
|||
"build": "lerna exec npm run build",
|
||||
"dev": "lerna exec npm run dev --parallel",
|
||||
"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:default": "cd packages/cli/bin && ./n8n",
|
||||
"start:windows": "cd packages/cli/bin && n8n",
|
||||
|
|
|
@ -2,6 +2,26 @@
|
|||
|
||||
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
|
||||
|
||||
### What changed?
|
||||
|
|
54
packages/cli/commands/Interfaces.d.ts
vendored
Normal file
54
packages/cli/commands/Interfaces.d.ts
vendored
Normal 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;
|
||||
}
|
|
@ -22,7 +22,7 @@ import {
|
|||
WorkflowRunner,
|
||||
} from '../src';
|
||||
|
||||
import {
|
||||
import {
|
||||
getLogger,
|
||||
} from '../src/Logger';
|
||||
|
||||
|
@ -46,6 +46,9 @@ export class Execute extends Command {
|
|||
id: flags.string({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
console.info('Execution was successful:');
|
||||
console.info('====================================');
|
||||
console.info(JSON.stringify(data, null, 2));
|
||||
if (flags.rawOutput === undefined) {
|
||||
this.log('Execution was successful:');
|
||||
this.log('====================================');
|
||||
}
|
||||
this.log(JSON.stringify(data, null, 2));
|
||||
} catch (e) {
|
||||
console.error('Error executing workflow. See log messages for details.');
|
||||
logger.error('\nExecution error:');
|
||||
|
|
796
packages/cli/commands/executeBatch.ts
Normal file
796
packages/cli/commands/executeBatch.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -65,6 +65,9 @@ export class ImportCredentialsCommand extends Command {
|
|||
|
||||
try {
|
||||
await Db.init();
|
||||
|
||||
// Make sure the settings exist
|
||||
await UserSettings.prepareUserSettings();
|
||||
let i;
|
||||
|
||||
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||
|
|
|
@ -18,6 +18,9 @@ import {
|
|||
import * as fs from 'fs';
|
||||
import * as glob from 'glob-promise';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
UserSettings,
|
||||
} from 'n8n-core';
|
||||
|
||||
export class ImportWorkflowsCommand extends Command {
|
||||
static description = 'Import workflows';
|
||||
|
@ -60,6 +63,9 @@ export class ImportWorkflowsCommand extends Command {
|
|||
|
||||
try {
|
||||
await Db.init();
|
||||
|
||||
// Make sure the settings exist
|
||||
await UserSettings.prepareUserSettings();
|
||||
let i;
|
||||
if (flags.separate) {
|
||||
const files = await glob((flags.input.endsWith(path.sep) ? flags.input : flags.input + path.sep) + '*.json');
|
||||
|
|
67
packages/cli/commands/list/workflow.ts
Normal file
67
packages/cli/commands/list/workflow.ts
Normal 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();
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "0.126.0",
|
||||
"version": "0.129.0",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"homepage": "https://n8n.io",
|
||||
|
@ -82,6 +82,7 @@
|
|||
"dependencies": {
|
||||
"@oclif/command": "^1.5.18",
|
||||
"@oclif/errors": "^1.2.2",
|
||||
"@types/json-diff": "^0.5.1",
|
||||
"@types/jsonwebtoken": "^8.5.2",
|
||||
"basic-auth": "^2.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
@ -101,15 +102,16 @@
|
|||
"glob-promise": "^3.4.0",
|
||||
"google-timezones-json": "^1.0.2",
|
||||
"inquirer": "^7.0.1",
|
||||
"json-diff": "^0.5.4",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwks-rsa": "~1.12.1",
|
||||
"localtunnel": "^2.0.0",
|
||||
"lodash.get": "^4.4.2",
|
||||
"mysql2": "~2.2.0",
|
||||
"n8n-core": "~0.75.0",
|
||||
"n8n-editor-ui": "~0.96.0",
|
||||
"n8n-nodes-base": "~0.123.0",
|
||||
"n8n-workflow": "~0.62.0",
|
||||
"n8n-core": "~0.77.0",
|
||||
"n8n-editor-ui": "~0.98.0",
|
||||
"n8n-nodes-base": "~0.126.0",
|
||||
"n8n-workflow": "~0.63.0",
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
"open": "^7.0.0",
|
||||
"pg": "^8.3.0",
|
||||
|
|
|
@ -188,6 +188,7 @@ export interface IExecutionsListResponse {
|
|||
count: number;
|
||||
// results: IExecutionShortResponse[];
|
||||
results: IExecutionsSummary[];
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
export interface IExecutionsStopData {
|
||||
|
|
|
@ -33,6 +33,7 @@ import {
|
|||
CredentialsHelper,
|
||||
CredentialsOverwrites,
|
||||
CredentialTypes,
|
||||
DatabaseType,
|
||||
Db,
|
||||
ExternalHooks,
|
||||
GenericHelpers,
|
||||
|
@ -88,6 +89,7 @@ import {
|
|||
IRunData,
|
||||
IWorkflowBase,
|
||||
IWorkflowCredentials,
|
||||
LoggerProxy,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
|
@ -1612,8 +1614,7 @@ class App {
|
|||
executingWorkflowIds.push(...this.activeExecutionsInstance.getActiveExecutions().map(execution => execution.id.toString()) as string[]);
|
||||
|
||||
const countFilter = JSON.parse(JSON.stringify(filter));
|
||||
countFilter.select = ['id'];
|
||||
countFilter.where = {id: Not(In(executingWorkflowIds))};
|
||||
countFilter.id = Not(In(executingWorkflowIds));
|
||||
|
||||
const resultsQuery = await Db.collections.Execution!
|
||||
.createQueryBuilder("execution")
|
||||
|
@ -1645,10 +1646,10 @@ class App {
|
|||
|
||||
const resultsPromise = resultsQuery.getMany();
|
||||
|
||||
const countPromise = Db.collections.Execution!.count(countFilter);
|
||||
const countPromise = getExecutionsCount(countFilter);
|
||||
|
||||
const results: IExecutionFlattedDb[] = await resultsPromise;
|
||||
const count = await countPromise;
|
||||
const countedObjects = await countPromise;
|
||||
|
||||
const returnResults: IExecutionsSummary[] = [];
|
||||
|
||||
|
@ -1667,8 +1668,9 @@ class App {
|
|||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
count: countedObjects.count,
|
||||
results: returnResults,
|
||||
estimated: countedObjects.estimate,
|
||||
};
|
||||
}));
|
||||
|
||||
|
@ -2161,3 +2163,35 @@ export async function start(): Promise<void> {
|
|||
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 };
|
||||
}
|
||||
|
|
|
@ -387,9 +387,9 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
|
|||
}
|
||||
|
||||
// Leave log message before flatten as that operation increased memory usage a lot and the chance of a crash is highest here
|
||||
Logger.debug(`Save execution data to database for execution ID ${this.executionId}`, {
|
||||
executionId: this.executionId,
|
||||
workflowId: this.workflowData.id,
|
||||
Logger.debug(`Save execution data to database for execution ID ${this.executionId}`, {
|
||||
executionId: this.executionId,
|
||||
workflowId: this.workflowData.id,
|
||||
finished: fullExecutionData.finished,
|
||||
stoppedAt: fullExecutionData.stoppedAt,
|
||||
});
|
||||
|
@ -409,12 +409,12 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
|
|||
executeErrorWorkflow(this.workflowData, fullRunData, this.mode, this.executionId, this.retryOf);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, {
|
||||
executionId: this.executionId,
|
||||
Logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, {
|
||||
executionId: this.executionId,
|
||||
workflowId: this.workflowData.id,
|
||||
error,
|
||||
});
|
||||
|
||||
|
||||
if (!isManualMode) {
|
||||
executeErrorWorkflow(this.workflowData, fullRunData, this.mode, undefined, this.retryOf);
|
||||
}
|
||||
|
@ -608,44 +608,78 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi
|
|||
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
|
||||
// calling workflow.
|
||||
const credentials = await WorkflowCredentials(workflowData!.nodes);
|
||||
|
||||
// Get the needed credentials for the current workflow as they will differ to the ones of the
|
||||
// calling workflow.
|
||||
const credentials = await WorkflowCredentials(workflowData!.nodes);
|
||||
// Create new additionalData to have different workflow loaded and to call
|
||||
// different webooks
|
||||
const additionalDataIntegrated = await getBase(credentials);
|
||||
additionalDataIntegrated.hooks = getWorkflowHooksIntegrated(runData.executionMode, executionId, workflowData!, { parentProcessMode: additionalData.hooks!.mode });
|
||||
// Make sure we pass on the original executeWorkflow function we received
|
||||
// This one already contains changes to talk to parent process
|
||||
// and get executionID from `activeExecutions` running on main process
|
||||
additionalDataIntegrated.executeWorkflow = additionalData.executeWorkflow;
|
||||
|
||||
let subworkflowTimeout = additionalData.executionTimeoutTimestamp;
|
||||
if (workflowData.settings?.executionTimeout !== undefined && workflowData.settings.executionTimeout > 0) {
|
||||
// We might have received a max timeout timestamp from the parent workflow
|
||||
// If we did, then we get the minimum time between the two timeouts
|
||||
// If no timeout was given from the parent, then we use our timeout.
|
||||
subworkflowTimeout = Math.min(additionalData.executionTimeoutTimestamp || Number.MAX_SAFE_INTEGER, Date.now() + (workflowData.settings.executionTimeout as number * 1000));
|
||||
}
|
||||
|
||||
// Create new additionalData to have different workflow loaded and to call
|
||||
// different webooks
|
||||
const additionalDataIntegrated = await getBase(credentials);
|
||||
additionalDataIntegrated.hooks = getWorkflowHooksIntegrated(runData.executionMode, executionId, workflowData!, { parentProcessMode: additionalData.hooks!.mode });
|
||||
// Make sure we pass on the original executeWorkflow function we received
|
||||
// This one already contains changes to talk to parent process
|
||||
// and get executionID from `activeExecutions` running on main process
|
||||
additionalDataIntegrated.executeWorkflow = additionalData.executeWorkflow;
|
||||
additionalDataIntegrated.executionTimeoutTimestamp = subworkflowTimeout;
|
||||
|
||||
let subworkflowTimeout = additionalData.executionTimeoutTimestamp;
|
||||
if (workflowData.settings?.executionTimeout !== undefined && workflowData.settings.executionTimeout > 0) {
|
||||
// We might have received a max timeout timestamp from the parent workflow
|
||||
// If we did, then we get the minimum time between the two timeouts
|
||||
// If no timeout was given from the parent, then we use our timeout.
|
||||
subworkflowTimeout = Math.min(additionalData.executionTimeoutTimestamp || Number.MAX_SAFE_INTEGER, Date.now() + (workflowData.settings.executionTimeout as number * 1000));
|
||||
}
|
||||
const runExecutionData = runData.executionData as IRunExecutionData;
|
||||
|
||||
additionalDataIntegrated.executionTimeoutTimestamp = subworkflowTimeout;
|
||||
|
||||
|
||||
// Execute the workflow
|
||||
const workflowExecute = new WorkflowExecute(additionalDataIntegrated, runData.executionMode, runExecutionData);
|
||||
if (parentExecutionId !== undefined) {
|
||||
// Must be changed to become typed
|
||||
return {
|
||||
// Execute the workflow
|
||||
const workflowExecute = new WorkflowExecute(additionalDataIntegrated, runData.executionMode, runExecutionData);
|
||||
if (parentExecutionId !== undefined) {
|
||||
// Must be changed to become typed
|
||||
return {
|
||||
startedAt: new Date(),
|
||||
workflow,
|
||||
workflowExecute,
|
||||
};
|
||||
}
|
||||
data = await workflowExecute.processRunExecutionData(workflow);
|
||||
} catch (error) {
|
||||
const fullRunData: IRun = {
|
||||
data: {
|
||||
resultData: {
|
||||
error,
|
||||
runData: {},
|
||||
},
|
||||
},
|
||||
finished: false,
|
||||
mode: 'integrated',
|
||||
startedAt: new Date(),
|
||||
workflow,
|
||||
workflowExecute,
|
||||
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,
|
||||
};
|
||||
}
|
||||
const data = await workflowExecute.processRunExecutionData(workflow);
|
||||
|
||||
await externalHooks.run('workflow.postExecute', [data, workflowData]);
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
IBullJobResponse,
|
||||
ICredentialsOverwrite,
|
||||
ICredentialsTypeData,
|
||||
IExecutionDb,
|
||||
IExecutionFlattedDb,
|
||||
IExecutionResponse,
|
||||
IProcessMessageDataHook,
|
||||
|
@ -29,6 +30,7 @@ import {
|
|||
import {
|
||||
ExecutionError,
|
||||
IRun,
|
||||
IWorkflowBase,
|
||||
LoggerProxy as Logger,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
|
@ -85,11 +87,15 @@ export class WorkflowRunner {
|
|||
* @param {string} executionId
|
||||
* @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 = {
|
||||
data: {
|
||||
resultData: {
|
||||
error,
|
||||
error: {
|
||||
...error,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
runData: {},
|
||||
},
|
||||
},
|
||||
|
@ -102,6 +108,10 @@ export class WorkflowRunner {
|
|||
// Remove from active execution with empty data. That will
|
||||
// set the execution to failed.
|
||||
this.activeExecutions.remove(executionId, fullRunData);
|
||||
|
||||
if (hooks) {
|
||||
await hooks.executeHookFunctions('workflowExecuteAfter', [fullRunData]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -159,7 +169,6 @@ export class WorkflowRunner {
|
|||
|
||||
const nodeTypes = NodeTypes();
|
||||
|
||||
|
||||
// Soft timeout to stop workflow execution after current running node
|
||||
// Changes were made by adding the `workflowTimeout` to the `additionalData`
|
||||
// So that the timeout will also work for executions with nested workflows.
|
||||
|
@ -178,47 +187,55 @@ export class WorkflowRunner {
|
|||
|
||||
// Register the active execution
|
||||
const executionId = await this.activeExecutions.add(data, undefined);
|
||||
Logger.verbose(`Execution for workflow ${data.workflowData.name} was assigned id ${executionId}`, {executionId});
|
||||
|
||||
additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId, true);
|
||||
|
||||
additionalData.sendMessageToUI = WorkflowExecuteAdditionalData.sendMessageToUI.bind({sessionId: data.sessionId});
|
||||
|
||||
let workflowExecution: PCancelable<IRun>;
|
||||
if (data.executionData !== undefined) {
|
||||
Logger.debug(`Execution ID ${executionId} had Execution data. Running with payload.`, {executionId});
|
||||
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode, data.executionData);
|
||||
workflowExecution = workflowExecute.processRunExecutionData(workflow);
|
||||
} else if (data.runData === undefined || data.startNodes === undefined || data.startNodes.length === 0 || data.destinationNode === undefined) {
|
||||
Logger.debug(`Execution ID ${executionId} will run executing all nodes.`, {executionId});
|
||||
// Execute all nodes
|
||||
|
||||
// Can execute without webhook so go on
|
||||
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode);
|
||||
workflowExecution = workflowExecute.run(workflow, undefined, data.destinationNode);
|
||||
} else {
|
||||
Logger.debug(`Execution ID ${executionId} is a partial execution.`, {executionId});
|
||||
// Execute only the nodes between start and destination nodes
|
||||
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode);
|
||||
workflowExecution = workflowExecute.runPartialWorkflow(workflow, data.runData, data.startNodes, data.destinationNode);
|
||||
}
|
||||
try {
|
||||
Logger.verbose(`Execution for workflow ${data.workflowData.name} was assigned id ${executionId}`, { executionId });
|
||||
additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId, true);
|
||||
additionalData.sendMessageToUI = WorkflowExecuteAdditionalData.sendMessageToUI.bind({sessionId: data.sessionId});
|
||||
|
||||
this.activeExecutions.attachWorkflowExecution(executionId, workflowExecution);
|
||||
if (data.executionData !== undefined) {
|
||||
Logger.debug(`Execution ID ${executionId} had Execution data. Running with payload.`, {executionId});
|
||||
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode, data.executionData);
|
||||
workflowExecution = workflowExecute.processRunExecutionData(workflow);
|
||||
} else if (data.runData === undefined || data.startNodes === undefined || data.startNodes.length === 0 || data.destinationNode === undefined) {
|
||||
Logger.debug(`Execution ID ${executionId} will run executing all nodes.`, {executionId});
|
||||
// Execute all nodes
|
||||
|
||||
if (workflowTimeout > 0) {
|
||||
const timeout = Math.min(workflowTimeout, config.get('executions.maxTimeout') as number) * 1000; // as seconds
|
||||
executionTimeout = setTimeout(() => {
|
||||
this.activeExecutions.stopExecution(executionId, 'timeout');
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
workflowExecution.then((fullRunData) => {
|
||||
clearTimeout(executionTimeout);
|
||||
if (workflowExecution.isCanceled) {
|
||||
fullRunData.finished = false;
|
||||
// Can execute without webhook so go on
|
||||
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode);
|
||||
workflowExecution = workflowExecute.run(workflow, undefined, data.destinationNode);
|
||||
} else {
|
||||
Logger.debug(`Execution ID ${executionId} is a partial execution.`, {executionId});
|
||||
// Execute only the nodes between start and destination nodes
|
||||
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode);
|
||||
workflowExecution = workflowExecute.runPartialWorkflow(workflow, data.runData, data.startNodes, data.destinationNode);
|
||||
}
|
||||
this.activeExecutions.remove(executionId, fullRunData);
|
||||
});
|
||||
|
||||
this.activeExecutions.attachWorkflowExecution(executionId, workflowExecution);
|
||||
|
||||
if (workflowTimeout > 0) {
|
||||
const timeout = Math.min(workflowTimeout, config.get('executions.maxTimeout') as number) * 1000; // as seconds
|
||||
executionTimeout = setTimeout(() => {
|
||||
this.activeExecutions.stopExecution(executionId, 'timeout');
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
workflowExecution.then((fullRunData) => {
|
||||
clearTimeout(executionTimeout);
|
||||
if (workflowExecution.isCanceled) {
|
||||
fullRunData.finished = false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
@ -247,39 +264,48 @@ export class WorkflowRunner {
|
|||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
};
|
||||
const job = await this.jobQueue.add(jobData, jobOptions);
|
||||
console.log('Started with ID: ' + job.id.toString());
|
||||
let job: Bull.Job;
|
||||
let hooks: WorkflowHooks;
|
||||
try {
|
||||
job = await this.jobQueue.add(jobData, jobOptions);
|
||||
|
||||
const hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerMain(data.executionMode, executionId, data.workflowData, { retryOf: data.retryOf ? data.retryOf.toString() : undefined });
|
||||
console.log('Started with ID: ' + job.id.toString());
|
||||
|
||||
// Normally also workflow should be supplied here but as it only used for sending
|
||||
// data to editor-UI is not needed.
|
||||
hooks.executeHookFunctions('workflowExecuteBefore', []);
|
||||
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
|
||||
// data to editor-UI is not needed.
|
||||
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) => {
|
||||
onCancel.shouldReject = false;
|
||||
onCancel(async () => {
|
||||
await Queue.getInstance().stopJob(job);
|
||||
|
||||
const fullRunData :IRun = {
|
||||
data: {
|
||||
resultData: {
|
||||
error: new WorkflowOperationError('Workflow has been canceled!'),
|
||||
runData: {},
|
||||
},
|
||||
},
|
||||
mode: data.executionMode,
|
||||
startedAt: new Date(),
|
||||
stoppedAt: new Date(),
|
||||
};
|
||||
this.activeExecutions.remove(executionId, fullRunData);
|
||||
resolve(fullRunData);
|
||||
// We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
|
||||
// "workflowExecuteAfter" which we require.
|
||||
const hooksWorker = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter(data.executionMode, executionId, data.workflowData, { retryOf: data.retryOf ? data.retryOf.toString() : undefined });
|
||||
|
||||
const error = new WorkflowOperationError('Workflow-Execution has been canceled!');
|
||||
await this.processError(error, new Date(), data.executionMode, executionId, hooksWorker);
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
const jobData: Promise<IBullJobResponse> = job.finished();
|
||||
|
||||
const queueRecoveryInterval = config.get('queue.bull.queueRecoveryInterval') as number;
|
||||
|
||||
const racingPromises: Array<Promise<IBullJobResponse | object>> = [jobData];
|
||||
|
||||
let clearWatchdogInterval;
|
||||
if (queueRecoveryInterval > 0) {
|
||||
/*************************************************
|
||||
* Long explanation about what this solves: *
|
||||
|
@ -295,7 +321,7 @@ export class WorkflowRunner {
|
|||
*************************************************/
|
||||
let watchDogInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
const watchDog = new Promise((res) => {
|
||||
const watchDog: Promise<object> = new Promise((res) => {
|
||||
watchDogInterval = setInterval(async () => {
|
||||
const currentJob = await this.jobQueue.getJob(job.id);
|
||||
// When null means job is finished (not found in queue)
|
||||
|
@ -306,22 +332,33 @@ export class WorkflowRunner {
|
|||
}, queueRecoveryInterval * 1000);
|
||||
});
|
||||
|
||||
racingPromises.push(watchDog);
|
||||
|
||||
const clearWatchdogInterval = () => {
|
||||
clearWatchdogInterval = () => {
|
||||
if (watchDogInterval) {
|
||||
clearInterval(watchDogInterval);
|
||||
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 fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb) as IExecutionResponse;
|
||||
|
@ -333,7 +370,6 @@ export class WorkflowRunner {
|
|||
stoppedAt: fullExecutionData.stoppedAt,
|
||||
} as IRun;
|
||||
|
||||
|
||||
this.activeExecutions.remove(executionId, runData);
|
||||
// Normally also static data should be supplied here but as it only used for sending
|
||||
// data to editor-UI is not needed.
|
||||
|
@ -427,8 +463,13 @@ export class WorkflowRunner {
|
|||
|
||||
const workflowHooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId);
|
||||
|
||||
// Send all data to subprocess it needs to run the workflow
|
||||
subprocess.send({ type: 'startWorkflow', data } as IProcessMessage);
|
||||
try {
|
||||
// Send all data to subprocess it needs to run the workflow
|
||||
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
|
||||
let executionTimeout: NodeJS.Timeout;
|
||||
|
@ -476,14 +517,14 @@ export class WorkflowRunner {
|
|||
} else if (message.type === 'processError') {
|
||||
clearTimeout(executionTimeout);
|
||||
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') {
|
||||
this.processHookMessage(workflowHooks, message.data as IProcessMessageDataHook);
|
||||
} else if (message.type === 'timeout') {
|
||||
// Execution timed out and its process has been terminated
|
||||
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);
|
||||
} else if (message.type === 'startExecution') {
|
||||
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
|
||||
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) {
|
||||
Logger.debug(`Subprocess for execution ID ${executionId} finished with error code ${code}.`, {executionId});
|
||||
// Process did exit with error code, so something went wrong.
|
||||
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) {
|
||||
|
|
|
@ -30,6 +30,7 @@ import {
|
|||
IWorkflowExecuteHooks,
|
||||
LoggerProxy,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
WorkflowHooks,
|
||||
WorkflowOperationError,
|
||||
} from 'n8n-workflow';
|
||||
|
@ -315,7 +316,7 @@ process.on('message', async (message: IProcessMessage) => {
|
|||
for (const executionId of executionIds) {
|
||||
const childWorkflowExecute = workflowRunner.childExecutions[executionId];
|
||||
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
|
||||
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
|
||||
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
|
||||
await workflowRunner.workflowExecute.processSuccessExecution(workflowRunner.startedAt, workflowRunner.workflow!, timeOutError);
|
||||
|
@ -336,8 +337,8 @@ process.on('message', async (message: IProcessMessage) => {
|
|||
runData: {},
|
||||
},
|
||||
},
|
||||
finished: message.type !== 'timeout',
|
||||
mode: workflowRunner.data!.executionMode,
|
||||
finished: false,
|
||||
mode: workflowRunner.data ? workflowRunner.data!.executionMode : 'own' as WorkflowExecuteMode,
|
||||
startedAt: workflowRunner.startedAt,
|
||||
stoppedAt: new Date(),
|
||||
};
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"es2017"
|
||||
"es2017",
|
||||
"ES2020.Promise"
|
||||
],
|
||||
"types": [
|
||||
"node",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-core",
|
||||
"version": "0.75.0",
|
||||
"version": "0.77.0",
|
||||
"description": "Core functionality of n8n",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"homepage": "https://n8n.io",
|
||||
|
@ -47,7 +47,7 @@
|
|||
"file-type": "^14.6.2",
|
||||
"lodash.get": "^4.4.2",
|
||||
"mime-types": "^2.1.27",
|
||||
"n8n-workflow": "~0.62.0",
|
||||
"n8n-workflow": "~0.63.0",
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
"p-cancelable": "^2.0.0",
|
||||
"request": "^2.88.2",
|
||||
|
|
|
@ -807,7 +807,7 @@ export class WorkflowExecute {
|
|||
})()
|
||||
.then(async () => {
|
||||
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);
|
||||
})
|
||||
|
@ -844,7 +844,11 @@ export class WorkflowExecute {
|
|||
|
||||
if (executionError !== undefined) {
|
||||
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 {
|
||||
Logger.verbose(`Workflow execution finished successfully`, { workflowId: workflow.id });
|
||||
fullRunData.finished = true;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-editor-ui",
|
||||
"version": "0.96.0",
|
||||
"version": "0.98.0",
|
||||
"description": "Workflow Editor UI for n8n",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"homepage": "https://n8n.io",
|
||||
|
@ -68,7 +68,7 @@
|
|||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.set": "^4.3.2",
|
||||
"n8n-workflow": "~0.62.0",
|
||||
"n8n-workflow": "~0.63.0",
|
||||
"node-sass": "^4.12.0",
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"prismjs": "^1.17.1",
|
||||
|
|
|
@ -202,7 +202,7 @@ export interface IVariableSelectorOption {
|
|||
|
||||
// Simple version of n8n-workflow.Workflow
|
||||
export interface IWorkflowData {
|
||||
id?: string;
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
nodes: INode[];
|
||||
|
@ -212,7 +212,7 @@ export interface IWorkflowData {
|
|||
}
|
||||
|
||||
export interface IWorkflowDataUpdate {
|
||||
id?: string;
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
nodes?: INode[];
|
||||
connections?: IConnections;
|
||||
|
@ -325,6 +325,7 @@ export interface IExecutionShortResponse {
|
|||
export interface IExecutionsListResponse {
|
||||
count: number;
|
||||
results: IExecutionsSummary[];
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
export interface IExecutionsCurrentSummaryExtended {
|
||||
|
|
|
@ -14,6 +14,10 @@
|
|||
<div v-if="!binaryData">
|
||||
Data to display did not get found
|
||||
</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"/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<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">
|
||||
<el-row>
|
||||
<el-col :span="4" class="filter-headline">
|
||||
|
@ -38,7 +38,7 @@
|
|||
|
||||
<div class="selection-options">
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
|
@ -142,7 +142,7 @@
|
|||
</el-table-column>
|
||||
</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">
|
||||
<font-awesome-icon icon="sync" /> Load More
|
||||
</el-button>
|
||||
|
@ -200,6 +200,7 @@ export default mixins(
|
|||
return {
|
||||
finishedExecutions: [] as IExecutionsSummary[],
|
||||
finishedExecutionsCount: 0,
|
||||
finishedExecutionsCountEstimated: false,
|
||||
|
||||
checkAll: false,
|
||||
autoRefresh: true,
|
||||
|
@ -256,7 +257,7 @@ export default mixins(
|
|||
return returnData;
|
||||
},
|
||||
combinedExecutionsCount (): number {
|
||||
return this.activeExecutions.length + this.finishedExecutionsCount;
|
||||
return 0 + this.activeExecutions.length + this.finishedExecutionsCount;
|
||||
},
|
||||
numSelected (): number {
|
||||
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.finishedExecutionsCount = results[0].count;
|
||||
this.finishedExecutionsCountEstimated = results[0].estimated;
|
||||
},
|
||||
async loadFinishedExecutions (): Promise<void> {
|
||||
if (this.filter.status === 'running') {
|
||||
this.finishedExecutions = [];
|
||||
this.finishedExecutionsCount = 0;
|
||||
this.finishedExecutionsCountEstimated = false;
|
||||
return;
|
||||
}
|
||||
const data = await this.restApi().getPastExecutions(this.workflowFilterPast, this.requestItemsPerRequest);
|
||||
this.finishedExecutions = data.results;
|
||||
this.finishedExecutionsCount = data.count;
|
||||
this.finishedExecutionsCountEstimated = data.estimated;
|
||||
},
|
||||
async loadMore () {
|
||||
if (this.filter.status === 'running') {
|
||||
|
@ -526,6 +530,7 @@ export default mixins(
|
|||
|
||||
this.finishedExecutions.push.apply(this.finishedExecutions, data.results);
|
||||
this.finishedExecutionsCount = data.count;
|
||||
this.finishedExecutionsCountEstimated = data.estimated;
|
||||
|
||||
this.isDataLoading = false;
|
||||
},
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
@keydown.esc="onEscape"
|
||||
ref="input"
|
||||
size="4"
|
||||
v-click-outside="onBlur"
|
||||
v-click-outside="onClickOutside"
|
||||
/>
|
||||
</ExpandableInputBase>
|
||||
</template>
|
||||
|
@ -47,8 +47,10 @@ export default Vue.extend({
|
|||
onEnter() {
|
||||
this.$emit('enter', (this.$refs.input as HTMLInputElement).value);
|
||||
},
|
||||
onBlur() {
|
||||
this.$emit('blur', (this.$refs.input as HTMLInputElement).value);
|
||||
onClickOutside(e: Event) {
|
||||
if (e.type === 'click') {
|
||||
this.$emit('blur', (this.$refs.input as HTMLInputElement).value);
|
||||
}
|
||||
},
|
||||
onEscape() {
|
||||
this.$emit('esc');
|
||||
|
|
|
@ -408,6 +408,9 @@ export default mixins(
|
|||
const workflowData = await this.getWorkflowDataToSave();
|
||||
|
||||
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)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
@before-leave="beforeLeave"
|
||||
@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
|
||||
:item="item"
|
||||
:active="activeIndex === index && !disabled"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</SlideTransition>
|
||||
|
@ -29,9 +29,17 @@ export default Vue.extend({
|
|||
props: [
|
||||
'active',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
allNodeTypes: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
nodeTypes(): INodeTypeDescription[] {
|
||||
return this.$store.getters.allNodeTypes;
|
||||
},
|
||||
visibleNodeTypes(): INodeTypeDescription[] {
|
||||
return this.$store.getters.allNodeTypes
|
||||
return this.allNodeTypes
|
||||
.filter((nodeType: INodeTypeDescription) => {
|
||||
return !HIDDEN_NODES.includes(nodeType.name);
|
||||
});
|
||||
|
@ -64,13 +72,22 @@ export default Vue.extend({
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
closeCreator () {
|
||||
this.$emit('closeNodeCreator');
|
||||
onClickOutside (e: Event) {
|
||||
if (e.type === 'click') {
|
||||
this.$emit('closeNodeCreator');
|
||||
}
|
||||
},
|
||||
nodeTypeSelected (nodeTypeName: string) {
|
||||
this.$emit('nodeTypeSelected', nodeTypeName);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
nodeTypes(newList, prevList) {
|
||||
if (prevList.length === 0) {
|
||||
this.allNodeTypes = newList;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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
|
||||
:popperAppendToBody="false"
|
||||
:value="appliedTags"
|
||||
|
@ -215,8 +215,10 @@ export default mixins(showMessage).extend({
|
|||
this.focusOnInput();
|
||||
});
|
||||
},
|
||||
onBlur() {
|
||||
this.$emit('blur');
|
||||
onClickOutside(e: Event) {
|
||||
if (e.type === 'click') {
|
||||
this.$emit('blur');
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
<template slot-scope="scope">
|
||||
<div :key="scope.row.id">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
@ -124,6 +124,11 @@ export default mixins(
|
|||
updateTagsFilter(tags: string[]) {
|
||||
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
|
||||
if (column.label !== 'Active') {
|
||||
|
||||
|
|
|
@ -213,33 +213,16 @@ export const pushConnection = mixins(
|
|||
|
||||
const runDataExecuted = pushData.data;
|
||||
|
||||
let runDataExecutedErrorMessage;
|
||||
const runDataExecutedErrorMessage = this.$getExecutionError(runDataExecuted.data.resultData.error);
|
||||
|
||||
// @ts-ignore
|
||||
const workflow = this.getWorkflow();
|
||||
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.$showMessage({
|
||||
title: 'Problem executing workflow',
|
||||
message: errorMessage,
|
||||
message: runDataExecutedErrorMessage,
|
||||
type: 'error',
|
||||
});
|
||||
} else {
|
||||
|
|
|
@ -3,6 +3,7 @@ import { ElNotificationOptions } from 'element-ui/types/notification';
|
|||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
import { ExecutionError } from 'n8n-workflow';
|
||||
|
||||
export const showMessage = mixins(externalHooks).extend({
|
||||
methods: {
|
||||
|
@ -15,6 +16,27 @@ export const showMessage = mixins(externalHooks).extend({
|
|||
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) {
|
||||
const messageLine = message ? `${message}<br/>` : '';
|
||||
this.$showMessage({
|
||||
|
|
|
@ -31,9 +31,6 @@ const module: Module<IUiState, IRootState> = {
|
|||
isModalActive: (state: IUiState) => {
|
||||
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,
|
||||
},
|
||||
mutations: {
|
||||
|
|
|
@ -375,6 +375,39 @@ export default mixins(
|
|||
});
|
||||
|
||||
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) {
|
||||
this.setLoadingText('Loading template');
|
||||
|
@ -514,15 +547,29 @@ export default mixins(
|
|||
// else which should ignore the default keybindings
|
||||
for (let index = 0; index < path.length; index++) {
|
||||
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')
|
||||
)) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -533,15 +580,12 @@ export default mixins(
|
|||
e.preventDefault();
|
||||
|
||||
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') {
|
||||
this.createNodeActive = !this.createNodeActive && !this.isReadOnly;
|
||||
} else if (e.key === this.controlKeyCode) {
|
||||
this.ctrlKeyPressed = true;
|
||||
} else if (e.key === 'F2') {
|
||||
} else if (e.key === 'F2' && !this.isReadOnly) {
|
||||
const lastSelectedNode = this.lastSelectedNode;
|
||||
if (lastSelectedNode !== null) {
|
||||
this.callDebounced('renameNodePrompt', 1500, lastSelectedNode.name);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-node-dev",
|
||||
"version": "0.15.0",
|
||||
"version": "0.17.0",
|
||||
"description": "CLI to simplify n8n credentials/node development",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"homepage": "https://n8n.io",
|
||||
|
@ -59,8 +59,8 @@
|
|||
"change-case": "^4.1.1",
|
||||
"copyfiles": "^2.1.1",
|
||||
"inquirer": "^7.0.1",
|
||||
"n8n-core": "~0.75.0",
|
||||
"n8n-workflow": "~0.62.0",
|
||||
"n8n-core": "~0.77.0",
|
||||
"n8n-workflow": "~0.63.0",
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
"replace-in-file": "^6.0.0",
|
||||
"request": "^2.88.2",
|
||||
|
|
|
@ -3,11 +3,18 @@ import {
|
|||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class TaigaServerApi implements ICredentialType {
|
||||
name = 'taigaServerApi';
|
||||
displayName = 'Taiga Server API';
|
||||
documentationUrl = 'taiga';
|
||||
// https://api.baserow.io/api/redoc/#section/Authentication
|
||||
|
||||
export class BaserowApi implements ICredentialType {
|
||||
name = 'baserowApi';
|
||||
displayName = 'Baserow API';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Host',
|
||||
name: 'host',
|
||||
type: 'string',
|
||||
default: 'https://api.baserow.io',
|
||||
},
|
||||
{
|
||||
displayName: 'Username',
|
||||
name: 'username',
|
||||
|
@ -19,13 +26,9 @@ export class TaigaServerApi implements ICredentialType {
|
|||
name: 'password',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'https://taiga.yourdomain.com',
|
||||
typeOptions: {
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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: '',
|
||||
},
|
||||
];
|
||||
}
|
|
@ -57,5 +57,12 @@ export class MicrosoftSql implements ICredentialType {
|
|||
default: 15000,
|
||||
description: 'Connection timeout in ms.',
|
||||
},
|
||||
{
|
||||
displayName: 'Request Timeout',
|
||||
name: 'requestTimeout',
|
||||
type: 'number',
|
||||
default: 15000,
|
||||
description: ' Request timeout in ms.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
@ -11,19 +11,35 @@ export class SalesforceOAuth2Api implements ICredentialType {
|
|||
displayName = 'Salesforce OAuth2 API';
|
||||
documentationUrl = 'salesforce';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Environment Type',
|
||||
name: 'environment',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Production',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
name: 'Sandbox',
|
||||
value: 'sandbox',
|
||||
},
|
||||
],
|
||||
default: 'production',
|
||||
},
|
||||
{
|
||||
displayName: 'Authorization URL',
|
||||
name: 'authUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://login.salesforce.com/services/oauth2/authorize',
|
||||
required: true,
|
||||
default: '={{ $self["environment"] === "sandbox" ? "https://test.salesforce.com/services/oauth2/authorize" : "https://login.salesforce.com/services/oauth2/authorize" }}',
|
||||
},
|
||||
{
|
||||
displayName: 'Access Token URL',
|
||||
name: 'accessTokenUrl',
|
||||
type: 'string',
|
||||
default: 'https://yourcompany.salesforce.com/services/oauth2/token',
|
||||
type: 'hidden',
|
||||
required: true,
|
||||
default: '={{ $self["environment"] === "sandbox" ? "https://test.salesforce.com/services/oauth2/token" : "https://login.salesforce.com/services/oauth2/token" }}',
|
||||
},
|
||||
{
|
||||
displayName: 'Scope',
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
}
|
|
@ -6,7 +6,7 @@ import {
|
|||
|
||||
export class StripeApi implements ICredentialType {
|
||||
name = 'stripeApi';
|
||||
displayName = 'Stripe Api';
|
||||
displayName = 'Stripe API';
|
||||
documentationUrl = 'stripe';
|
||||
properties: INodeProperties[] = [
|
||||
// The credentials to get from user and save encrypted.
|
||||
|
|
54
packages/nodes-base/credentials/TaigaApi.credentials.ts
Normal file
54
packages/nodes-base/credentials/TaigaApi.credentials.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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: '',
|
||||
},
|
||||
];
|
||||
}
|
|
@ -163,7 +163,7 @@ export function activeCampaignDefaultGetAllProperties(resource: string, operatio
|
|||
description: 'How many results to return.',
|
||||
},
|
||||
{
|
||||
displayName: 'Simple',
|
||||
displayName: 'Simplify Response',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
|
@ -177,7 +177,7 @@ export function activeCampaignDefaultGetAllProperties(resource: string, operatio
|
|||
},
|
||||
},
|
||||
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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
@ -221,7 +221,7 @@ export class Airtable implements INodeType {
|
|||
},
|
||||
},
|
||||
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',
|
||||
|
@ -390,7 +390,7 @@ export class Airtable implements INodeType {
|
|||
},
|
||||
|
||||
// ----------------------------------
|
||||
// append + update
|
||||
// append + delete + update
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Options',
|
||||
|
@ -401,12 +401,24 @@ export class Airtable implements INodeType {
|
|||
show: {
|
||||
operation: [
|
||||
'append',
|
||||
'delete',
|
||||
'update',
|
||||
],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
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',
|
||||
name: 'ignoreFields',
|
||||
|
@ -428,6 +440,14 @@ export class Airtable implements INodeType {
|
|||
displayName: 'Typecast',
|
||||
name: 'typecast',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': [
|
||||
'append',
|
||||
'update',
|
||||
],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
description: 'If the Airtable API should attempt mapping of string values for linked records & select options.',
|
||||
},
|
||||
|
@ -465,54 +485,81 @@ export class Airtable implements INodeType {
|
|||
let fields: string[];
|
||||
let options: IDataObject;
|
||||
|
||||
const rows: IDataObject[] = [];
|
||||
let bulkSize = 10;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
addAllFields = this.getNodeParameter('addAllFields', i) as boolean;
|
||||
options = this.getNodeParameter('options', i, {}) as IDataObject;
|
||||
bulkSize = options.bulkSize as number || bulkSize;
|
||||
|
||||
const row: IDataObject = {};
|
||||
|
||||
if (addAllFields === true) {
|
||||
// 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 {
|
||||
// Add only the specified fields
|
||||
body.fields = {} as IDataObject;
|
||||
row.fields = {} as IDataObject;
|
||||
|
||||
fields = this.getNodeParameter('fields', i, []) as string[];
|
||||
|
||||
for (const fieldName of fields) {
|
||||
// @ts-ignore
|
||||
body.fields[fieldName] = items[i].json[fieldName];
|
||||
row.fields[fieldName] = items[i].json[fieldName];
|
||||
}
|
||||
}
|
||||
|
||||
if (options.typecast === true) {
|
||||
body['typecast'] = true;
|
||||
rows.push(row);
|
||||
|
||||
if (rows.length === bulkSize || i === items.length - 1) {
|
||||
if (options.typecast === true) {
|
||||
body['typecast'] = true;
|
||||
}
|
||||
|
||||
body['records'] = rows;
|
||||
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
|
||||
|
||||
returnData.push(...responseData.records);
|
||||
// empty rows
|
||||
rows.length = 0;
|
||||
}
|
||||
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
|
||||
|
||||
returnData.push(responseData);
|
||||
}
|
||||
|
||||
} else if (operation === '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++) {
|
||||
let id: string;
|
||||
|
||||
id = this.getNodeParameter('id', i) as string;
|
||||
|
||||
endpoint = `${application}/${table}`;
|
||||
rows.push(id);
|
||||
|
||||
// Make one request after another. This is slower but makes
|
||||
// sure that we do not run into the rate limit they have in
|
||||
// place and so block for 30 seconds. Later some global
|
||||
// functionality in core should make it easy to make requests
|
||||
// according to specific rules like not more than 5 requests
|
||||
// per seconds.
|
||||
qs.records = [id];
|
||||
if (rows.length === bulkSize || i === items.length - 1) {
|
||||
endpoint = `${application}/${table}`;
|
||||
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
|
||||
// Make one request after another. This is slower but makes
|
||||
// sure that we do not run into the rate limit they have in
|
||||
// place and so block for 30 seconds. Later some global
|
||||
// functionality in core should make it easy to make requests
|
||||
// according to specific rules like not more than 5 requests
|
||||
// per seconds.
|
||||
qs.records = rows;
|
||||
|
||||
returnData.push(...responseData.records);
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
|
||||
|
||||
returnData.push(...responseData.records);
|
||||
// empty rows
|
||||
rows.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
} else if (operation === 'list') {
|
||||
|
@ -585,55 +632,69 @@ export class Airtable implements INodeType {
|
|||
|
||||
requestMethod = 'PATCH';
|
||||
|
||||
let id: string;
|
||||
let updateAllFields: boolean;
|
||||
let fields: string[];
|
||||
let options: IDataObject;
|
||||
|
||||
const rows: IDataObject[] = [];
|
||||
let bulkSize = 10;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
updateAllFields = this.getNodeParameter('updateAllFields', i) as boolean;
|
||||
options = this.getNodeParameter('options', i, {}) as IDataObject;
|
||||
bulkSize = options.bulkSize as number || bulkSize;
|
||||
|
||||
const row: IDataObject = {};
|
||||
row.fields = {} as IDataObject;
|
||||
|
||||
if (updateAllFields === true) {
|
||||
// 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 !== '') {
|
||||
const ignoreFields = (options.ignoreFields as string).split(',').map(field => field.trim()).filter(field => !!field);
|
||||
if (ignoreFields.length) {
|
||||
// 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))
|
||||
.reduce((obj, [key, val]) => Object.assign(obj, { [key]: val }), {});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Update only the specified fields
|
||||
body.fields = {} as IDataObject;
|
||||
|
||||
fields = this.getNodeParameter('fields', i, []) as string[];
|
||||
|
||||
for (const fieldName of fields) {
|
||||
// @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;
|
||||
|
||||
endpoint = `${application}/${table}`;
|
||||
rows.push(row);
|
||||
|
||||
// Make one request after another. This is slower but makes
|
||||
// sure that we do not run into the rate limit they have in
|
||||
// place and so block for 30 seconds. Later some global
|
||||
// functionality in core should make it easy to make requests
|
||||
// according to specific rules like not more than 5 requests
|
||||
// per seconds.
|
||||
if (rows.length === bulkSize || i === items.length - 1) {
|
||||
endpoint = `${application}/${table}`;
|
||||
|
||||
const data = { records: [{ id, fields: body.fields }], typecast: (options.typecast) ? true : false };
|
||||
// Make one request after another. This is slower but makes
|
||||
// sure that we do not run into the rate limit they have in
|
||||
// place and so block for 30 seconds. Later some global
|
||||
// functionality in core should make it easy to make requests
|
||||
// according to specific rules like not more than 5 requests
|
||||
// per seconds.
|
||||
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, data, qs);
|
||||
const data = { records: rows, typecast: (options.typecast) ? true : false };
|
||||
|
||||
returnData.push(...responseData.records);
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, data, qs);
|
||||
|
||||
returnData.push(...responseData.records);
|
||||
|
||||
// empty rows
|
||||
rows.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
|
|
@ -158,7 +158,7 @@ export class AwsComprehend implements INodeType {
|
|||
description: 'The text to send.',
|
||||
},
|
||||
{
|
||||
displayName: 'Simple',
|
||||
displayName: 'Simplify Response',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
|
@ -172,7 +172,7 @@ export class AwsComprehend implements INodeType {
|
|||
},
|
||||
},
|
||||
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',
|
||||
|
|
19
packages/nodes-base/nodes/Baserow/Baserow.node.json
Normal file
19
packages/nodes-base/nodes/Baserow/Baserow.node.json
Normal 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": []
|
||||
}
|
||||
}
|
318
packages/nodes-base/nodes/Baserow/Baserow.node.ts
Normal file
318
packages/nodes-base/nodes/Baserow/Baserow.node.ts
Normal 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)];
|
||||
}
|
||||
}
|
198
packages/nodes-base/nodes/Baserow/GenericFunctions.ts
Normal file
198
packages/nodes-base/nodes/Baserow/GenericFunctions.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
455
packages/nodes-base/nodes/Baserow/OperationDescription.ts
Normal file
455
packages/nodes-base/nodes/Baserow/OperationDescription.ts
Normal 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[];
|
143
packages/nodes-base/nodes/Baserow/baserow.svg
Normal file
143
packages/nodes-base/nodes/Baserow/baserow.svg
Normal 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 |
41
packages/nodes-base/nodes/Baserow/types.d.ts
vendored
Normal file
41
packages/nodes-base/nodes/Baserow/types.d.ts
vendored
Normal 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';
|
|
@ -29,6 +29,10 @@ import {
|
|||
|
||||
import * as moment from 'moment-timezone';
|
||||
|
||||
import {
|
||||
noCase,
|
||||
} from 'change-case';
|
||||
|
||||
export class Box implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Box',
|
||||
|
@ -81,6 +85,7 @@ export class Box implements INodeType {
|
|||
const length = items.length as unknown as number;
|
||||
const qs: IDataObject = {};
|
||||
let responseData;
|
||||
const timezone = this.getTimezone();
|
||||
const resource = this.getNodeParameter('resource', 0) as string;
|
||||
const operation = this.getNodeParameter('operation', 0) as string;
|
||||
for (let i = 0; i < length; i++) {
|
||||
|
@ -199,6 +204,51 @@ export class Box implements INodeType {
|
|||
}
|
||||
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
|
||||
if (operation === 'upload') {
|
||||
const parentId = this.getNodeParameter('parentId', i) as string;
|
||||
|
@ -356,6 +406,79 @@ export class Box implements INodeType {
|
|||
}
|
||||
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') {
|
||||
|
|
|
@ -21,7 +21,7 @@ export class BoxTrigger implements INodeType {
|
|||
icon: 'file:box.png',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Starts the workflow when a Box events occurs.',
|
||||
description: 'Starts the workflow when Box events occur',
|
||||
defaults: {
|
||||
name: 'Box Trigger',
|
||||
color: '#00aeef',
|
||||
|
|
|
@ -40,6 +40,11 @@ export const fileOperations = [
|
|||
value: 'search',
|
||||
description: 'Search files',
|
||||
},
|
||||
{
|
||||
name: 'Share',
|
||||
value: 'share',
|
||||
description: 'Share a file',
|
||||
},
|
||||
{
|
||||
name: '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 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
|
|
@ -35,6 +35,16 @@ export const folderOperations = [
|
|||
value: 'search',
|
||||
description: 'Search files',
|
||||
},
|
||||
{
|
||||
name: 'Share',
|
||||
value: 'share',
|
||||
description: 'Share a folder',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
description: 'Update folder',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
description: 'The operation to perform.',
|
||||
|
@ -147,6 +157,7 @@ export const folderFields = [
|
|||
default: '',
|
||||
description: 'Folder ID',
|
||||
},
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 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[];
|
||||
|
|
|
@ -20,7 +20,7 @@ export class CalendlyTrigger implements INodeType {
|
|||
icon: 'file:calendly.svg',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Starts the workflow when Calendly events occur.',
|
||||
description: 'Starts the workflow when Calendly events occur',
|
||||
defaults: {
|
||||
name: 'Calendly Trigger',
|
||||
color: '#374252',
|
||||
|
|
|
@ -17,7 +17,7 @@ export class ChargebeeTrigger implements INodeType {
|
|||
icon: 'file:chargebee.png',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Starts the workflow when Chargebee events occur.',
|
||||
description: 'Starts the workflow when Chargebee events occur',
|
||||
defaults: {
|
||||
name: 'Chargebee Trigger',
|
||||
color: '#559922',
|
||||
|
|
|
@ -26,7 +26,7 @@ export class ClockifyTrigger implements INodeType {
|
|||
name: 'clockifyTrigger',
|
||||
group: [ 'trigger' ],
|
||||
version: 1,
|
||||
description: 'Watches Clockify For Events',
|
||||
description: 'Listens to Clockify events',
|
||||
defaults: {
|
||||
name: 'Clockify Trigger',
|
||||
color: '#000000',
|
||||
|
|
|
@ -48,7 +48,7 @@ export class ConvertKit implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume ConvertKit API.',
|
||||
description: 'Consume ConvertKit API',
|
||||
defaults: {
|
||||
name: 'ConvertKit',
|
||||
color: '#fb6970',
|
||||
|
|
|
@ -25,7 +25,7 @@ export class CrateDb implements INodeType {
|
|||
icon: 'file:cratedb.png',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'Add and update data in CrateDB.',
|
||||
description: 'Add and update data in CrateDB',
|
||||
defaults: {
|
||||
name: 'CrateDB',
|
||||
color: '#47889f',
|
||||
|
|
|
@ -31,7 +31,7 @@ export class CustomerIoTrigger implements INodeType {
|
|||
group: ['trigger'],
|
||||
icon: 'file:customerio.svg',
|
||||
version: 1,
|
||||
description: 'Starts the workflow on a Customer.io update. (Beta)',
|
||||
description: 'Starts the workflow on a Customer.io update (Beta)',
|
||||
defaults: {
|
||||
name: 'Customer.io Trigger',
|
||||
color: '#ffcd00',
|
||||
|
|
|
@ -55,7 +55,7 @@ export class Discourse implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Discourse API.',
|
||||
description: 'Consume Discourse API',
|
||||
defaults: {
|
||||
name: 'Discourse',
|
||||
color: '#000000',
|
||||
|
|
|
@ -50,7 +50,7 @@ export const searchFields: INodeProperties[] = [
|
|||
description: 'Term to search for.',
|
||||
},
|
||||
{
|
||||
displayName: 'Simple',
|
||||
displayName: 'Simplify Response',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
|
@ -64,6 +64,6 @@ export const searchFields: INodeProperties[] = [
|
|||
},
|
||||
},
|
||||
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.',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -515,7 +515,7 @@ export class Egoi implements INodeType {
|
|||
description: 'How many results to return.',
|
||||
},
|
||||
{
|
||||
displayName: 'Simple',
|
||||
displayName: 'Simplify Response',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
|
@ -530,7 +530,7 @@ export class Egoi implements INodeType {
|
|||
},
|
||||
},
|
||||
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.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -35,7 +35,7 @@ export class EmailReadImap implements INodeType {
|
|||
icon: 'fa:inbox',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Triggers the workflow when a new email gets received',
|
||||
description: 'Triggers the workflow when a new email is received',
|
||||
defaults: {
|
||||
name: 'IMAP Email',
|
||||
color: '#44AA22',
|
||||
|
|
|
@ -51,7 +51,7 @@ export class ExecuteCommand implements INodeType {
|
|||
icon: 'fa:terminal',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Executes a command on the host.',
|
||||
description: 'Executes a command on the host',
|
||||
defaults: {
|
||||
name: 'Execute Command',
|
||||
color: '#886644',
|
||||
|
|
|
@ -33,7 +33,7 @@ export class FacebookTrigger implements INodeType {
|
|||
group: ['trigger'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["appId"] +"/"+ $parameter["object"]}}',
|
||||
description: 'Starts the workflow when a Facebook events occurs.',
|
||||
description: 'Starts the workflow when Facebook events occur',
|
||||
defaults: {
|
||||
name: 'Facebook Trigger',
|
||||
color: '#3B5998',
|
||||
|
|
|
@ -30,7 +30,7 @@ export class FileMaker implements INodeType {
|
|||
icon: 'file:filemaker.png',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'Retrieve data from FileMaker data API.',
|
||||
description: 'Retrieve data from the FileMaker data API',
|
||||
defaults: {
|
||||
name: 'FileMaker',
|
||||
color: '#665533',
|
||||
|
|
|
@ -45,7 +45,7 @@ export class Ftp implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["protocol"] + ": " + $parameter["operation"]}}',
|
||||
description: 'Transfers files via FTP or SFTP.',
|
||||
description: 'Transfers files via FTP or SFTP',
|
||||
defaults: {
|
||||
name: 'FTP',
|
||||
color: '#303050',
|
||||
|
|
|
@ -15,7 +15,7 @@ export class Function implements INodeType {
|
|||
icon: 'fa:code',
|
||||
group: ['transform'],
|
||||
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: {
|
||||
name: 'Function',
|
||||
color: '#FF9922',
|
||||
|
|
|
@ -17,7 +17,7 @@ export class FunctionItem implements INodeType {
|
|||
icon: 'fa:code',
|
||||
group: ['transform'],
|
||||
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: {
|
||||
name: 'FunctionItem',
|
||||
color: '#ddbb33',
|
||||
|
|
|
@ -31,7 +31,7 @@ export class GetResponse implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume GetResponse API.',
|
||||
description: 'Consume GetResponse API',
|
||||
defaults: {
|
||||
name: 'GetResponse',
|
||||
color: '#00afec',
|
||||
|
|
|
@ -26,7 +26,7 @@ export class GetResponseTrigger implements INodeType {
|
|||
icon: 'file:getResponse.png',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Starts the workflow when GetResponse events occur.',
|
||||
description: 'Starts the workflow when GetResponse events occur',
|
||||
defaults: {
|
||||
name: 'GetResponse Trigger',
|
||||
color: '#00afec',
|
||||
|
|
|
@ -33,7 +33,7 @@ export class Ghost implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Ghost API.',
|
||||
description: 'Consume Ghost API',
|
||||
defaults: {
|
||||
name: 'Ghost',
|
||||
color: '#15212a',
|
||||
|
|
|
@ -28,7 +28,7 @@ export class Github implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume GitHub API.',
|
||||
description: 'Consume GitHub API',
|
||||
defaults: {
|
||||
name: 'GitHub',
|
||||
color: '#000000',
|
||||
|
|
|
@ -25,7 +25,7 @@ export class GithubTrigger implements INodeType {
|
|||
group: ['trigger'],
|
||||
version: 1,
|
||||
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: {
|
||||
name: 'Github Trigger',
|
||||
color: '#000000',
|
||||
|
|
|
@ -23,7 +23,7 @@ export class Gitlab implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Retrieve data from GitLab API.',
|
||||
description: 'Retrieve data from GitLab API',
|
||||
defaults: {
|
||||
name: 'Gitlab',
|
||||
color: '#FC6D27',
|
||||
|
|
|
@ -24,7 +24,7 @@ export class GitlabTrigger implements INodeType {
|
|||
group: ['trigger'],
|
||||
version: 1,
|
||||
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: {
|
||||
name: 'Gitlab Trigger',
|
||||
color: '#FC6D27',
|
||||
|
|
|
@ -91,7 +91,7 @@ export const reportFields = [
|
|||
description: 'How many results to return.',
|
||||
},
|
||||
{
|
||||
displayName: 'Simple',
|
||||
displayName: 'Simplify Response',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
|
@ -105,7 +105,7 @@ export const reportFields = [
|
|||
},
|
||||
},
|
||||
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',
|
||||
|
|
|
@ -32,7 +32,7 @@ export class GoogleBigQuery implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Google BigQuery API.',
|
||||
description: 'Consume Google BigQuery API',
|
||||
defaults: {
|
||||
name: 'Google BigQuery',
|
||||
color: '#3E87E4',
|
||||
|
|
|
@ -287,7 +287,7 @@ export const recordFields = [
|
|||
description: 'How many results to return.',
|
||||
},
|
||||
{
|
||||
displayName: 'Simple',
|
||||
displayName: 'Simplify Response',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
|
|
|
@ -30,6 +30,7 @@ export async function googleApiRequest(this: IExecuteFunctions | IExecuteSingleF
|
|||
if (Object.keys(body).length === 0) {
|
||||
delete options.body;
|
||||
}
|
||||
console.log(options);
|
||||
//@ts-ignore
|
||||
return await this.helpers.requestOAuth2.call(this, 'googleCalendarOAuth2Api', options);
|
||||
} catch (error) {
|
||||
|
|
|
@ -44,7 +44,7 @@ export class GoogleCalendar implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Google Calendar API.',
|
||||
description: 'Consume Google Calendar API',
|
||||
defaults: {
|
||||
name: 'Google Calendar',
|
||||
color: '#3E87E4',
|
||||
|
|
|
@ -33,7 +33,7 @@ export class GoogleContacts implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Google Contacts API.',
|
||||
description: 'Consume Google Contacts API',
|
||||
defaults: {
|
||||
name: 'Google Contacts',
|
||||
color: '#1a73e8',
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
// group: ['trigger'],
|
||||
// version: 1,
|
||||
// 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: {
|
||||
// name: 'Google Drive Trigger',
|
||||
// color: '#3f87f2',
|
||||
|
|
|
@ -137,7 +137,7 @@ export const documentFields = [
|
|||
placeholder: 'productId, modelName, description',
|
||||
},
|
||||
{
|
||||
displayName: 'Simple',
|
||||
displayName: 'Simplify Response',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
|
@ -151,7 +151,7 @@ export const documentFields = [
|
|||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
{
|
||||
displayName: 'Simple',
|
||||
displayName: 'Simplify Response',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
|
@ -247,7 +247,7 @@ export const documentFields = [
|
|||
},
|
||||
},
|
||||
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.',
|
||||
},
|
||||
{
|
||||
displayName: 'Simple',
|
||||
displayName: 'Simplify Response',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
|
@ -367,7 +367,7 @@ export const documentFields = [
|
|||
},
|
||||
},
|
||||
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"}]}}',
|
||||
},
|
||||
{
|
||||
displayName: 'Simple',
|
||||
displayName: 'Simplify Response',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
|
@ -740,6 +740,6 @@ export const documentFields = [
|
|||
},
|
||||
},
|
||||
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[];
|
||||
|
|
|
@ -486,8 +486,9 @@ export class GoogleSheet {
|
|||
inputData.forEach((item) => {
|
||||
rowData = [];
|
||||
keyColumnOrder.forEach((key) => {
|
||||
if (item.hasOwnProperty(key) && item[key]) {
|
||||
rowData.push(item[key]!.toString());
|
||||
const data = item[key];
|
||||
if (item.hasOwnProperty(key) && data !== null && typeof data !== 'undefined') {
|
||||
rowData.push(data.toString());
|
||||
} else {
|
||||
rowData.push('');
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ export class GoogleTasks implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Google Tasks API.',
|
||||
description: 'Consume Google Tasks API',
|
||||
defaults: {
|
||||
name: 'Google Tasks',
|
||||
color: '#3E87E4',
|
||||
|
|
|
@ -55,7 +55,7 @@ export class YouTube implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume YouTube API.',
|
||||
description: 'Consume YouTube API',
|
||||
defaults: {
|
||||
name: 'YouTube',
|
||||
color: '#FF0000',
|
||||
|
|
|
@ -22,7 +22,7 @@ export class Gotify implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Gotify API.',
|
||||
description: 'Consume Gotify API',
|
||||
defaults: {
|
||||
name: 'Gotify',
|
||||
color: '#71c8ec',
|
||||
|
|
|
@ -64,7 +64,7 @@ export class HelpScout implements INodeType {
|
|||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Help Scout API.',
|
||||
description: 'Consume HelpScout API',
|
||||
defaults: {
|
||||
name: 'HelpScout',
|
||||
color: '#1392ee',
|
||||
|
|
|
@ -26,7 +26,7 @@ export class HelpScoutTrigger implements INodeType {
|
|||
icon: 'file:helpScout.svg',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Starts the workflow when HelpScout events occur.',
|
||||
description: 'Starts the workflow when HelpScout events occur',
|
||||
defaults: {
|
||||
name: 'HelpScout Trigger',
|
||||
color: '#1392ee',
|
||||
|
|
|
@ -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[];
|
32
packages/nodes-base/nodes/HomeAssistant/ConfigDescription.ts
Normal file
32
packages/nodes-base/nodes/HomeAssistant/ConfigDescription.ts
Normal 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[];
|
144
packages/nodes-base/nodes/HomeAssistant/EventDescription.ts
Normal file
144
packages/nodes-base/nodes/HomeAssistant/EventDescription.ts
Normal 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[];
|
42
packages/nodes-base/nodes/HomeAssistant/GenericFunctions.ts
Normal file
42
packages/nodes-base/nodes/HomeAssistant/GenericFunctions.ts
Normal 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);
|
||||
}
|
||||
}
|
128
packages/nodes-base/nodes/HomeAssistant/HistoryDescription.ts
Normal file
128
packages/nodes-base/nodes/HomeAssistant/HistoryDescription.ts
Normal 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[];
|
|
@ -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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
377
packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts
Normal file
377
packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts
Normal 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)];
|
||||
}
|
||||
}
|
||||
}
|
78
packages/nodes-base/nodes/HomeAssistant/LogDescription.ts
Normal file
78
packages/nodes-base/nodes/HomeAssistant/LogDescription.ts
Normal 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[];
|
159
packages/nodes-base/nodes/HomeAssistant/ServiceDescription.ts
Normal file
159
packages/nodes-base/nodes/HomeAssistant/ServiceDescription.ts
Normal 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
Loading…
Reference in a new issue