From fdf8a428ed38bb3ceb2bc0e50b002b34843d8fc4 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 23 Jun 2023 11:29:24 +0100 Subject: [PATCH 01/46] feat: Add missing input panels to some trigger nodes (#6518) --- .../nodes/EmailReadImap/v1/EmailReadImapV1.node.ts | 11 +++++++++++ .../nodes/EmailReadImap/v2/EmailReadImapV2.node.ts | 11 +++++++++++ .../nodes/LocalFileTrigger/LocalFileTrigger.node.ts | 11 +++++++++++ packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts | 12 ++++++++++++ .../nodes/Postgres/PostgresTrigger.node.ts | 12 ++++++++++++ .../nodes/RabbitMQ/RabbitMQTrigger.node.ts | 12 ++++++++++++ .../nodes-base/nodes/SseTrigger/SseTrigger.node.ts | 11 +++++++++++ 7 files changed, 80 insertions(+) diff --git a/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts b/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts index 0559d6e9fb..004edde494 100644 --- a/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts +++ b/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts @@ -72,6 +72,17 @@ const versionDescription: INodeTypeDescription = { name: 'Email Trigger (IMAP)', color: '#44AA22', }, + triggerPanel: { + header: '', + executionsHelp: { + inactive: + "While building your workflow, click the 'listen' button, then send an email to make an event happen. This will trigger an execution, which will show up in this editor.

Once you're happy with your workflow, activate it. Then every time an email is received, the workflow will execute. These executions will show up in the executions list, but not in the editor.", + active: + "While building your workflow, click the 'listen' button, then send an email to make an event happen. This will trigger an execution, which will show up in this editor.

Your workflow will also execute automatically, since it's activated. Every time an email is received, this node will trigger an execution. These executions will show up in the executions list, but not in the editor.", + }, + activationHint: + "Once you’ve finished building your workflow, activate it to have it also listen continuously (you just won’t see those executions here).", + }, // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], outputs: ['main'], diff --git a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts index cfe9dc3c2c..29677d10a7 100644 --- a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts +++ b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts @@ -73,6 +73,17 @@ const versionDescription: INodeTypeDescription = { name: 'Email Trigger (IMAP)', color: '#44AA22', }, + triggerPanel: { + header: '', + executionsHelp: { + inactive: + "While building your workflow, click the 'listen' button, then send an email to make an event happen. This will trigger an execution, which will show up in this editor.

Once you're happy with your workflow, activate it. Then every time an email is received, the workflow will execute. These executions will show up in the executions list, but not in the editor.", + active: + "While building your workflow, click the 'listen' button, then send an email to make an event happen. This will trigger an execution, which will show up in this editor.

Your workflow will also execute automatically, since it's activated. Every time an email is received, this node will trigger an execution. These executions will show up in the executions list, but not in the editor.", + }, + activationHint: + "Once you’ve finished building your workflow, activate it to have it also listen continuously (you just won’t see those executions here).", + }, // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], outputs: ['main'], diff --git a/packages/nodes-base/nodes/LocalFileTrigger/LocalFileTrigger.node.ts b/packages/nodes-base/nodes/LocalFileTrigger/LocalFileTrigger.node.ts index 7bfea916c6..eaf6525486 100644 --- a/packages/nodes-base/nodes/LocalFileTrigger/LocalFileTrigger.node.ts +++ b/packages/nodes-base/nodes/LocalFileTrigger/LocalFileTrigger.node.ts @@ -22,6 +22,17 @@ export class LocalFileTrigger implements INodeType { name: 'Local File Trigger', color: '#404040', }, + triggerPanel: { + header: '', + executionsHelp: { + inactive: + "While building your workflow, click the 'listen' button, then make a change to your watched file or folder. This will trigger an execution, which will show up in this editor.

Once you're happy with your workflow, activate it. Then every time a change is detected, the workflow will execute. These executions will show up in the executions list, but not in the editor.", + active: + "While building your workflow, click the 'listen' button, then make a change to your watched file or folder. This will trigger an execution, which will show up in this editor.

Your workflow will also execute automatically, since it's activated. Every time a change is detected, this node will trigger an execution. These executions will show up in the executions list, but not in the editor.", + }, + activationHint: + "Once you’ve finished building your workflow, activate it to have it also listen continuously (you just won’t see those executions here).", + }, inputs: [], outputs: ['main'], properties: [ diff --git a/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts b/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts index 18336afd91..f666a13be5 100644 --- a/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts +++ b/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts @@ -17,9 +17,21 @@ export class MqttTrigger implements INodeType { group: ['trigger'], version: 1, description: 'Listens to MQTT events', + eventTriggerDescription: '', defaults: { name: 'MQTT Trigger', }, + triggerPanel: { + header: '', + executionsHelp: { + inactive: + "While building your workflow, click the 'listen' button, then trigger an MQTT event. This will trigger an execution, which will show up in this editor.

Once you're happy with your workflow, activate it. Then every time a change is detected, the workflow will execute. These executions will show up in the executions list, but not in the editor.", + active: + "While building your workflow, click the 'listen' button, then trigger an MQTT event. This will trigger an execution, which will show up in this editor.

Your workflow will also execute automatically, since it's activated. Every time a change is detected, this node will trigger an execution. These executions will show up in the executions list, but not in the editor.", + }, + activationHint: + "Once you’ve finished building your workflow, activate it to have it also listen continuously (you just won’t see those executions here).", + }, inputs: [], outputs: ['main'], credentials: [ diff --git a/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts b/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts index 4f880ab2e6..fd1e1c7a0b 100644 --- a/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts +++ b/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts @@ -21,9 +21,21 @@ export class PostgresTrigger implements INodeType { group: ['trigger'], version: 1, description: 'Listens to Postgres messages', + eventTriggerDescription: '', defaults: { name: 'Postgres Trigger', }, + triggerPanel: { + header: '', + executionsHelp: { + inactive: + "While building your workflow, click the 'listen' button, then trigger a Postgres event. This will trigger an execution, which will show up in this editor.

Once you're happy with your workflow, activate it. Then every time a change is detected, the workflow will execute. These executions will show up in the executions list, but not in the editor.", + active: + "While building your workflow, click the 'listen' button, then trigger a Postgres event. This will trigger an execution, which will show up in this editor.

Your workflow will also execute automatically, since it's activated. Every time a change is detected, this node will trigger an execution. These executions will show up in the executions list, but not in the editor.", + }, + activationHint: + "Once you’ve finished building your workflow, activate it to have it also listen continuously (you just won’t see those executions here).", + }, inputs: [], outputs: ['main'], credentials: [ diff --git a/packages/nodes-base/nodes/RabbitMQ/RabbitMQTrigger.node.ts b/packages/nodes-base/nodes/RabbitMQ/RabbitMQTrigger.node.ts index 7c20a0c65b..7a1024ddb8 100644 --- a/packages/nodes-base/nodes/RabbitMQ/RabbitMQTrigger.node.ts +++ b/packages/nodes-base/nodes/RabbitMQ/RabbitMQTrigger.node.ts @@ -26,9 +26,21 @@ export class RabbitMQTrigger implements INodeType { group: ['trigger'], version: 1, description: 'Listens to RabbitMQ messages', + eventTriggerDescription: '', defaults: { name: 'RabbitMQ Trigger', }, + triggerPanel: { + header: '', + executionsHelp: { + inactive: + "While building your workflow, click the 'listen' button, then trigger a Rabbit MQ event. This will trigger an execution, which will show up in this editor.

Once you're happy with your workflow, activate it. Then every time a change is detected, the workflow will execute. These executions will show up in the executions list, but not in the editor.", + active: + "While building your workflow, click the 'listen' button, then trigger a Rabbit MQ event. This will trigger an execution, which will show up in this editor.

Your workflow will also execute automatically, since it's activated. Every time a change is detected, this node will trigger an execution. These executions will show up in the executions list, but not in the editor.", + }, + activationHint: + "Once you’ve finished building your workflow, activate it to have it also listen continuously (you just won’t see those executions here).", + }, inputs: [], outputs: ['main'], credentials: [ diff --git a/packages/nodes-base/nodes/SseTrigger/SseTrigger.node.ts b/packages/nodes-base/nodes/SseTrigger/SseTrigger.node.ts index a0cfde72d4..16d696817d 100644 --- a/packages/nodes-base/nodes/SseTrigger/SseTrigger.node.ts +++ b/packages/nodes-base/nodes/SseTrigger/SseTrigger.node.ts @@ -22,6 +22,17 @@ export class SseTrigger implements INodeType { name: 'SSE Trigger', color: '#225577', }, + triggerPanel: { + header: '', + executionsHelp: { + inactive: + "While building your workflow, click the 'listen' button, then trigger an SSE event. This will trigger an execution, which will show up in this editor.

Once you're happy with your workflow, activate it. Then every time a change is detected, the workflow will execute. These executions will show up in the executions list, but not in the editor.", + active: + "While building your workflow, click the 'listen' button, then trigger an SSE event. This will trigger an execution, which will show up in this editor.

Your workflow will also execute automatically, since it's activated. Every time a change is detected, this node will trigger an execution. These executions will show up in the executions list, but not in the editor.", + }, + activationHint: + "Once you’ve finished building your workflow, activate it to have it also listen continuously (you just won’t see those executions here).", + }, inputs: [], outputs: ['main'], properties: [ From b06462f4415bd1143a00b4a66e6e626da8c52196 Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Fri, 23 Jun 2023 12:32:00 +0200 Subject: [PATCH 02/46] fix(core): Rename to credential_stubs and variable_stubs.json (#6528) rename to credential_stubs and variable_stubs.json --- packages/cli/src/environments/sourceControl/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/environments/sourceControl/constants.ts b/packages/cli/src/environments/sourceControl/constants.ts index c3f8c2390d..ab74559ce7 100644 --- a/packages/cli/src/environments/sourceControl/constants.ts +++ b/packages/cli/src/environments/sourceControl/constants.ts @@ -2,8 +2,8 @@ export const SOURCE_CONTROL_PREFERENCES_DB_KEY = 'features.sourceControl'; export const SOURCE_CONTROL_GIT_FOLDER = 'git'; export const SOURCE_CONTROL_GIT_KEY_COMMENT = 'n8n deploy key'; export const SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER = 'workflows'; -export const SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credentials'; -export const SOURCE_CONTROL_VARIABLES_EXPORT_FILE = 'variables.json'; +export const SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credential_stubs'; +export const SOURCE_CONTROL_VARIABLES_EXPORT_FILE = 'variable_stubs.json'; export const SOURCE_CONTROL_TAGS_EXPORT_FILE = 'tags.json'; export const SOURCE_CONTROL_SSH_FOLDER = 'ssh'; export const SOURCE_CONTROL_SSH_KEY_NAME = 'key'; From 38dc784d2eed25aae777c5c3c3fda1a35e20bd24 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Fri, 23 Jun 2023 14:21:08 +0300 Subject: [PATCH 03/46] fix(Notion Node): Version fix (#6531) --- .../nodes/Notion/DatabaseDescription.ts | 6 +++--- .../nodes/Notion/DatabasePageDescription.ts | 18 +++++++++--------- .../nodes/Notion/GenericFunctions.ts | 10 +++++----- .../nodes-base/nodes/Notion/PageDescription.ts | 12 ++++++------ .../nodes/Notion/v1/VersionDescription.ts | 6 ------ .../nodes/Notion/v2/NotionV2.node.ts | 5 ----- .../nodes/Notion/v2/VersionDescription.ts | 6 ------ 7 files changed, 23 insertions(+), 40 deletions(-) diff --git a/packages/nodes-base/nodes/Notion/DatabaseDescription.ts b/packages/nodes-base/nodes/Notion/DatabaseDescription.ts index 93e59af7d0..d5932e9765 100644 --- a/packages/nodes-base/nodes/Notion/DatabaseDescription.ts +++ b/packages/nodes-base/nodes/Notion/DatabaseDescription.ts @@ -8,7 +8,7 @@ export const databaseOperations: INodeProperties[] = [ noDataExpression: true, displayOptions: { show: { - version: [2], + '@version': [2], resource: ['database'], }, }, @@ -41,7 +41,7 @@ export const databaseOperations: INodeProperties[] = [ noDataExpression: true, displayOptions: { show: { - version: [1], + '@version': [1], resource: ['database'], }, }, @@ -178,7 +178,7 @@ export const databaseFields: INodeProperties[] = [ type: 'boolean', displayOptions: { show: { - version: [2], + '@version': [2], resource: ['database'], operation: ['getAll', 'get'], }, diff --git a/packages/nodes-base/nodes/Notion/DatabasePageDescription.ts b/packages/nodes-base/nodes/Notion/DatabasePageDescription.ts index ee45894269..c28d61ed42 100644 --- a/packages/nodes-base/nodes/Notion/DatabasePageDescription.ts +++ b/packages/nodes-base/nodes/Notion/DatabasePageDescription.ts @@ -14,7 +14,7 @@ export const databasePageOperations: INodeProperties[] = [ noDataExpression: true, displayOptions: { show: { - version: [2], + '@version': [2], resource: ['databasePage'], }, }, @@ -53,7 +53,7 @@ export const databasePageOperations: INodeProperties[] = [ noDataExpression: true, displayOptions: { show: { - version: [1], + '@version': [1], resource: ['databasePage'], }, }, @@ -161,7 +161,7 @@ export const databasePageFields: INodeProperties[] = [ default: '', displayOptions: { show: { - version: [2], + '@version': [2], resource: ['databasePage'], operation: ['create'], }, @@ -502,7 +502,7 @@ export const databasePageFields: INodeProperties[] = [ }, displayOptions: { show: { - '/version': [2], + '@version': [2], type: ['files'], }, }, @@ -969,7 +969,7 @@ export const databasePageFields: INodeProperties[] = [ }, displayOptions: { show: { - '/version': [2], + '@version': [2], type: ['files'], }, }, @@ -1055,7 +1055,7 @@ export const databasePageFields: INodeProperties[] = [ ], displayOptions: { show: { - version: [2], + '@version': [2], resource: ['databasePage'], operation: ['get'], }, @@ -1068,7 +1068,7 @@ export const databasePageFields: INodeProperties[] = [ type: 'boolean', displayOptions: { show: { - version: [2], + '@version': [2], resource: ['databasePage'], operation: ['get'], }, @@ -1212,7 +1212,7 @@ export const databasePageFields: INodeProperties[] = [ type: 'boolean', displayOptions: { show: { - '/version': [2], + '@version': [2], '/resource': ['databasePage'], '/operation': ['getAll'], }, @@ -1230,7 +1230,7 @@ export const databasePageFields: INodeProperties[] = [ }, displayOptions: { show: { - '/version': [1], + '@version': [1], }, }, default: {}, diff --git a/packages/nodes-base/nodes/Notion/GenericFunctions.ts b/packages/nodes-base/nodes/Notion/GenericFunctions.ts index e41a32cc81..f93704b7ea 100644 --- a/packages/nodes-base/nodes/Notion/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Notion/GenericFunctions.ts @@ -880,7 +880,7 @@ export function getSearchFilters(resource: string) { ], displayOptions: { show: { - version: [2], + '@version': [2], resource: [resource], operation: ['getAll'], }, @@ -903,7 +903,7 @@ export function getSearchFilters(resource: string) { ], displayOptions: { show: { - version: [2], + '@version': [2], resource: [resource], operation: ['getAll'], filterType: ['manual'], @@ -920,7 +920,7 @@ export function getSearchFilters(resource: string) { }, displayOptions: { show: { - version: [2], + '@version': [2], resource: [resource], operation: ['getAll'], filterType: ['manual'], @@ -943,7 +943,7 @@ export function getSearchFilters(resource: string) { type: 'notice', displayOptions: { show: { - version: [2], + '@version': [2], resource: [resource], operation: ['getAll'], filterType: ['json'], @@ -957,7 +957,7 @@ export function getSearchFilters(resource: string) { type: 'string', displayOptions: { show: { - version: [2], + '@version': [2], resource: [resource], operation: ['getAll'], filterType: ['json'], diff --git a/packages/nodes-base/nodes/Notion/PageDescription.ts b/packages/nodes-base/nodes/Notion/PageDescription.ts index 5c4ec2d158..41dd8a0706 100644 --- a/packages/nodes-base/nodes/Notion/PageDescription.ts +++ b/packages/nodes-base/nodes/Notion/PageDescription.ts @@ -10,7 +10,7 @@ export const pageOperations: INodeProperties[] = [ noDataExpression: true, displayOptions: { show: { - version: [1], + '@version': [1], resource: ['page'], }, }, @@ -43,7 +43,7 @@ export const pageOperations: INodeProperties[] = [ noDataExpression: true, displayOptions: { show: { - version: [2], + '@version': [2], resource: ['page'], }, }, @@ -127,7 +127,7 @@ export const pageFields: INodeProperties[] = [ ], displayOptions: { show: { - version: [2], + '@version': [2], resource: ['page'], operation: ['archive'], }, @@ -140,7 +140,7 @@ export const pageFields: INodeProperties[] = [ type: 'boolean', displayOptions: { show: { - version: [2], + '@version': [2], resource: ['page'], operation: ['archive'], }, @@ -289,7 +289,7 @@ export const pageFields: INodeProperties[] = [ required: true, displayOptions: { show: { - version: [1], + '@version': [1], resource: ['page'], operation: ['get'], }, @@ -303,7 +303,7 @@ export const pageFields: INodeProperties[] = [ type: 'boolean', displayOptions: { show: { - version: [1], + '@version': [1], resource: ['page'], operation: ['get'], }, diff --git a/packages/nodes-base/nodes/Notion/v1/VersionDescription.ts b/packages/nodes-base/nodes/Notion/v1/VersionDescription.ts index 9398aa6e10..08e9a54cb0 100644 --- a/packages/nodes-base/nodes/Notion/v1/VersionDescription.ts +++ b/packages/nodes-base/nodes/Notion/v1/VersionDescription.ts @@ -73,12 +73,6 @@ export const versionDescription: INodeTypeDescription = { type: 'notice', default: '', }, - { - displayName: 'Version', - name: 'version', - type: 'hidden', - default: 1, - }, { displayName: 'Resource', name: 'resource', diff --git a/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts b/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts index f0050ab867..4f85773124 100644 --- a/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts +++ b/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts @@ -758,11 +758,6 @@ export class NotionV2 implements INodeType { } } - if (download) { - const rawData = returnData.map((data) => data.json); - return this.prepareOutputData(rawData as INodeExecutionData[]); - } - return this.prepareOutputData(returnData); } } diff --git a/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts b/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts index df0e116761..0ba2fffdd9 100644 --- a/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts +++ b/packages/nodes-base/nodes/Notion/v2/VersionDescription.ts @@ -73,12 +73,6 @@ export const versionDescription: INodeTypeDescription = { type: 'notice', default: '', }, - { - displayName: 'Version', - name: 'version', - type: 'hidden', - default: 2, - }, { displayName: 'Resource', name: 'resource', From 806d13460240abe94843e569b1820cd8d0d8edd1 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Fri, 23 Jun 2023 14:24:31 +0300 Subject: [PATCH 04/46] fix(Google Sheets Node): Incorrect read of 0 and false (#6525) --- .../nodes/Google/Sheet/test/v2/utils/utils.test.ts | 2 +- .../nodes/Google/Sheet/v2/helpers/GoogleSheets.utils.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/utils/utils.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/utils/utils.test.ts index d00eaf6387..69894eea4b 100644 --- a/packages/nodes-base/nodes/Google/Sheet/test/v2/utils/utils.test.ts +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/utils/utils.test.ts @@ -163,7 +163,7 @@ describe('Test Google Sheets, removeEmptyColumns', () => { expect(result).toBeDefined(); expect(result).toEqual([ ['id', 'col1', 'col3'], - ['', 'A', 'C'], // TODO:should be [0, 'A', 'C'] ? + [0, 'A', 'C'], [1, 'a', 'c'], [2, 'd', 'f'], [3, 'g', 'i'], diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/helpers/GoogleSheets.utils.ts b/packages/nodes-base/nodes/Google/Sheet/v2/helpers/GoogleSheets.utils.ts index 50fd0f4ca1..6451b38956 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/helpers/GoogleSheets.utils.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/helpers/GoogleSheets.utils.ts @@ -102,7 +102,9 @@ export function trimToFirstEmptyRow(data: SheetRangeData, includesRowNumber = tr export function removeEmptyRows(data: SheetRangeData, includesRowNumber = true) { const baseLength = includesRowNumber ? 1 : 0; const notEmptyRows = data.filter((row) => - row.slice(baseLength).some((cell) => cell || typeof cell === 'number'), + row + .slice(baseLength) + .some((cell) => cell || typeof cell === 'number' || typeof cell === 'boolean'), ); if (includesRowNumber) { notEmptyRows[0][0] = ROW_NUMBER; @@ -143,7 +145,9 @@ export function removeEmptyColumns(data: SheetRangeData) { returnData.push(column); } } - return (returnData[0] || []).map((_, i) => returnData.map((row) => row[i] || '')); + return (returnData[0] || []).map((_, i) => + returnData.map((row) => (row[i] === undefined ? '' : row[i])), + ); } export function prepareSheetData( From c82c7f19128df3a11d6d0f18e8d8dab57e6a3b8f Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Fri, 23 Jun 2023 16:21:42 +0300 Subject: [PATCH 05/46] fix(Merge Node): Enrich input 2 fix (#6526) --- .../node/workflow.combine.mergeByFields.json | 8 ++--- .../nodes/Merge/v2/GenericFunctions.ts | 30 +++++++------------ .../nodes-base/nodes/Merge/v2/MergeV2.node.ts | 18 ++++++----- 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/packages/nodes-base/nodes/Merge/test/node/workflow.combine.mergeByFields.json b/packages/nodes-base/nodes/Merge/test/node/workflow.combine.mergeByFields.json index aecdcd2712..f31d72e8f0 100644 --- a/packages/nodes-base/nodes/Merge/test/node/workflow.combine.mergeByFields.json +++ b/packages/nodes-base/nodes/Merge/test/node/workflow.combine.mergeByFields.json @@ -278,8 +278,8 @@ { "json": { "id": 1, - "data": "c", - "input": 2, + "data": "a", + "input": 1, "text": "foo", "tag": "second" } @@ -287,8 +287,8 @@ { "json": { "id": 2, - "data": "d", - "input": 2, + "data": "b", + "input": 1, "text": "foo", "tag": "second" } diff --git a/packages/nodes-base/nodes/Merge/v2/GenericFunctions.ts b/packages/nodes-base/nodes/Merge/v2/GenericFunctions.ts index 19f2f8bffb..a74ec6e44f 100644 --- a/packages/nodes-base/nodes/Merge/v2/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Merge/v2/GenericFunctions.ts @@ -132,12 +132,8 @@ export function findMatches( fieldsToMatch: PairToMatch[], options: MatchFieldsOptions, ) { - let data1 = [...input1]; - let data2 = [...input2]; - - if (options.joinMode === 'enrichInput2') { - [data1, data2] = [data2, data1]; - } + const data1 = [...input1]; + const data2 = [...input2]; const isEntriesEqual = fuzzyCompare(options.fuzzyCompare as boolean); const disableDotNotation = options.disableDotNotation || false; @@ -263,12 +259,8 @@ export function mergeMatched( let binary: IBinaryKeyData = {}; if (resolveClash === 'addSuffix') { - let suffix1 = '1'; - let suffix2 = '2'; - - if (joinMode === 'enrichInput2') { - [suffix1, suffix2] = [suffix2, suffix1]; - } + const suffix1 = '1'; + const suffix2 = '2'; [entry] = addSuffixToEntriesKeys([entry], suffix1); matches = addSuffixToEntriesKeys(matches, suffix2); @@ -279,15 +271,15 @@ export function mergeMatched( ...matches.map((item) => item.binary as IDataObject), ); } else { - let preferInput1 = 'preferInput1'; - let preferInput2 = 'preferInput2'; - - if (joinMode === 'enrichInput2') { - [preferInput1, preferInput2] = [preferInput2, preferInput1]; - } + const preferInput1 = 'preferInput1'; + const preferInput2 = 'preferInput2'; if (resolveClash === undefined) { - resolveClash = 'preferInput2'; + if (joinMode !== 'enrichInput2') { + resolveClash = 'preferInput2'; + } else { + resolveClash = 'preferInput1'; + } } if (resolveClash === preferInput1) { diff --git a/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts b/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts index 8b05d98a70..ec777f9b19 100644 --- a/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts +++ b/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts @@ -541,14 +541,18 @@ export class MergeV2 implements INodeType { const mergedEntries = mergeMatched(matches.matched, clashResolveOptions, joinMode); - if (clashResolveOptions.resolveClash === 'addSuffix') { - const suffix = joinMode === 'enrichInput1' ? '1' : '2'; - returnData.push( - ...mergedEntries, - ...addSuffixToEntriesKeys(matches.unmatched1, suffix), - ); + if (joinMode === 'enrichInput1') { + if (clashResolveOptions.resolveClash === 'addSuffix') { + returnData.push(...mergedEntries, ...addSuffixToEntriesKeys(matches.unmatched1, '1')); + } else { + returnData.push(...mergedEntries, ...matches.unmatched1); + } } else { - returnData.push(...mergedEntries, ...matches.unmatched1); + if (clashResolveOptions.resolveClash === 'addSuffix') { + returnData.push(...mergedEntries, ...addSuffixToEntriesKeys(matches.unmatched2, '2')); + } else { + returnData.push(...mergedEntries, ...matches.unmatched2); + } } } } From d9ed0b31b538320a67ee4e5c0cae34656c9f4334 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Fri, 23 Jun 2023 17:12:54 +0300 Subject: [PATCH 06/46] fix(Google Drive Node): URL parsing (#6527) --- .../nodes/Google/Drive/GoogleDrive.node.ts | 12 ++++++------ .../nodes/Google/Drive/GoogleDriveTrigger.node.ts | 8 ++++---- .../nodes/Google/Sheet/GoogleSheetsTrigger.node.ts | 4 ++-- .../Google/Sheet/v2/actions/sheet/Sheet.resource.ts | 4 ++-- .../Sheet/v2/actions/spreadsheet/delete.operation.ts | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts b/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts index f51d1461a6..fe3169946b 100644 --- a/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts +++ b/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts @@ -238,14 +238,14 @@ export class GoogleDrive implements INodeType { extractValue: { type: 'regex', regex: - 'https:\\/\\/(?:drive|docs)\\.google\\.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/(?:drive|docs)\\.google\\.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', }, validation: [ { type: 'regex', properties: { regex: - 'https:\\/\\/(?:drive|docs)\\.google.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/(?:drive|docs)\\.google.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', errorMessage: 'Not a valid Google Drive File URL', }, }, @@ -302,14 +302,14 @@ export class GoogleDrive implements INodeType { extractValue: { type: 'regex', regex: - 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', }, validation: [ { type: 'regex', properties: { regex: - 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', errorMessage: 'Not a valid Google Drive Folder URL', }, }, @@ -1487,14 +1487,14 @@ export class GoogleDrive implements INodeType { extractValue: { type: 'regex', regex: - 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', }, validation: [ { type: 'regex', properties: { regex: - 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', errorMessage: 'Not a valid Google Drive Drive URL', }, }, diff --git a/packages/nodes-base/nodes/Google/Drive/GoogleDriveTrigger.node.ts b/packages/nodes-base/nodes/Google/Drive/GoogleDriveTrigger.node.ts index fd964b0a26..08299f42a2 100644 --- a/packages/nodes-base/nodes/Google/Drive/GoogleDriveTrigger.node.ts +++ b/packages/nodes-base/nodes/Google/Drive/GoogleDriveTrigger.node.ts @@ -113,14 +113,14 @@ export class GoogleDriveTrigger implements INodeType { extractValue: { type: 'regex', regex: - 'https:\\/\\/(?:drive|docs)\\.google\\.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/(?:drive|docs)\\.google\\.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', }, validation: [ { type: 'regex', properties: { regex: - 'https:\\/\\/(?:drive|docs)\\.google.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/(?:drive|docs)\\.google.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', errorMessage: 'Not a valid Google Drive File URL', }, }, @@ -193,14 +193,14 @@ export class GoogleDriveTrigger implements INodeType { extractValue: { type: 'regex', regex: - 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', }, validation: [ { type: 'regex', properties: { regex: - 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', errorMessage: 'Not a valid Google Drive Folder URL', }, }, diff --git a/packages/nodes-base/nodes/Google/Sheet/GoogleSheetsTrigger.node.ts b/packages/nodes-base/nodes/Google/Sheet/GoogleSheetsTrigger.node.ts index 514c12d952..71095c94f4 100644 --- a/packages/nodes-base/nodes/Google/Sheet/GoogleSheetsTrigger.node.ts +++ b/packages/nodes-base/nodes/Google/Sheet/GoogleSheetsTrigger.node.ts @@ -85,14 +85,14 @@ export class GoogleSheetsTrigger implements INodeType { extractValue: { type: 'regex', regex: - 'https:\\/\\/(?:drive|docs)\\.google\\.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/(?:drive|docs)\\.google\\.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', }, validation: [ { type: 'regex', properties: { regex: - 'https:\\/\\/(?:drive|docs)\\.google.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/(?:drive|docs)\\.google.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', errorMessage: 'Not a valid Google Drive File URL', }, }, diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/Sheet.resource.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/Sheet.resource.ts index 8dc3de896e..6e67e3a0ea 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/Sheet.resource.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/Sheet.resource.ts @@ -97,14 +97,14 @@ export const descriptions: INodeProperties[] = [ extractValue: { type: 'regex', regex: - 'https:\\/\\/(?:drive|docs)\\.google\\.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/(?:drive|docs)\\.google\\.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', }, validation: [ { type: 'regex', properties: { regex: - 'https:\\/\\/(?:drive|docs)\\.google.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/(?:drive|docs)\\.google.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', errorMessage: 'Not a valid Google Drive File URL', }, }, diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/spreadsheet/delete.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/spreadsheet/delete.operation.ts index 787b6412f6..d8008bd409 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/spreadsheet/delete.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/spreadsheet/delete.operation.ts @@ -38,14 +38,14 @@ export const description: SpreadSheetProperties = [ extractValue: { type: 'regex', regex: - 'https:\\/\\/(?:drive|docs)\\.google\\.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/(?:drive|docs)\\.google\\.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', }, validation: [ { type: 'regex', properties: { regex: - 'https:\\/\\/(?:drive|docs)\\.google.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + 'https:\\/\\/(?:drive|docs)\\.google.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', errorMessage: 'Not a valid Google Drive File URL', }, }, From 9294e2da3c7c99c2099f5865e610fa7217bf06be Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Fri, 23 Jun 2023 18:23:28 +0200 Subject: [PATCH 07/46] fix(core): Add empty credential value marker to show empty pw field (#6532) add empty credential value marker to show empty pw field --- .../cli/src/credentials/credentials.service.ts | 17 ++++++++++++----- .../editor-ui/src/components/ParameterInput.vue | 7 ++++++- packages/workflow/src/Constants.ts | 4 ++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 2eb0f55961..e00820a958 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -7,7 +7,7 @@ import type { INodeCredentialTestResult, INodeProperties, } from 'n8n-workflow'; -import { deepCopy, LoggerProxy, NodeHelpers } from 'n8n-workflow'; +import { CREDENTIAL_EMPTY_VALUE, deepCopy, LoggerProxy, NodeHelpers } from 'n8n-workflow'; import { Container } from 'typedi'; import type { FindManyOptions, FindOptionsWhere } from 'typeorm'; import { In } from 'typeorm'; @@ -300,7 +300,11 @@ export class CredentialsService { for (const dataKey of Object.keys(copiedData)) { // The frontend only cares that this value isn't falsy. if (dataKey === 'oauthTokenData') { - copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; + if (copiedData[dataKey].toString().length > 0) { + copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; + } else { + copiedData[dataKey] = CREDENTIAL_EMPTY_VALUE; + } continue; } const prop = properties.find((v) => v.name === dataKey); @@ -308,8 +312,11 @@ export class CredentialsService { continue; } if (prop.typeOptions?.password) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; + if (copiedData[dataKey].toString().length > 0) { + copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; + } else { + copiedData[dataKey] = CREDENTIAL_EMPTY_VALUE; + } } } @@ -321,7 +328,7 @@ export class CredentialsService { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument for (const [key, value] of Object.entries(unmerged)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (value === CREDENTIAL_BLANKING_VALUE) { + if (value === CREDENTIAL_BLANKING_VALUE || value === CREDENTIAL_EMPTY_VALUE) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access unmerged[key] = replacement[key]; } else if ( diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 3ef7c84f85..c886160c02 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -367,7 +367,7 @@ import type { EditorType, CodeNodeEditorLanguage, } from 'n8n-workflow'; -import { NodeHelpers } from 'n8n-workflow'; +import { NodeHelpers, CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow'; import CredentialsSelect from '@/components/CredentialsSelect.vue'; import ExpressionEdit from '@/components/ExpressionEdit.vue'; @@ -607,6 +607,11 @@ export default defineComponent({ return this.$locale.baseText('parameterInput.loadingOptions'); } + // if the value is marked as empty return empty string, to prevent displaying the asterisks + if (this.value === CREDENTIAL_EMPTY_VALUE) { + return ''; + } + let returnValue; if (this.isValueExpression === false) { returnValue = this.isResourceLocatorParameter diff --git a/packages/workflow/src/Constants.ts b/packages/workflow/src/Constants.ts index 48a44de600..c81cfd9e7c 100644 --- a/packages/workflow/src/Constants.ts +++ b/packages/workflow/src/Constants.ts @@ -14,3 +14,7 @@ export const NODES_WITH_RENAMABLE_CONTENT = new Set([ 'n8n-nodes-base.function', 'n8n-nodes-base.functionItem', ]); + +// Arbitrary value to represent an empty credential value +export const CREDENTIAL_EMPTY_VALUE = + '__n8n_EMPTY_VALUE_7b1af746-3729-4c60-9b9b-e08eb29e58da' as const; From 665710f432a3d68becdd1de757e87b6c8c917ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Fri, 23 Jun 2023 18:25:44 +0200 Subject: [PATCH 08/46] feat(editor): Ensure we use `v1.0.1` for `@n8n/codemirror-lang-sql` (no-changelog) (#6535) chore(editor): Ensure we use `v1.0.1` for `@n8n/codemirror-lang-sql` (no-changelog) --- packages/editor-ui/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 1312df7888..361c14fdf7 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -45,7 +45,7 @@ "@jsplumb/connector-bezier": "^5.13.2", "@jsplumb/core": "^5.13.2", "@jsplumb/util": "^5.13.2", - "@n8n/codemirror-lang-sql": "^1.0.0", + "@n8n/codemirror-lang-sql": "^1.0.1", "axios": "^0.21.1", "codemirror-lang-html-n8n": "^1.0.0", "codemirror-lang-n8n-expression": "^0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1ad530bd5..edd69c2452 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -819,8 +819,8 @@ importers: specifier: ^5.13.2 version: 5.13.2 '@n8n/codemirror-lang-sql': - specifier: ^1.0.0 - version: 1.0.0(@codemirror/view@6.5.1)(@lezer/common@1.0.1) + specifier: ^1.0.1 + version: 1.0.1(@codemirror/view@6.5.1)(@lezer/common@1.0.1) axios: specifier: ^0.21.1 version: 0.21.4 @@ -5262,8 +5262,8 @@ packages: dev: false optional: true - /@n8n/codemirror-lang-sql@1.0.0(@codemirror/view@6.5.1)(@lezer/common@1.0.1): - resolution: {integrity: sha512-7bmlhaSW+f/g+IarWbif/D9bUgwW8bjCbjfW6BCGqZHXTz9UQt8fM6tQ9MNh/3sZz9LPwcnT7XSSv73Ku0rriw==} + /@n8n/codemirror-lang-sql@1.0.1(@codemirror/view@6.5.1)(@lezer/common@1.0.1): + resolution: {integrity: sha512-wwuYHg+iCP6HMZjzBiBSO7oQ44EXnfkLaPl+9ZMvr4/8QYURqbulDdUhIW0A9d/zQcXhqkyoxS7ukS3s5oN6Hg==} dependencies: '@codemirror/autocomplete': 6.4.0(@codemirror/language@6.2.1)(@codemirror/state@6.1.4)(@codemirror/view@6.5.1)(@lezer/common@1.0.1) '@codemirror/language': 6.2.1 From f0ab023c3357afaa8923d263050ded8f8cf82224 Mon Sep 17 00:00:00 2001 From: Sandra Ashipala Date: Mon, 26 Jun 2023 11:05:05 +0200 Subject: [PATCH 09/46] fix: Minor grammar adjustments (no-changelog) (#6489) --- CONTRIBUTING.md | 26 +++++++++--------- packages/node-dev/README.md | 54 ++++++++++++++++++------------------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6960d5ce28..529bab60aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,8 +54,8 @@ The most important directories: ## Development setup -If you want to change or extend n8n you have to make sure that all needed -dependencies are installed and the packages get linked correctly. Here a short guide on how that can be done: +If you want to change or extend n8n you have to make sure that all the needed +dependencies are installed and the packages get linked correctly. Here's a short guide on how that can be done: ### Requirements @@ -69,7 +69,7 @@ dependencies are installed and the packages get linked correctly. Here a short g ##### pnpm workspaces -n8n is split up in different modules which are all in a single mono repository. +n8n is split up into different modules which are all in a single mono repository. To facilitate the module management, [pnpm workspaces](https://pnpm.io/workspaces) are used. This automatically sets up file-links between modules which depend on each other. @@ -113,24 +113,24 @@ No additional packages required. > **IMPORTANT**: All the steps below have to get executed at least once to get the development setup up and running! -Now that everything n8n requires to run is installed the actual n8n code can be +Now that everything n8n requires to run is installed, the actual n8n code can be checked out and set up: -1. [Fork](https://guides.github.com/activities/forking/#fork) the n8n repository +1. [Fork](https://guides.github.com/activities/forking/#fork) the n8n repository. -2. Clone your forked repository +2. Clone your forked repository: ``` git clone https://github.com//n8n.git ``` -3. Go into repository folder +3. Go into repository folder: ``` cd n8n ``` -4. Add the original n8n repository as `upstream` to your forked repository +4. Add the original n8n repository as `upstream` to your forked repository: ``` git remote add upstream https://github.com/n8n-io/n8n.git @@ -172,13 +172,13 @@ automatically build your code, restart the backend and refresh the frontend pnpm dev ``` 1. Hack, hack, hack -1. Check if everything still runs in production mode +1. Check if everything still runs in production mode: ``` pnpm build pnpm start ``` 1. Create tests -1. Run all [tests](#test-suite) +1. Run all [tests](#test-suite): ``` pnpm test ``` @@ -198,7 +198,7 @@ tests of all packages. ## Releasing -To start a release, trigger [this workflow](https://github.com/n8n-io/n8n/actions/workflows/release-create-pr.yml) with the SemVer release type, and select a branch to cut this release from. This workflow will then +To start a release, trigger [this workflow](https://github.com/n8n-io/n8n/actions/workflows/release-create-pr.yml) with the SemVer release type, and select a branch to cut this release from. This workflow will then: 1. Bump versions of packages that have changed or have dependencies that have changed 2. Update the Changelog @@ -206,7 +206,7 @@ To start a release, trigger [this workflow](https://github.com/n8n-io/n8n/action 4. Create a new pull-request to track any further changes that need to be included in this release Once ready to release, simply merge the pull-request. -This triggers [another workflow](https://github.com/n8n-io/n8n/actions/workflows/release-publish.yml), that will +This triggers [another workflow](https://github.com/n8n-io/n8n/actions/workflows/release-publish.yml), that will: 1. Build and publish the packages that have a new version in this release 2. Create a new tag, and GitHub release from squashed release commit @@ -226,4 +226,4 @@ That we do not have any potential problems later it is sadly necessary to sign a We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally only a few lines long. -A bot will automatically comment on the pull request once it got opened asking for the agreement to be signed. Before it did not get signed it is sadly not possible to merge it in. +Once a pull request is opened, an automated bot will promptly leave a comment requesting the agreement to be signed. The pull request can only be merged once the signature is obtained. diff --git a/packages/node-dev/README.md b/packages/node-dev/README.md index b27af6537d..4571d96a94 100644 --- a/packages/node-dev/README.md +++ b/packages/node-dev/README.md @@ -48,13 +48,13 @@ all the basics. A n8n node is a JavaScript file (normally written in TypeScript) which describes some basic information (like name, description, ...) and also at least one method. -Depending on which method got implemented defines if it is a a regular-, trigger- +Depending on which method gets implemented defines if it is a regular-, trigger- or webhook-node. A simple regular node which: - defines one node property -- sets its value do all items it receives +- sets its value to all items it receives would look like this: @@ -121,34 +121,34 @@ export class MyNode implements INodeType { ``` The "description" property has to be set on all nodes because it contains all -the base information. Additionally do all nodes have to have exactly one of the +the base information. Additionally all nodes have to have exactly one of the following methods defined which contains the actual logic: **Regular node** -Method get called when the workflow gets executed +Method is called when the workflow gets executed - `execute`: Executed once no matter how many items - `executeSingle`: Executed once for every item -By default always `execute` should be used especially when creating a -third-party integration. The reason for that is that it is way more flexible -and allows to, for example, return a different amount of items than it received -as input. This is very important when a node should query data like _return -all users_. In that case, does the node normally just receive one input-item -but returns as many as users exist. So in doubt always `execute` should be -used! +By default, `execute` should always be used, especially when creating a +third-party integration. The reason for this is that it provides much more +flexibility and allows, for example, returning a different number of items than +it received as input. This becomes crucial when a node needs to query data such as _return +all users_. In such cases, the node typically receives only one input item but returns as +many items as there are users. Therefore, when in doubt, it is recommended to use `execute`! + + **Trigger node** -Method gets called once when the workflow gets activated. It can then trigger -workflow runs which data it provides by itself. +Method is called once when the workflow gets activated. It can then trigger workflow runs and provide the necessary data by itself. - `trigger` **Webhook node** -Method gets called when webhook gets called. +Method is called when webhook gets called. - `webhook` @@ -157,12 +157,12 @@ Method gets called when webhook gets called. Property overview - **description** [required]: Describes the node like its name, properties, hooks, ... see `Node Type Description` bellow. -- **execute** [optional]: Method get called when the workflow gets executed (once). -- **executeSingle** [optional]: Method get called when the workflow gets executed (once for every item). +- **execute** [optional]: Method is called when the workflow gets executed (once). +- **executeSingle** [optional]: Method is called when the workflow gets executed (once for every item). - **hooks** [optional]: The hook methods. - **methods** [optional]: Additional methods. Currently only "loadOptions" exists which allows loading options for parameters from external services -- **trigger** [optional]: Method gets called once when the workflow gets activated. -- **webhook** [optional]: Method gets called when webhook gets called. +- **trigger** [optional]: Method is called once when the workflow gets activated. +- **webhook** [optional]: Method is called when webhook gets called. - **webhookMethods** [optional]: Methods to setup webhooks on external services. ### Node Type Description @@ -175,15 +175,15 @@ The following properties can be set in the node description: - **description** [required]: Description to display users in Editor UI - **group** [required]: Node group for example "transform" or "trigger" - **hooks** [optional]: Methods to execute at different points in time like when the workflow gets activated or deactivated -- **icon** [optional]: Icon to display (can be an icon or a font awsome icon) +- **icon** [optional]: Icon to display (can be an icon or a font awesome icon) - **inputs** [required]: Types of inputs the node has (currently only "main" exists) and the amount - **outputs** [required]: Types of outputs the node has (currently only "main" exists) and the amount -- **outputNames** [optional]: In case a node has multiple outputs names can be set that users know what data to expect -- **maxNodes** [optional]: If not an unlimited amount of nodes of that type can exist in a workflow the max-amount can be specified +- **outputNames** [optional]: In case a node has multiple outputs, names can be set that users know what data to expect +- **maxNodes** [optional]: If an unlimited number of nodes of that type cannot exist in a workflow, the max-amount can be specified - **name** [required]: Name of the node (for n8n to use internally, in camelCase) - **properties** [required]: Properties which get displayed in the Editor UI and can be set by the user - **subtitle** [optional]: Text which should be displayed underneath the name of the node in the Editor UI (can be an expression) -- **version** [required]: Version of the node. Currently always "1" (integer). For future usage, does not get used yet. +- **version** [required]: Version of the node. Currently always "1" (integer). For future usage, does not get used yet - **webhooks** [optional]: Webhooks the node should listen to ### Node Properties @@ -203,18 +203,18 @@ The following properties can be set in the node properties: ### Node Property Options -The following properties can be set in the node property options. +The following properties can be set in the node property options: All properties are optional. However, most only work when the node-property is of a specfic type. -- **alwaysOpenEditWindow** [type: json]: If set then the "Editor Window" will always open when the user tries to edit the field. Helpful if long text is typically used in the property. +- **alwaysOpenEditWindow** [type: json]: If set then the "Editor Window" will always open when the user tries to edit the field. Helpful if long text is typically used in the property - **loadOptionsMethod** [type: options]: Method to use to load options from an external service - **maxValue** [type: number]: Maximum value of the number - **minValue** [type: number]: Minimum value of the number - **multipleValues** [type: all]: If set the property gets turned into an Array and the user can add multiple values -- **multipleValueButtonText** [type: all]: Custom text for add button in case "multipleValues" got set -- **numberPrecision** [type: number]: The precision of the number. By default it is "0" and will so only allow integers. -- **password** [type: string]: If a password field should be displayed (normally only used by credentials because all node data is not encrypted and get saved in clear-text) +- **multipleValueButtonText** [type: all]: Custom text for add button in case "multipleValues" were set +- **numberPrecision** [type: number]: The precision of the number. By default, it is "0" and will only allow integers +- **password** [type: string]: If a password field should be displayed (normally only used by credentials because all node data is not encrypted and gets saved in clear-text) - **rows** [type: string]: Number of rows the input field should have. By default it is "1" ## License From e239730715733652d91e4f07f16fe36af3a1d473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Mon, 26 Jun 2023 12:04:21 +0200 Subject: [PATCH 10/46] ci: Automate `no-changelog` removal in generated changelog (no-changelog) (#6511) --- .github/scripts/package.json | 8 +++--- .github/scripts/update-changelog.mjs | 36 +++++++++++++++++++++++++ .github/workflows/release-create-pr.yml | 4 +-- 3 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 .github/scripts/update-changelog.mjs diff --git a/.github/scripts/package.json b/.github/scripts/package.json index 3c5740b2f7..679eeebead 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -1,8 +1,10 @@ { "dependencies": { - "conventional-changelog-cli": "^2.2.2", - "glob": "^10.2.7", - "semver": "^7.3.8", + "add-stream": "^1.0.0", + "conventional-changelog": "^4.0.0", + "glob": "^10.3.0", + "semver": "^7.5.2", + "tempfile": "^5.0.0", "typescript": "*" } } diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs new file mode 100644 index 0000000000..fda9ae1fd0 --- /dev/null +++ b/.github/scripts/update-changelog.mjs @@ -0,0 +1,36 @@ +import addStream from 'add-stream'; +import createTempFile from 'tempfile'; +import conventionalChangelog from 'conventional-changelog'; +import { resolve } from 'path'; +import { createReadStream, createWriteStream } from 'fs'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import stream from 'stream'; +import { promisify } from 'util'; + +const pipeline = promisify(stream.pipeline); + +const changelogFile = resolve(dirname(fileURLToPath(import.meta.url)), '../../CHANGELOG.md'); + +const changelogStream = conventionalChangelog({ + preset: 'angular', + releaseCount: 1, + tagPrefix: 'n8n@', + transform: (commit, callback) => { + callback(null, commit.header.includes('(no-changelog)') ? undefined : commit); + }, +}).on('error', (err) => { + console.error(err.stack); + process.exit(1); +}); + +// Since we can't read and write from the same file at the same time, +// we use a temporary file to output the updated changelog to. +const tmpFile = createTempFile(); +await pipeline( + changelogStream, + addStream(createReadStream(changelogFile)), + createWriteStream(tmpFile), +); + +await pipeline(createReadStream(tmpFile), createWriteStream(changelogFile)); diff --git a/.github/workflows/release-create-pr.yml b/.github/workflows/release-create-pr.yml index a7942cd6ec..79cf65e6b6 100644 --- a/.github/workflows/release-create-pr.yml +++ b/.github/workflows/release-create-pr.yml @@ -53,8 +53,8 @@ jobs: env: RELEASE_TYPE: ${{ github.event.inputs.release-type }} - - name: Generate Changelog - run: npx conventional-changelog-cli -p angular -i CHANGELOG.md -s -t n8n@ + - name: Update Changelog + run: node .github/scripts/update-changelog.mjs - name: Push the release branch, and Create the PR uses: peter-evans/create-pull-request@v4 From 186271e939bca19ec9c94d9455e9430d8b8cf9d7 Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Mon, 26 Jun 2023 12:43:53 +0200 Subject: [PATCH 11/46] fix(editor): Change default branchColor and remove label (#6541) change default branchColor and remove label --- .../src/components/__tests__/MainSidebarSourceControl.test.ts | 2 +- packages/editor-ui/src/plugins/i18n/locales/en.json | 1 - packages/editor-ui/src/stores/sourceControl.store.ts | 2 +- packages/editor-ui/src/views/SettingsSourceControl.vue | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/editor-ui/src/components/__tests__/MainSidebarSourceControl.test.ts b/packages/editor-ui/src/components/__tests__/MainSidebarSourceControl.test.ts index eb15095bab..b80bd9821f 100644 --- a/packages/editor-ui/src/components/__tests__/MainSidebarSourceControl.test.ts +++ b/packages/editor-ui/src/components/__tests__/MainSidebarSourceControl.test.ts @@ -66,7 +66,7 @@ describe('MainSidebarSourceControl', () => { authorEmail: '', repositoryUrl: '', branchReadOnly: false, - branchColor: '#F4A6DC', + branchColor: '#5296D6', connected: true, publicKey: '', }); diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 6deacf5843..3b2c1558ca 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1335,7 +1335,6 @@ "settings.sourceControl.gitConfig": "Git configuration", "settings.sourceControl.repoUrl": "Git repository URL (SSH)", "settings.sourceControl.repoUrlPlaceholder": "e.g. git@github.com:my-team/my-repository", - "settings.sourceControl.repoUrlDescription": "The SSH url of your Git repository", "settings.sourceControl.repoUrlInvalid": "The Git repository URL is not valid", "settings.sourceControl.authorName": "Commit author name", "settings.sourceControl.authorEmail": "Commit author email", diff --git a/packages/editor-ui/src/stores/sourceControl.store.ts b/packages/editor-ui/src/stores/sourceControl.store.ts index 4d33a1c679..982e4c4dda 100644 --- a/packages/editor-ui/src/stores/sourceControl.store.ts +++ b/packages/editor-ui/src/stores/sourceControl.store.ts @@ -21,7 +21,7 @@ export const useSourceControlStore = defineStore('sourceControl', () => { authorEmail: '', repositoryUrl: '', branchReadOnly: false, - branchColor: '#F4A6DC', + branchColor: '#5296D6', connected: false, publicKey: '', }); diff --git a/packages/editor-ui/src/views/SettingsSourceControl.vue b/packages/editor-ui/src/views/SettingsSourceControl.vue index eee3020341..d5b32ee330 100644 --- a/packages/editor-ui/src/views/SettingsSourceControl.vue +++ b/packages/editor-ui/src/views/SettingsSourceControl.vue @@ -226,7 +226,6 @@ const refreshBranches = async () => { >{{ locale.baseText('settings.sourceControl.button.disconnect') }} - {{ locale.baseText('settings.sourceControl.repoUrlDescription') }}
From b66d1510665789473d78f8c946154b2d69e6c660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 26 Jun 2023 13:33:31 +0200 Subject: [PATCH 12/46] refactor: Remove triggers and actions experiment (no-changelog) (#6512) * refactor: Remove triggers and actions experiment * fix: Use `value` for computed property * test: Fix e2e --- cypress/e2e/4-node-creator.cy.ts | 2 +- cypress/pages/workflow.ts | 5 +++-- .../Node/NodeCreator/Modes/ActionsMode.vue | 8 +------- packages/editor-ui/src/constants.ts | 12 +----------- 4 files changed, 6 insertions(+), 21 deletions(-) diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index 187345e940..5e081ce870 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -271,7 +271,7 @@ describe('Node Creator', () => { NDVModal.actions.close(); WorkflowPage.getters.canvasNodes().should('have.length', 2); WorkflowPage.actions.zoomToFit(); - WorkflowPage.actions.addNodeBetweenNodes('n8n', 'n8n1', 'Item Lists') + WorkflowPage.actions.addNodeBetweenNodes('n8n', 'n8n1', 'Item Lists', 'Summarize') WorkflowPage.getters.canvasNodes().should('have.length', 3); }) }); diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 8b6d195105..a17c52c9cf 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -241,14 +241,15 @@ export class WorkflowPage extends BasePage { executeWorkflow: () => { this.getters.executeWorkflowButton().click(); }, - addNodeBetweenNodes: (sourceNodeName: string, targetNodeName: string, newNodeName: string) => { + addNodeBetweenNodes: (sourceNodeName: string, targetNodeName: string, newNodeName: string, action?: string) => { this.getters.getConnectionBetweenNodes(sourceNodeName, targetNodeName).first().realHover(); this.getters .getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName) .find('.add') .first() .click({ force: true }); - this.actions.addNodeToCanvas(newNodeName, false); + + this.actions.addNodeToCanvas(newNodeName, false, false, action); }, deleteNodeBetweenNodes: ( sourceNodeName: string, diff --git a/packages/editor-ui/src/components/Node/NodeCreator/Modes/ActionsMode.vue b/packages/editor-ui/src/components/Node/NodeCreator/Modes/ActionsMode.vue index ba2e246536..1170f54bf1 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/Modes/ActionsMode.vue +++ b/packages/editor-ui/src/components/Node/NodeCreator/Modes/ActionsMode.vue @@ -13,10 +13,8 @@ import { REGULAR_NODE_CREATOR_VIEW, TRIGGER_NODE_CREATOR_VIEW, CUSTOM_API_CALL_KEY, - AUTO_INSERT_ACTION_EXPERIMENT, } from '@/constants'; -import { usePostHog } from '@/stores/posthog.store'; import { useUsersStore } from '@/stores/users.store'; import { useWebhooksStore } from '@/stores/webhooks.store'; import { runExternalHook } from '@/utils'; @@ -143,12 +141,8 @@ function onSelected(actionCreateElement: INodeCreateElement) { const isPlaceholderTriggerAction = placeholderTriggerActions.some( (p) => p.key === actionCreateElement.key, ); - const includeNodeWithPlaceholderTrigger = usePostHog().isVariantEnabled( - AUTO_INSERT_ACTION_EXPERIMENT.name, - AUTO_INSERT_ACTION_EXPERIMENT.variant, - ); - if (includeNodeWithPlaceholderTrigger && isPlaceholderTriggerAction && isTriggerRootView) { + if (isPlaceholderTriggerAction && isTriggerRootView.value) { const actionNode = actions.value[0].key; emit('nodeTypeSelected', [actionData.key as string, actionNode]); diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index f244280fe0..d21c2b423a 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -520,12 +520,6 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_ export const MAIN_AUTH_FIELD_NAME = 'authentication'; export const NODE_RESOURCE_FIELD_NAME = 'resource'; -export const AUTO_INSERT_ACTION_EXPERIMENT = { - name: '003_auto_insert_action', - control: 'control', - variant: 'variant', -}; - export const TEMPLATE_EXPERIMENT = { name: '002_remove_templates', control: 'control', @@ -538,11 +532,7 @@ export const ONBOARDING_EXPERIMENT = { variant: 'variant', }; -export const EXPERIMENTS_TO_TRACK = [ - TEMPLATE_EXPERIMENT.name, - AUTO_INSERT_ACTION_EXPERIMENT.name, - ONBOARDING_EXPERIMENT.name, -]; +export const EXPERIMENTS_TO_TRACK = [TEMPLATE_EXPERIMENT.name, ONBOARDING_EXPERIMENT.name]; export const NODE_TYPES_EXCLUDED_FROM_OUTPUT_NAME_APPEND = [FILTER_NODE_TYPE]; From 8b76e980852062b192a95593035697c43d6f808e Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 26 Jun 2023 16:37:40 +0100 Subject: [PATCH 13/46] feat(Matrix Node): Allow setting filename if the binary data has none (#6536) --- .../nodes/Matrix/GenericFunctions.ts | 10 ++++++-- .../nodes/Matrix/MediaDescription.ts | 24 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/Matrix/GenericFunctions.ts b/packages/nodes-base/nodes/Matrix/GenericFunctions.ts index a01b846934..eebe1aebfe 100644 --- a/packages/nodes-base/nodes/Matrix/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Matrix/GenericFunctions.ts @@ -189,6 +189,7 @@ export async function handleMatrixCall( const roomId = this.getNodeParameter('roomId', index) as string; const mediaType = this.getNodeParameter('mediaType', index) as string; const binaryPropertyName = this.getNodeParameter('binaryPropertyName', index); + const additionalFields = this.getNodeParameter('additionalFields', index); let body; const qs: IDataObject = {}; @@ -197,7 +198,12 @@ export async function handleMatrixCall( const { fileName, mimeType } = this.helpers.assertBinaryData(index, binaryPropertyName); body = await this.helpers.getBinaryDataBuffer(index, binaryPropertyName); - qs.filename = fileName; + if (additionalFields.fileName) { + qs.filename = additionalFields.fileName as string; + } else { + qs.filename = fileName; + } + headers['Content-Type'] = mimeType; headers.accept = 'application/json,text/*;q=0.99'; @@ -216,7 +222,7 @@ export async function handleMatrixCall( body = { msgtype: `m.${mediaType}`, - body: fileName, + body: qs.filename, url: uploadRequestResult.content_uri, }; const messageId = uuid(); diff --git a/packages/nodes-base/nodes/Matrix/MediaDescription.ts b/packages/nodes-base/nodes/Matrix/MediaDescription.ts index 22af3a068b..38e78557a2 100644 --- a/packages/nodes-base/nodes/Matrix/MediaDescription.ts +++ b/packages/nodes-base/nodes/Matrix/MediaDescription.ts @@ -81,8 +81,30 @@ export const mediaFields: INodeProperties[] = [ description: 'Image media type', }, ], - description: 'Name of the uploaded file', + description: 'Type of file being uploaded', placeholder: 'mxc://matrix.org/uploaded-media-uri', required: true, }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['media'], + operation: ['upload'], + }, + }, + options: [ + { + displayName: 'File Name', + name: 'fileName', + type: 'string', + default: '', + description: 'Name of the file being uploaded', + }, + ], + }, ]; From e1a02c76257de30e08878279dea33d7854d46938 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Mon, 26 Jun 2023 20:17:44 +0200 Subject: [PATCH 14/46] fix(editor): Add default author name and email to source control settings (#6543) --- .../editor-ui/src/stores/sourceControl.store.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/editor-ui/src/stores/sourceControl.store.ts b/packages/editor-ui/src/stores/sourceControl.store.ts index 982e4c4dda..54f472eb0f 100644 --- a/packages/editor-ui/src/stores/sourceControl.store.ts +++ b/packages/editor-ui/src/stores/sourceControl.store.ts @@ -1,24 +1,31 @@ import { computed, reactive } from 'vue'; import { defineStore } from 'pinia'; import { EnterpriseEditionFeature } from '@/constants'; -import { useSettingsStore } from '@/stores/settings.store'; +import { useSettingsStore, useRootStore, useUsersStore } from '@/stores'; import * as vcApi from '@/api/sourceControl'; -import { useRootStore } from '@/stores/n8nRoot.store'; import type { SourceControlPreferences } from '@/Interface'; export const useSourceControlStore = defineStore('sourceControl', () => { const rootStore = useRootStore(); const settingsStore = useSettingsStore(); + const usersStore = useUsersStore(); const isEnterpriseSourceControlEnabled = computed(() => settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.SourceControl), ); + const defaultAuthor = computed(() => { + const user = usersStore.currentUser; + return { + name: user?.fullName ?? `${user?.firstName} ${user?.lastName}`.trim(), + email: user?.email ?? '', + }; + }); const preferences = reactive({ branchName: '', branches: [], - authorName: '', - authorEmail: '', + authorName: defaultAuthor.value.name, + authorEmail: defaultAuthor.value.email, repositoryUrl: '', branchReadOnly: false, branchColor: '#5296D6', From e43924da36dd9c13f62bdcd2b761d483d22d532b Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Mon, 26 Jun 2023 22:08:17 -0400 Subject: [PATCH 15/46] refactor(editor): Update endpoint to retrieve limits (no-changelog) (#6539) update endpoint to retrieve limits --- packages/editor-ui/src/api/cloudPlans.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/api/cloudPlans.ts b/packages/editor-ui/src/api/cloudPlans.ts index eb39bd42d9..27dc6cc7ed 100644 --- a/packages/editor-ui/src/api/cloudPlans.ts +++ b/packages/editor-ui/src/api/cloudPlans.ts @@ -9,5 +9,5 @@ export async function getCurrentPlan( } export async function getCurrentUsage(context: IRestApiContext): Promise { - return get(context.baseUrl, '/limits'); + return get(context.baseUrl, '/cloud/limits'); } From d70a1cb0c82ee0a4b92776684c6c9079020d028f Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Tue, 27 Jun 2023 11:51:41 +0300 Subject: [PATCH 16/46] feat(Google Drive Node): Overhaul (#5941) --- .../GoogleDriveOAuth2Api.credentials.ts | 7 + .../nodes/Google/Drive/GoogleDrive.node.ts | 2761 +---------------- .../Google/Drive/GoogleDriveTrigger.node.ts | 4 +- .../Drive/test/v2/node/drive/create.test.ts | 78 + .../test/v2/node/drive/deleteDrive.test.ts | 50 + .../Drive/test/v2/node/drive/get.test.ts | 55 + .../Drive/test/v2/node/drive/list.test.ts | 78 + .../Drive/test/v2/node/drive/update.test.ts | 58 + .../Drive/test/v2/node/file/copy.test.ts | 76 + .../test/v2/node/file/createFromText.test.ts | 91 + .../test/v2/node/file/deleteFile.test.ts | 56 + .../Drive/test/v2/node/file/download.test.ts | 64 + .../Drive/test/v2/node/file/move.test.ts | 84 + .../Drive/test/v2/node/file/share.test.ts | 74 + .../Drive/test/v2/node/file/update.test.ts | 66 + .../Drive/test/v2/node/file/upload.test.ts | 96 + .../test/v2/node/fileFolder/search.test.ts | 119 + .../Drive/test/v2/node/folder/create.test.ts | 68 + .../test/v2/node/folder/deleteFolder.test.ts | 80 + .../Drive/test/v2/node/folder/share.test.ts | 64 + .../Google/Drive/test/v2/node/helpers.ts | 42 + .../nodes/Google/Drive/test/v2/utils.test.ts | 125 + .../Google/Drive/{ => v1}/GenericFunctions.ts | 2 +- .../Google/Drive/v1/GoogleDriveV1.node.ts | 2750 ++++++++++++++++ .../Google/Drive/{ => v1}/SearchFunctions.ts | 0 .../Google/Drive/v2/GoogleDriveV2.node.ts | 28 + .../Drive/v2/actions/common.descriptions.ts | 623 ++++ .../Drive/v2/actions/drive/Drive.resource.ts | 61 + .../v2/actions/drive/create.operation.ts | 263 ++ .../v2/actions/drive/deleteDrive.operation.ts | 41 + .../Drive/v2/actions/drive/get.operation.ts | 63 + .../Drive/v2/actions/drive/list.operation.ts | 103 + .../v2/actions/drive/update.operation.ts | 116 + .../Drive/v2/actions/file/File.resource.ts | 85 + .../Drive/v2/actions/file/copy.operation.ts | 136 + .../actions/file/createFromText.operation.ts | 183 ++ .../v2/actions/file/deleteFile.operation.ts | 67 + .../v2/actions/file/download.operation.ts | 284 ++ .../Drive/v2/actions/file/move.operation.ts | 84 + .../Drive/v2/actions/file/share.operation.ts | 64 + .../Drive/v2/actions/file/update.operation.ts | 274 ++ .../Drive/v2/actions/file/upload.operation.ts | 189 ++ .../actions/fileFolder/FileFolder.resource.ts | 29 + .../v2/actions/fileFolder/search.operation.ts | 359 +++ .../v2/actions/folder/Folder.resource.ts | 45 + .../v2/actions/folder/create.operation.ts | 109 + .../actions/folder/deleteFolder.operation.ts | 77 + .../v2/actions/folder/share.operation.ts | 64 + .../Google/Drive/v2/actions/node.type.ts | 18 + .../nodes/Google/Drive/v2/actions/router.ts | 55 + .../Drive/v2/actions/versionDescription.ts | 90 + .../Google/Drive/v2/helpers/interfaces.ts | 37 + .../nodes/Google/Drive/v2/helpers/utils.ts | 133 + .../nodes/Google/Drive/v2/methods/index.ts | 1 + .../Google/Drive/v2/methods/listSearch.ts | 199 ++ .../nodes/Google/Drive/v2/transport/index.ts | 111 + 56 files changed, 8200 insertions(+), 2739 deletions(-) create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/create.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/deleteDrive.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/get.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/list.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/update.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/file/copy.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/file/createFromText.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/file/deleteFile.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/file/download.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/file/move.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/file/share.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/file/update.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/file/upload.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/fileFolder/search.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/create.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/deleteFolder.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/share.test.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/node/helpers.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/test/v2/utils.test.ts rename packages/nodes-base/nodes/Google/Drive/{ => v1}/GenericFunctions.ts (97%) create mode 100644 packages/nodes-base/nodes/Google/Drive/v1/GoogleDriveV1.node.ts rename packages/nodes-base/nodes/Google/Drive/{ => v1}/SearchFunctions.ts (100%) create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/GoogleDriveV2.node.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/common.descriptions.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/drive/Drive.resource.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/drive/create.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/drive/deleteDrive.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/drive/get.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/drive/list.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/drive/update.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/file/File.resource.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/file/copy.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/file/createFromText.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/file/deleteFile.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/file/download.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/file/move.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/file/share.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/file/update.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/file/upload.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/fileFolder/FileFolder.resource.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/fileFolder/search.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/folder/Folder.resource.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/folder/create.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/folder/deleteFolder.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/folder/share.operation.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/node.type.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/router.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/actions/versionDescription.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/helpers/interfaces.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/helpers/utils.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/methods/index.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/methods/listSearch.ts create mode 100644 packages/nodes-base/nodes/Google/Drive/v2/transport/index.ts diff --git a/packages/nodes-base/credentials/GoogleDriveOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleDriveOAuth2Api.credentials.ts index 1f48245e56..a5b5312705 100644 --- a/packages/nodes-base/credentials/GoogleDriveOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/GoogleDriveOAuth2Api.credentials.ts @@ -22,5 +22,12 @@ export class GoogleDriveOAuth2Api implements ICredentialType { type: 'hidden', default: scopes.join(' '), }, + { + displayName: + 'Make sure that you have enabled the Google Drive API in the Google Cloud Console. More info.', + name: 'notice', + type: 'notice', + default: '', + }, ]; } diff --git a/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts b/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts index fe3169946b..ed2dde9c54 100644 --- a/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts +++ b/packages/nodes-base/nodes/Google/Drive/GoogleDrive.node.ts @@ -1,2738 +1,27 @@ -import type { - IDataObject, - IExecuteFunctions, - INodeExecutionData, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; -import { BINARY_ENCODING } from 'n8n-workflow'; - -import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; - -import { v4 as uuid } from 'uuid'; -import type { Readable } from 'stream'; -import { driveSearch, fileSearch, folderSearch } from './SearchFunctions'; - -const UPLOAD_CHUNK_SIZE = 256 * 1024; - -export class GoogleDrive implements INodeType { - description: INodeTypeDescription = { - displayName: 'Google Drive', - name: 'googleDrive', - icon: 'file:googleDrive.svg', - group: ['input'], - version: [1, 2], - subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Access data on Google Drive', - defaults: { - name: 'Google Drive', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'googleApi', - required: true, - displayOptions: { - show: { - authentication: ['serviceAccount'], - }, - }, - }, - { - name: 'googleDriveOAuth2Api', - required: true, - displayOptions: { - show: { - authentication: ['oAuth2'], - }, - }, - }, - ], - properties: [ - { - displayName: 'Authentication', - name: 'authentication', - type: 'options', - options: [ - { - name: 'Service Account', - value: 'serviceAccount', - }, - { - name: 'OAuth2', - value: 'oAuth2', - }, - ], - default: 'serviceAccount', - displayOptions: { - show: { - '@version': [1], - }, - }, - }, - { - displayName: 'Authentication', - name: 'authentication', - type: 'options', - options: [ - { - // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - name: 'OAuth2 (recommended)', - value: 'oAuth2', - }, - { - name: 'Service Account', - value: 'serviceAccount', - }, - ], - default: 'oAuth2', - displayOptions: { - show: { - '@version': [2], - }, - }, - }, - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Drive', - value: 'drive', - }, - { - name: 'File', - value: 'file', - }, - { - name: 'Folder', - value: 'folder', - }, - ], - default: 'file', - }, - - // ---------------------------------- - // operations - // ---------------------------------- - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { - show: { - resource: ['file'], - }, - }, - options: [ - { - name: 'Copy', - value: 'copy', - description: 'Copy a file', - action: 'Copy a file', - }, - { - name: 'Delete', - value: 'delete', - description: 'Delete a file', - action: 'Delete a file', - }, - { - name: 'Download', - value: 'download', - description: 'Download a file', - action: 'Download a file', - }, - { - name: 'List', - value: 'list', - description: 'List files and folders', - action: 'List a file', - }, - { - name: 'Share', - value: 'share', - description: 'Share a file', - action: 'Share a file', - }, - { - name: 'Update', - value: 'update', - description: 'Update a file', - action: 'Update a file', - }, - { - name: 'Upload', - value: 'upload', - description: 'Upload a file', - action: 'Upload a file', - }, - ], - default: 'upload', - }, - - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { - show: { - resource: ['folder'], - }, - }, - options: [ - { - name: 'Create', - value: 'create', - description: 'Create a folder', - action: 'Create a folder', - }, - { - name: 'Delete', - value: 'delete', - description: 'Delete a folder', - action: 'Delete a folder', - }, - { - name: 'Share', - value: 'share', - description: 'Share a folder', - action: 'Share a folder', - }, - ], - default: 'create', - }, - - // ---------------------------------- - // file - // ---------------------------------- - - { - displayName: 'File', - name: 'fileId', - type: 'resourceLocator', - default: { mode: 'list', value: '' }, - required: true, - modes: [ - { - displayName: 'File', - name: 'list', - type: 'list', - placeholder: 'Select a file...', - typeOptions: { - searchListMethod: 'fileSearch', - searchable: true, - }, - }, - { - displayName: 'Link', - name: 'url', - type: 'string', - placeholder: - 'https://drive.google.com/file/d/1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A/edit', - extractValue: { - type: 'regex', - regex: - 'https:\\/\\/(?:drive|docs)\\.google\\.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', - }, - validation: [ - { - type: 'regex', - properties: { - regex: - 'https:\\/\\/(?:drive|docs)\\.google.com(?:\\/.*|)\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', - errorMessage: 'Not a valid Google Drive File URL', - }, - }, - ], - }, - { - displayName: 'ID', - name: 'id', - type: 'string', - placeholder: '1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A', - validation: [ - { - type: 'regex', - properties: { - regex: '[a-zA-Z0-9\\-_]{2,}', - errorMessage: 'Not a valid Google Drive File ID', - }, - }, - ], - url: '=https://drive.google.com/file/d/{{$value}}/view', - }, - ], - displayOptions: { - show: { - operation: ['download', 'copy', 'update', 'delete', 'share'], - resource: ['file'], - }, - }, - description: 'The ID of the file', - }, - - { - displayName: 'Folder', - name: 'fileId', - type: 'resourceLocator', - default: { mode: 'list', value: '' }, - required: true, - modes: [ - { - displayName: 'Folder', - name: 'list', - type: 'list', - placeholder: 'Select a folder...', - typeOptions: { - searchListMethod: 'folderSearch', - searchable: true, - }, - }, - { - displayName: 'Link', - name: 'url', - type: 'string', - placeholder: 'https://drive.google.com/drive/folders/1Tx9WHbA3wBpPB4C_HcoZDH9WZFWYxAMU', - extractValue: { - type: 'regex', - regex: - 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', - }, - validation: [ - { - type: 'regex', - properties: { - regex: - 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', - errorMessage: 'Not a valid Google Drive Folder URL', - }, - }, - ], - }, - { - displayName: 'ID', - name: 'id', - type: 'string', - placeholder: '1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A', - validation: [ - { - type: 'regex', - properties: { - regex: '[a-zA-Z0-9\\-_]{2,}', - errorMessage: 'Not a valid Google Drive Folder ID', - }, - }, - ], - url: '=https://drive.google.com/drive/folders/{{$value}}', - }, - ], - displayOptions: { - show: { - operation: ['delete', 'share'], - resource: ['folder'], - }, - }, - description: 'The ID of the folder', - }, - - // ---------------------------------- - // file:copy - // ---------------------------------- - - // ---------------------------------- - // file/folder:delete - // ---------------------------------- - - // ---------------------------------- - // file:download - // ---------------------------------- - { - displayName: 'Binary Property', - name: 'binaryPropertyName', - type: 'string', - required: true, - default: 'data', - displayOptions: { - show: { - operation: ['download'], - resource: ['file'], - }, - }, - description: 'Name of the binary property to which to write the data of the read file', - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - displayOptions: { - show: { - operation: ['download'], - resource: ['file'], - }, - }, - options: [ - { - displayName: 'Google File Conversion', - name: 'googleFileConversion', - type: 'fixedCollection', - typeOptions: { - multipleValues: false, - }, - default: {}, - placeholder: 'Add Conversion', - options: [ - { - displayName: 'Conversion', - name: 'conversion', - values: [ - { - displayName: 'Google Docs', - name: 'docsToFormat', - type: 'options', - options: [ - { - name: 'To HTML', - value: 'text/html', - }, - { - name: 'To MS Word', - value: - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }, - { - name: 'To OpenOffice Doc', - value: 'application/vnd.oasis.opendocument.text', - }, - { - name: 'To PDF', - value: 'application/pdf', - }, - { - name: 'To Rich Text', - value: 'application/rtf', - }, - ], - default: - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - description: 'Format used to export when downloading Google Docs files', - }, - { - displayName: 'Google Drawings', - name: 'drawingsToFormat', - type: 'options', - options: [ - { - name: 'To JPEG', - value: 'image/jpeg', - }, - { - name: 'To PNG', - value: 'image/png', - }, - { - name: 'To SVG', - value: 'image/svg+xml', - }, - { - name: 'To PDF', - value: 'application/pdf', - }, - ], - default: 'image/jpeg', - description: 'Format used to export when downloading Google Drawings files', - }, - { - displayName: 'Google Slides', - name: 'slidesToFormat', - type: 'options', - options: [ - { - name: 'To MS PowerPoint', - value: - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - }, - { - name: 'To PDF', - value: 'application/pdf', - }, - { - name: 'To OpenOffice Presentation', - value: 'application/vnd.oasis.opendocument.presentation', - }, - { - name: 'To Plain Text', - value: 'text/plain', - }, - ], - default: - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - description: 'Format used to export when downloading Google Slides files', - }, - { - displayName: 'Google Sheets', - name: 'sheetsToFormat', - type: 'options', - options: [ - { - name: 'To MS Excel', - value: 'application/x-vnd.oasis.opendocument.spreadsheet', - }, - { - name: 'To PDF', - value: 'application/pdf', - }, - { - name: 'To CSV', - value: 'text/csv', - }, - ], - default: 'application/x-vnd.oasis.opendocument.spreadsheet', - description: 'Format used to export when downloading Google Spreadsheets files', - }, - ], - }, - ], - }, - { - displayName: 'File Name', - name: 'fileName', - type: 'string', - default: '', - description: 'File name. Ex: data.pdf.', - }, - ], - }, - - // ---------------------------------- - // file:list - // ---------------------------------- - { - displayName: 'Use Query String', - name: 'useQueryString', - type: 'boolean', - default: false, - displayOptions: { - show: { - operation: ['list'], - resource: ['file'], - }, - }, - description: 'Whether a query string should be used to filter results', - }, - { - displayName: 'Query String', - name: 'queryString', - type: 'string', - default: '', - displayOptions: { - show: { - operation: ['list'], - useQueryString: [true], - resource: ['file'], - }, - }, - placeholder: "name contains 'invoice'", - description: 'Query to use to return only specific files', - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - operation: ['list'], - resource: ['file'], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 1000, - }, - default: 50, - description: 'Max number of results to return', - }, - { - displayName: 'Filters', - name: 'queryFilters', - placeholder: 'Add Filter', - description: 'Filters to use to return only specific files', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - default: {}, - displayOptions: { - show: { - operation: ['list'], - useQueryString: [false], - resource: ['file'], - }, - }, - options: [ - { - name: 'name', - displayName: 'Name', - values: [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Contains', - value: 'contains', - }, - { - name: 'Is', - value: 'is', - }, - { - name: 'Is Not', - value: 'isNot', - }, - ], - default: 'contains', - }, - { - displayName: 'Value', - name: 'value', - type: 'string', - default: '', - description: 'The value for operation', - }, - ], - }, - { - name: 'mimeType', - displayName: 'Mime Type', - values: [ - { - displayName: 'Mime Type', - name: 'mimeType', - type: 'options', - options: [ - { - name: '3rd Party Shortcut', - value: 'application/vnd.google-apps.drive-sdk', - }, - { - name: 'Audio', - value: 'application/vnd.google-apps.audio', - }, - { - name: 'Custom Mime Type', - value: 'custom', - }, - { - name: 'Google Apps Scripts', - value: 'application/vnd.google-apps.script', - }, - { - name: 'Google Docs', - value: 'application/vnd.google-apps.document', - }, - { - name: 'Google Drawing', - value: 'application/vnd.google-apps.drawing', - }, - { - name: 'Google Drive File', - value: 'application/vnd.google-apps.file', - }, - { - name: 'Google Drive Folder', - value: 'application/vnd.google-apps.folder', - }, - { - name: 'Google Forms', - value: 'application/vnd.google-apps.form', - }, - { - name: 'Google Fusion Tables', - value: 'application/vnd.google-apps.fusiontable', - }, - { - name: 'Google My Maps', - value: 'application/vnd.google-apps.map', - }, - { - name: 'Google Sheets', - value: 'application/vnd.google-apps.spreadsheet', - }, - { - name: 'Google Sites', - value: 'application/vnd.google-apps.site', - }, - { - name: 'Google Slides', - value: 'application/vnd.google-apps.presentation', - }, - { - name: 'Photo', - value: 'application/vnd.google-apps.photo', - }, - { - name: 'Unknown', - value: 'application/vnd.google-apps.unknown', - }, - { - name: 'Video', - value: 'application/vnd.google-apps.video', - }, - ], - default: 'application/vnd.google-apps.file', - description: 'The Mime-Type of the files to return', - }, - { - displayName: 'Custom Mime Type', - name: 'customMimeType', - type: 'string', - default: '', - displayOptions: { - show: { - mimeType: ['custom'], - }, - }, - }, - ], - }, - ], - }, - - // ---------------------------------- - // file:share - // ---------------------------------- - { - displayName: 'Permissions', - name: 'permissionsUi', - placeholder: 'Add Permission', - type: 'fixedCollection', - default: {}, - typeOptions: { - multipleValues: false, - }, - displayOptions: { - show: { - resource: ['file', 'folder'], - operation: ['share'], - }, - }, - options: [ - { - displayName: 'Permission', - name: 'permissionsValues', - values: [ - { - displayName: 'Role', - name: 'role', - type: 'options', - options: [ - { - name: 'Commenter', - value: 'commenter', - }, - { - name: 'File Organizer', - value: 'fileOrganizer', - }, - { - name: 'Organizer', - value: 'organizer', - }, - { - name: 'Owner', - value: 'owner', - }, - { - name: 'Reader', - value: 'reader', - }, - { - name: 'Writer', - value: 'writer', - }, - ], - default: '', - }, - { - displayName: 'Type', - name: 'type', - type: 'options', - options: [ - { - name: 'User', - value: 'user', - }, - { - name: 'Group', - value: 'group', - }, - { - name: 'Domain', - value: 'domain', - }, - { - name: 'Anyone', - value: 'anyone', - }, - ], - default: '', - description: - 'Information about the different types can be found here', - }, - { - displayName: 'Email Address', - name: 'emailAddress', - type: 'string', - displayOptions: { - show: { - type: ['user', 'group'], - }, - }, - default: '', - description: - 'The email address of the user or group to which this permission refers', - }, - { - displayName: 'Domain', - name: 'domain', - type: 'string', - displayOptions: { - show: { - type: ['domain'], - }, - }, - default: '', - description: 'The domain to which this permission refers', - }, - { - displayName: 'Allow File Discovery', - name: 'allowFileDiscovery', - type: 'boolean', - displayOptions: { - show: { - type: ['domain', 'anyone'], - }, - }, - default: false, - description: - 'Whether the permission allows the file to be discovered through search', - }, - ], - }, - ], - }, - - { - displayName: 'Binary Data', - name: 'binaryData', - type: 'boolean', - default: false, - displayOptions: { - show: { - operation: ['upload'], - resource: ['file'], - }, - }, - description: 'Whether the data to upload should be taken from binary field', - }, - { - displayName: 'File Content', - name: 'fileContent', - type: 'string', - default: '', - displayOptions: { - show: { - operation: ['upload'], - resource: ['file'], - binaryData: [false], - }, - }, - placeholder: '', - description: 'The text content of the file to upload', - }, - { - displayName: 'Binary Property', - name: 'binaryPropertyName', - type: 'string', - default: 'data', - required: true, - displayOptions: { - show: { - operation: ['upload'], - resource: ['file'], - binaryData: [true], - }, - }, - placeholder: '', - description: - 'Name of the binary property which contains the data for the file to be uploaded', - }, - - // ---------------------------------- - // file:update - // ---------------------------------- - { - displayName: 'Update Fields', - name: 'updateFields', - type: 'collection', - placeholder: 'Add Option', - default: {}, - displayOptions: { - show: { - operation: ['update'], - resource: ['file'], - }, - }, - options: [ - { - displayName: 'File Name', - name: 'fileName', - type: 'string', - default: '', - description: 'The name of the file', - }, - { - displayName: 'Keep Revision Forever', - name: 'keepRevisionForever', - type: 'boolean', - default: false, - description: - "Whether to set the 'keepForever' field in the new head revision. This is only applicable to files with binary content in Google Drive. Only 200 revisions for the file can be kept forever. If the limit is reached, try deleting pinned revisions.", - }, - { - displayName: 'Move to Trash', - name: 'trashed', - type: 'boolean', - default: false, - description: 'Whether to move a file to the trash. Only the owner may trash a file.', - }, - { - displayName: 'OCR Language', - name: 'ocrLanguage', - type: 'string', - default: '', - description: 'A language hint for OCR processing during image import (ISO 639-1 code)', - }, - { - displayName: 'Parent ID', - name: 'parentId', - type: 'string', - default: '', - description: 'The ID of the parent to set', - }, - { - displayName: 'Use Content As Indexable Text', - name: 'useContentAsIndexableText', - type: 'boolean', - default: false, - description: 'Whether to use the uploaded content as indexable text', - }, - ], - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - displayOptions: { - show: { - operation: ['update'], - resource: ['file'], - }, - }, - options: [ - { - displayName: 'Fields', - name: 'fields', - type: 'multiOptions', - options: [ - { - name: '[All]', - value: '*', - description: 'All fields', - }, - { - name: 'explicitlyTrashed', - value: 'explicitlyTrashed', - }, - { - name: 'exportLinks', - value: 'exportLinks', - }, - { - name: 'hasThumbnail', - value: 'hasThumbnail', - }, - { - name: 'iconLink', - value: 'iconLink', - }, - { - name: 'ID', - value: 'id', - }, - { - name: 'Kind', - value: 'kind', - }, - { - name: 'mimeType', - value: 'mimeType', - }, - { - name: 'Name', - value: 'name', - }, - { - name: 'Permissions', - value: 'permissions', - }, - { - name: 'Shared', - value: 'shared', - }, - { - name: 'Spaces', - value: 'spaces', - }, - { - name: 'Starred', - value: 'starred', - }, - { - name: 'thumbnailLink', - value: 'thumbnailLink', - }, - { - name: 'Trashed', - value: 'trashed', - }, - { - name: 'Version', - value: 'version', - }, - { - name: 'webViewLink', - value: 'webViewLink', - }, - ], - default: [], - description: 'The fields to return', - }, - ], - }, - // ---------------------------------- - // file:upload - // ---------------------------------- - { - displayName: 'File Name', - name: 'name', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['upload'], - resource: ['file'], - }, - }, - placeholder: 'invoice_1.pdf', - description: 'The name the file should be saved as', - }, - // ---------------------------------- - { - displayName: 'Resolve Data', - name: 'resolveData', - type: 'boolean', - default: false, - displayOptions: { - show: { - operation: ['upload'], - resource: ['file'], - }, - }, - // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether - description: - 'By default the response only contain the ID of the file. If this option gets activated, it will resolve the data automatically.', - }, - { - displayName: 'Parents', - name: 'parents', - type: 'string', - typeOptions: { - multipleValues: true, - }, - default: [], - displayOptions: { - show: { - operation: ['upload'], - resource: ['file'], - }, - }, - description: 'The IDs of the parent folders which contain the file', - }, - - // ---------------------------------- - // folder - // ---------------------------------- - - // ---------------------------------- - // folder:create - // ---------------------------------- - { - displayName: 'Folder', - name: 'name', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['create'], - resource: ['folder'], - }, - }, - placeholder: 'invoices', - description: 'The name of folder to create', - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - displayOptions: { - show: { - '/operation': ['copy', 'list', 'share', 'create'], - '/resource': ['file', 'folder'], - }, - }, - options: [ - { - displayName: 'Email Message', - name: 'emailMessage', - type: 'string', - displayOptions: { - show: { - '/operation': ['share'], - '/resource': ['file', 'folder'], - }, - }, - default: '', - description: 'A plain text custom message to include in the notification email', - }, - { - displayName: 'Enforce Single Parent', - name: 'enforceSingleParent', - type: 'boolean', - displayOptions: { - show: { - '/operation': ['share'], - '/resource': ['file', 'folder'], - }, - }, - default: false, - description: - 'Whether to opt in to API behavior that aims for all items to have exactly one parent. This parameter only takes effect if the item is not in a shared drive.', - }, - { - displayName: 'Fields', - name: 'fields', - type: 'multiOptions', - displayOptions: { - show: { - '/operation': ['list', 'copy'], - }, - }, - options: [ - { - name: '*', - value: '*', - description: 'All fields', - }, - { - name: 'explicitlyTrashed', - value: 'explicitlyTrashed', - }, - { - name: 'exportLinks', - value: 'exportLinks', - }, - { - name: 'hasThumbnail', - value: 'hasThumbnail', - }, - { - name: 'iconLink', - value: 'iconLink', - }, - { - name: 'ID', - value: 'id', - }, - { - name: 'Kind', - value: 'kind', - }, - { - name: 'mimeType', - value: 'mimeType', - }, - { - name: 'Name', - value: 'name', - }, - { - name: 'Permissions', - value: 'permissions', - }, - { - name: 'Shared', - value: 'shared', - }, - { - name: 'Spaces', - value: 'spaces', - }, - { - name: 'Starred', - value: 'starred', - }, - { - name: 'thumbnailLink', - value: 'thumbnailLink', - }, - { - name: 'Trashed', - value: 'trashed', - }, - { - name: 'Version', - value: 'version', - }, - { - name: 'webViewLink', - value: 'webViewLink', - }, - ], - default: [], - description: 'The fields to return', - }, - { - displayName: 'Move To New Owners Root', - name: 'moveToNewOwnersRoot', - type: 'boolean', - displayOptions: { - show: { - '/operation': ['share'], - '/resource': ['file', 'folder'], - }, - }, - default: false, - // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether - description: - "

This parameter only takes effect if the item is not in a shared drive and the request is attempting to transfer the ownership of the item.

When set to true, the item is moved to the new owner's My Drive root folder and all prior parents removed.

", - }, - { - displayName: 'Send Notification Email', - name: 'sendNotificationEmail', - type: 'boolean', - displayOptions: { - show: { - '/operation': ['share'], - '/resource': ['file', 'folder'], - }, - }, - default: false, - description: 'Whether to send a notification email when sharing to users or groups', - }, - { - displayName: 'Supports All Drives', - name: 'supportsAllDrives', - type: 'boolean', - displayOptions: { - show: { - '/operation': ['share'], - '/resource': ['file', 'folder'], - }, - }, - default: false, - description: - 'Whether the requesting application supports both My Drives and shared drives', - }, - { - displayName: 'Transfer Ownership', - name: 'transferOwnership', - type: 'boolean', - displayOptions: { - show: { - '/operation': ['share'], - '/resource': ['file', 'folder'], - }, - }, - default: false, - description: - 'Whether to transfer ownership to the specified user and downgrade the current owner to a writer', - }, - { - displayName: 'Use Domain Admin Access', - name: 'useDomainAdminAccess', - type: 'boolean', - displayOptions: { - show: { - '/operation': ['share'], - '/resource': ['file', 'folder'], - }, - }, - default: false, - description: - 'Whether to perform the operation as domain administrator, i.e. if you are an administrator of the domain to which the shared drive belongs, you will be granted access automatically.', - }, - - { - displayName: 'File Name', - name: 'name', - type: 'string', - displayOptions: { - show: { - '/operation': ['copy'], - '/resource': ['file'], - }, - }, - default: '', - placeholder: 'invoice_1.pdf', - description: 'The name the file should be saved as', - }, - { - displayName: 'Parents', - name: 'parents', - type: 'string', - displayOptions: { - show: { - '/operation': ['copy', 'create'], - '/resource': ['file', 'folder'], - }, - }, - typeOptions: { - multipleValues: true, - }, - default: [], - description: 'The IDs of the parent folders the file/folder should be saved in', - }, - { - displayName: 'Spaces', - name: 'spaces', - type: 'multiOptions', - displayOptions: { - show: { - '/operation': ['list'], - '/resource': ['file'], - }, - }, - options: [ - { - name: '[All]', - value: '*', - description: 'All spaces', - }, - { - name: 'appDataFolder', - value: 'appDataFolder', - }, - { - name: 'Drive', - value: 'drive', - }, - { - name: 'Photos', - value: 'photos', - }, - ], - default: [], - description: 'The spaces to operate on', - }, - { - displayName: 'Corpora', - name: 'corpora', - type: 'options', - displayOptions: { - show: { - '/operation': ['list'], - '/resource': ['file'], - }, - }, - options: [ - { - name: 'User', - value: 'user', - description: 'All files in "My Drive" and "Shared with me"', - }, - { - name: 'Domain', - value: 'domain', - description: "All files shared to the user's domain that are searchable", - }, - { - name: 'Drive', - value: 'drive', - description: 'All files contained in a single shared drive', - }, - { - name: 'allDrives', - value: 'allDrives', - description: 'All drives', - }, - ], - default: '', - description: 'The corpora to operate on', - }, - { - displayName: 'Drive ID', - name: 'driveId', - type: 'string', - default: '', - displayOptions: { - show: { - '/operation': ['list'], - '/resource': ['file'], - corpora: ['drive'], - }, - }, - description: - 'ID of the shared drive to search. The driveId parameter must be specified if and only if corpora is set to drive.', - }, - ], - }, - // ---------------------------------- - // drive - // ---------------------------------- - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { - show: { - resource: ['drive'], - }, - }, - options: [ - { - name: 'Create', - value: 'create', - description: 'Create a drive', - action: 'Create a drive', - }, - { - name: 'Delete', - value: 'delete', - description: 'Delete a drive', - action: 'Delete a drive', - }, - { - name: 'Get', - value: 'get', - description: 'Get a drive', - action: 'Get a drive', - }, - { - name: 'List', - value: 'list', - description: 'List all drives', - action: 'List all drives', - }, - { - name: 'Update', - value: 'update', - description: 'Update a drive', - action: 'Update a drive', - }, - ], - default: 'create', - }, - - { - displayName: 'Drive', - name: 'driveId', - type: 'resourceLocator', - default: { mode: 'list', value: '' }, - required: true, - hint: 'The Google Drive drive to operate on', - modes: [ - { - displayName: 'Drive', - name: 'list', - type: 'list', - placeholder: 'Drive', - typeOptions: { - searchListMethod: 'driveSearch', - searchable: true, - }, - }, - { - displayName: 'Link', - name: 'url', - type: 'string', - placeholder: 'https://drive.google.com/drive/folders/0AaaaaAAAAAAAaa', - extractValue: { - type: 'regex', - regex: - 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', - }, - validation: [ - { - type: 'regex', - properties: { - regex: - 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', - errorMessage: 'Not a valid Google Drive Drive URL', - }, - }, - ], - }, - { - displayName: 'ID', - name: 'id', - type: 'string', - hint: 'The ID of the shared drive', - validation: [ - { - type: 'regex', - properties: { - regex: '[a-zA-Z0-9\\-_]{2,}', - errorMessage: 'Not a valid Google Drive Drive ID', - }, - }, - ], - url: '=https://drive.google.com/drive/folders/{{$value}}', - }, - ], - displayOptions: { - show: { - operation: ['delete', 'get', 'update'], - resource: ['drive'], - }, - }, - description: 'The ID of the drive', - }, - - // ---------------------------------- - // drive:create - // ---------------------------------- - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - displayOptions: { - show: { - operation: ['create'], - resource: ['drive'], - }, - }, - description: 'The name of this shared drive', - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - displayOptions: { - show: { - operation: ['create'], - resource: ['drive'], - }, - }, - options: [ - { - displayName: 'Capabilities', - name: 'capabilities', - type: 'collection', - placeholder: 'Add Field', - default: {}, - options: [ - { - displayName: 'Can Add Children', - name: 'canAddChildren', - type: 'boolean', - default: false, - description: - 'Whether the current user can add children to folders in this shared drive', - }, - { - displayName: 'Can Change Copy Requires Writer Permission Restriction', - name: 'canChangeCopyRequiresWriterPermissionRestriction', - type: 'boolean', - default: false, - description: - 'Whether the current user can change the copyRequiresWriterPermission restriction of this shared drive', - }, - { - displayName: 'Can Change Domain Users Only Restriction', - name: 'canChangeDomainUsersOnlyRestriction', - type: 'boolean', - default: false, - description: - 'Whether the current user can change the domainUsersOnly restriction of this shared drive', - }, - { - displayName: 'Can Change Drive Background', - name: 'canChangeDriveBackground', - type: 'boolean', - default: false, - description: - 'Whether the current user can change the background of this shared drive', - }, - { - displayName: 'Can Change Drive Members Only Restriction', - name: 'canChangeDriveMembersOnlyRestriction', - type: 'boolean', - default: false, - description: - 'Whether the current user can change the driveMembersOnly restriction of this shared drive', - }, - { - displayName: 'Can Comment', - name: 'canComment', - type: 'boolean', - default: false, - description: 'Whether the current user can comment on files in this shared drive', - }, - { - displayName: 'Can Copy', - name: 'canCopy', - type: 'boolean', - default: false, - description: 'Whether the current user can copy files in this shared drive', - }, - { - displayName: 'Can Delete Children', - name: 'canDeleteChildren', - type: 'boolean', - default: false, - description: - 'Whether the current user can delete children from folders in this shared drive', - }, - { - displayName: 'Can Delete Drive', - name: 'canDeleteDrive', - type: 'boolean', - default: false, - description: - 'Whether the current user can delete this shared drive. Attempting to delete the shared drive may still fail if there are untrashed items inside the shared drive.', - }, - { - displayName: 'Can Download', - name: 'canDownload', - type: 'boolean', - default: false, - description: 'Whether the current user can download files in this shared drive', - }, - { - displayName: 'Can Edit', - name: 'canEdit', - type: 'boolean', - default: false, - description: 'Whether the current user can edit files in this shared drive', - }, - { - displayName: 'Can List Children', - name: 'canListChildren', - type: 'boolean', - default: false, - description: - 'Whether the current user can list the children of folders in this shared drive', - }, - { - displayName: 'Can Manage Members', - name: 'canManageMembers', - type: 'boolean', - default: false, - description: - 'Whether the current user can add members to this shared drive or remove them or change their role', - }, - { - displayName: 'Can Read Revisions', - name: 'canReadRevisions', - type: 'boolean', - default: false, - description: - 'Whether the current user can read the revisions resource of files in this shared drive', - }, - { - displayName: 'Can Rename', - name: 'canRename', - type: 'boolean', - default: false, - description: - 'Whether the current user can rename files or folders in this shared drive', - }, - { - displayName: 'Can Rename Drive', - name: 'canRenameDrive', - type: 'boolean', - default: false, - description: 'Whether the current user can rename this shared drive', - }, - { - displayName: 'Can Share', - name: 'canShare', - type: 'boolean', - default: false, - description: 'Whether the current user can rename this shared drive', - }, - { - displayName: 'Can Trash Children', - name: 'canTrashChildren', - type: 'boolean', - default: false, - description: - 'Whether the current user can trash children from folders in this shared drive', - }, - ], - }, - { - displayName: 'Color RGB', - name: 'colorRgb', - type: 'color', - default: '', - description: 'The color of this shared drive as an RGB hex string', - }, - { - displayName: 'Created Time', - name: 'createdTime', - type: 'dateTime', - default: '', - description: 'The time at which the shared drive was created (RFC 3339 date-time)', - }, - { - displayName: 'Hidden', - name: 'hidden', - type: 'boolean', - default: false, - description: 'Whether the shared drive is hidden from default view', - }, - { - displayName: 'Restrictions', - name: 'restrictions', - type: 'collection', - placeholder: 'Add Field', - default: {}, - options: [ - { - displayName: 'Admin Managed Restrictions', - name: 'adminManagedRestrictions', - type: 'boolean', - default: false, - description: - 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', - }, - { - displayName: 'Copy Requires Writer Permission', - name: 'copyRequiresWriterPermission', - type: 'boolean', - default: false, - description: - 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', - }, - { - displayName: 'Domain Users Only', - name: 'domainUsersOnly', - type: 'boolean', - default: false, - description: - 'Whether access to this shared drive and items inside this shared drive is restricted to users of the domain to which this shared drive belongs. This restriction may be overridden by other sharing policies controlled outside of this shared drive.', - }, - { - displayName: 'Drive Members Only', - name: 'driveMembersOnly', - type: 'boolean', - default: false, - description: - 'Whether access to items inside this shared drive is restricted to its members', - }, - ], - }, - ], - }, - // ---------------------------------- - // drive:delete - // ---------------------------------- - - // ---------------------------------- - // drive:get - // ---------------------------------- - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - displayOptions: { - show: { - operation: ['get'], - resource: ['drive'], - }, - }, - options: [ - { - displayName: 'Use Domain Admin Access', - name: 'useDomainAdminAccess', - type: 'boolean', - default: false, - description: - 'Whether to issue the request as a domain administrator; if set to true, then the requester will be granted access if they are an administrator of the domain to which the shared drive belongs. (Default: false).', - }, - ], - }, - // ---------------------------------- - // drive:list - // ---------------------------------- - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: ['list'], - resource: ['drive'], - }, - }, - default: false, - description: 'Whether to return all results or only up to a given limit', - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - operation: ['list'], - resource: ['drive'], - returnAll: [false], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 200, - }, - default: 100, - description: 'Max number of results to return', - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - displayOptions: { - show: { - operation: ['list'], - resource: ['drive'], - }, - }, - options: [ - { - displayName: 'Query', - name: 'q', - type: 'string', - default: '', - description: - 'Query string for searching shared drives. See the "Search for shared drives" guide for supported syntax.', - }, - { - displayName: 'Use Domain Admin Access', - name: 'useDomainAdminAccess', - type: 'boolean', - default: false, - description: - 'Whether to issue the request as a domain administrator; if set to true, then the requester will be granted access if they are an administrator of the domain to which the shared drive belongs. (Default: false).', - }, - ], - }, - // ---------------------------------- - // drive:update - // ---------------------------------- - { - displayName: 'Update Fields', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - displayOptions: { - show: { - operation: ['update'], - resource: ['drive'], - }, - }, - options: [ - { - displayName: 'Color RGB', - name: 'colorRgb', - type: 'color', - default: '', - description: 'The color of this shared drive as an RGB hex string', - }, - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - description: 'The name of this shared drive', - }, - { - displayName: 'Restrictions', - name: 'restrictions', - type: 'collection', - placeholder: 'Add Field', - default: {}, - options: [ - { - displayName: 'Admin Managed Restrictions', - name: 'adminManagedRestrictions', - type: 'boolean', - default: false, - description: - 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', - }, - { - displayName: 'Copy Requires Writer Permission', - name: 'copyRequiresWriterPermission', - type: 'boolean', - default: false, - description: - 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', - }, - { - displayName: 'Domain Users Only', - name: 'domainUsersOnly', - type: 'boolean', - default: false, - description: - 'Whether access to this shared drive and items inside this shared drive is restricted to users of the domain to which this shared drive belongs. This restriction may be overridden by other sharing policies controlled outside of this shared drive.', - }, - { - displayName: 'Drive Members Only', - name: 'driveMembersOnly', - type: 'boolean', - default: false, - description: - 'Whether access to items inside this shared drive is restricted to its members', - }, - ], - }, - ], - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - displayOptions: { - show: { - operation: ['upload'], - resource: ['file'], - }, - }, - options: [ - { - displayName: 'APP Properties', - name: 'appPropertiesUi', - placeholder: 'Add Property', - type: 'fixedCollection', - default: {}, - typeOptions: { - multipleValues: true, - }, - description: - 'A collection of arbitrary key-value pairs which are private to the requesting app', - options: [ - { - name: 'appPropertyValues', - displayName: 'APP Property', - values: [ - { - displayName: 'Key', - name: 'key', - type: 'string', - default: '', - description: 'Name of the key to add', - }, - { - displayName: 'Value', - name: 'value', - type: 'string', - default: '', - description: 'Value to set for the key', - }, - ], - }, - ], - }, - { - displayName: 'Properties', - name: 'propertiesUi', - placeholder: 'Add Property', - type: 'fixedCollection', - default: {}, - typeOptions: { - multipleValues: true, - }, - description: 'A collection of arbitrary key-value pairs which are visible to all apps', - options: [ - { - name: 'propertyValues', - displayName: 'Property', - values: [ - { - displayName: 'Key', - name: 'key', - type: 'string', - default: '', - description: 'Name of the key to add', - }, - { - displayName: 'Value', - name: 'value', - type: 'string', - default: '', - description: 'Value to set for the key', - }, - ], - }, - ], - }, - ], - }, - ], - }; - - methods = { - listSearch: { - fileSearch, - folderSearch, - driveSearch, - }, - }; - - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - const returnData: INodeExecutionData[] = []; - - const resource = this.getNodeParameter('resource', 0); - const operation = this.getNodeParameter('operation', 0); - - for (let i = 0; i < items.length; i++) { - try { - const options = this.getNodeParameter('options', i, {}); - - let queryFields = 'id, name'; - if (options?.fields) { - const fields = options.fields as string[]; - if (fields.includes('*')) { - queryFields = '*'; - } else { - queryFields = fields.join(', '); - } - } - - if (resource === 'drive') { - if (operation === 'create') { - // ---------------------------------- - // create - // ---------------------------------- - - const name = this.getNodeParameter('name', i) as string; - - const body: IDataObject = { - name, - }; - - Object.assign(body, options); - - const response = await googleApiRequest.call(this, 'POST', '/drive/v3/drives', body, { - requestId: uuid(), - }); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(response as IDataObject[]), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } - if (operation === 'delete') { - // ---------------------------------- - // delete - // ---------------------------------- - - const driveId = this.getNodeParameter('driveId', i, undefined, { - extractValue: true, - }) as string; - - await googleApiRequest.call(this, 'DELETE', `/drive/v3/drives/${driveId}`); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ success: true }), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } - if (operation === 'get') { - // ---------------------------------- - // get - // ---------------------------------- - - const driveId = this.getNodeParameter('driveId', i, undefined, { - extractValue: true, - }) as string; - - const qs: IDataObject = {}; - - Object.assign(qs, options); - - const response = await googleApiRequest.call( - this, - 'GET', - `/drive/v3/drives/${driveId}`, - {}, - qs, - ); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(response as IDataObject[]), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } - if (operation === 'list') { - // ---------------------------------- - // list - // ---------------------------------- - const returnAll = this.getNodeParameter('returnAll', i); - - const qs: IDataObject = {}; - - let response: IDataObject[] = []; - - Object.assign(qs, options); - - if (returnAll) { - response = await googleApiRequestAllItems.call( - this, - 'drives', - 'GET', - '/drive/v3/drives', - {}, - qs, - ); - } else { - qs.pageSize = this.getNodeParameter('limit', i); - const data = await googleApiRequest.call(this, 'GET', '/drive/v3/drives', {}, qs); - response = data.drives as IDataObject[]; - } - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(response), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } - if (operation === 'update') { - // ---------------------------------- - // update - // ---------------------------------- - - const driveId = this.getNodeParameter('driveId', i, undefined, { - extractValue: true, - }) as string; - - const body: IDataObject = {}; - - Object.assign(body, options); - - const response = await googleApiRequest.call( - this, - 'PATCH', - `/drive/v3/drives/${driveId}`, - body, - ); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(response as IDataObject[]), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } - } - if (resource === 'file') { - if (operation === 'copy') { - // ---------------------------------- - // copy - // ---------------------------------- - - const fileId = this.getNodeParameter('fileId', i, undefined, { - extractValue: true, - }) as string; - - const body: IDataObject = { - fields: queryFields, - }; - - const optionProperties = ['name', 'parents']; - for (const propertyName of optionProperties) { - if (options[propertyName] !== undefined) { - body[propertyName] = options[propertyName]; - } - } - - const qs = { - supportsAllDrives: true, - }; - - const response = await googleApiRequest.call( - this, - 'POST', - `/drive/v3/files/${fileId}/copy`, - body, - qs, - ); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(response as IDataObject[]), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } else if (operation === 'download') { - // ---------------------------------- - // download - // ---------------------------------- - - const fileId = this.getNodeParameter('fileId', i, undefined, { - extractValue: true, - }) as string; - const downloadOptions = this.getNodeParameter('options', i); - - const requestOptions = { - useStream: true, - resolveWithFullResponse: true, - encoding: null, - json: false, - }; - - const file = await googleApiRequest.call( - this, - 'GET', - `/drive/v3/files/${fileId}`, - {}, - { fields: 'mimeType,name', supportsTeamDrives: true }, - ); - let response; - - if (file.mimeType.includes('vnd.google-apps')) { - const parameterKey = 'options.googleFileConversion.conversion'; - const type = file.mimeType.split('.')[2]; - let mime; - if (type === 'document') { - mime = this.getNodeParameter( - `${parameterKey}.docsToFormat`, - i, - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - ) as string; - } else if (type === 'presentation') { - mime = this.getNodeParameter( - `${parameterKey}.slidesToFormat`, - i, - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - ) as string; - } else if (type === 'spreadsheet') { - mime = this.getNodeParameter( - `${parameterKey}.sheetsToFormat`, - i, - 'application/x-vnd.oasis.opendocument.spreadsheet', - ) as string; - } else { - mime = this.getNodeParameter( - `${parameterKey}.drawingsToFormat`, - i, - 'image/jpeg', - ) as string; - } - response = await googleApiRequest.call( - this, - 'GET', - `/drive/v3/files/${fileId}/export`, - {}, - { mimeType: mime }, - undefined, - requestOptions, - ); - } else { - response = await googleApiRequest.call( - this, - 'GET', - `/drive/v3/files/${fileId}`, - {}, - { alt: 'media' }, - undefined, - requestOptions, - ); - } - - const mimeType = response.headers['content-type'] ?? file.mimeType ?? undefined; - const fileName = downloadOptions.fileName ?? file.name ?? undefined; - - const newItem: INodeExecutionData = { - json: items[i].json, - binary: {}, - }; - - 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. - // @ts-ignore - Object.assign(newItem.binary, items[i].binary); - } - - items[i] = newItem; - - const dataPropertyNameDownload = this.getNodeParameter('binaryPropertyName', i); - - items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData( - response.body as unknown as Readable, - fileName as string, - mimeType as string, - ); - } else if (operation === 'list') { - // ---------------------------------- - // list - // ---------------------------------- - - let querySpaces = ''; - if (options.spaces) { - const spaces = options.spaces as string[]; - if (spaces.includes('*')) { - querySpaces = 'appDataFolder, drive, photos'; - } else { - querySpaces = spaces.join(', '); - } - } - - let queryCorpora = ''; - if (options.corpora) { - queryCorpora = options.corpora as string; - } - - let driveId: string | undefined; - driveId = options.driveId as string; - if (driveId === '') { - driveId = undefined; - } - - let queryString = ''; - const useQueryString = this.getNodeParameter('useQueryString', i) as boolean; - if (useQueryString) { - // Use the user defined query string - queryString = this.getNodeParameter('queryString', i) as string; - } else { - // Build query string out of parameters set by user - const queryFilters = this.getNodeParameter('queryFilters', i) as IDataObject; - - const queryFilterFields: string[] = []; - if (queryFilters.name) { - (queryFilters.name as IDataObject[]).forEach((nameFilter) => { - let filterOperation = nameFilter.operation; - if (filterOperation === 'is') { - filterOperation = '='; - } else if (filterOperation === 'isNot') { - filterOperation = '!='; - } - queryFilterFields.push(`name ${filterOperation} '${nameFilter.value}'`); - }); - - queryString += queryFilterFields.join(' or '); - } - - queryFilterFields.length = 0; - if (queryFilters.mimeType) { - (queryFilters.mimeType as IDataObject[]).forEach((mimeTypeFilter) => { - let mimeType = mimeTypeFilter.mimeType; - if (mimeTypeFilter.mimeType === 'custom') { - mimeType = mimeTypeFilter.customMimeType; - } - queryFilterFields.push(`mimeType = '${mimeType}'`); - }); - - if (queryFilterFields.length) { - if (queryString !== '') { - queryString += ' and '; - } - - queryString += queryFilterFields.join(' or '); - } - } - } - - const pageSize = this.getNodeParameter('limit', i); - - const qs = { - pageSize, - orderBy: 'modifiedTime', - fields: `nextPageToken, files(${queryFields})`, - spaces: querySpaces, - q: queryString, - includeItemsFromAllDrives: queryCorpora !== '' || driveId !== '', - supportsAllDrives: queryCorpora !== '' || driveId !== '', - }; - - const response = await googleApiRequest.call(this, 'GET', '/drive/v3/files', {}, qs); - - const files = response.files; - - const version = this.getNode().typeVersion; - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(files as IDataObject[]), - { itemData: { item: i } }, - ); - - if (version === 1) { - return [executionData]; - } - - returnData.push(...executionData); - } else if (operation === 'upload') { - // ---------------------------------- - // upload - // ---------------------------------- - const resolveData = this.getNodeParameter('resolveData', 0); - - let contentLength: number; - let fileContent: Buffer | Readable; - let originalFilename: string | undefined; - let mimeType = 'text/plain'; - - if (this.getNodeParameter('binaryData', i)) { - const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i); - const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); - if (binaryData.id) { - // Stream data in 256KB chunks, and upload the via the resumable upload api - fileContent = this.helpers.getBinaryStream(binaryData.id, UPLOAD_CHUNK_SIZE); - const metadata = await this.helpers.getBinaryMetadata(binaryData.id); - contentLength = metadata.fileSize; - originalFilename = metadata.fileName; - if (metadata.mimeType) mimeType = binaryData.mimeType; - } else { - fileContent = Buffer.from(binaryData.data, BINARY_ENCODING); - contentLength = fileContent.length; - originalFilename = binaryData.fileName; - mimeType = binaryData.mimeType; - } - } else { - // Is text file - fileContent = Buffer.from(this.getNodeParameter('fileContent', i) as string, 'utf8'); - contentLength = fileContent.byteLength; - } - - const name = this.getNodeParameter('name', i) as string; - const parents = this.getNodeParameter('parents', i) as string[]; - - let uploadId; - if (Buffer.isBuffer(fileContent)) { - const response = await googleApiRequest.call( - this, - 'POST', - '/upload/drive/v3/files', - fileContent, - { - fields: queryFields, - uploadType: 'media', - }, - undefined, - { - headers: { - 'Content-Type': mimeType, - 'Content-Length': contentLength, - }, - encoding: null, - json: false, - }, - ); - uploadId = JSON.parse(response as string).id; - } else { - const resumableUpload = await googleApiRequest.call( - this, - 'POST', - '/upload/drive/v3/files', - undefined, - { uploadType: 'resumable' }, - undefined, - { - resolveWithFullResponse: true, - }, - ); - const uploadUrl = resumableUpload.headers.location; - - let offset = 0; - for await (const chunk of fileContent) { - const nextOffset = offset + Number(chunk.length); - try { - const response = await this.helpers.httpRequest({ - method: 'PUT', - url: uploadUrl, - headers: { - 'Content-Length': chunk.length, - 'Content-Range': `bytes ${offset}-${nextOffset - 1}/${contentLength}`, - }, - body: chunk, - }); - uploadId = response.id; - } catch (error) { - if (error.response?.status !== 308) throw error; - } - offset = nextOffset; - } - } - - const requestBody = { - mimeType, - name, - originalFilename, - }; - - const properties = this.getNodeParameter( - 'options.propertiesUi.propertyValues', - i, - [], - ) as IDataObject[]; - - if (properties.length) { - Object.assign(requestBody, { - properties: properties.reduce( - (obj, value) => Object.assign(obj, { [`${value.key}`]: value.value }), - {}, - ), - }); - } - - const appProperties = this.getNodeParameter( - 'options.appPropertiesUi.appPropertyValues', - i, - [], - ) as IDataObject[]; - - if (properties.length) { - Object.assign(requestBody, { - appProperties: appProperties.reduce( - (obj, value) => Object.assign(obj, { [`${value.key}`]: value.value }), - {}, - ), - }); - } - - let response = await googleApiRequest.call( - this, - 'PATCH', - `/drive/v3/files/${uploadId}`, - requestBody, - { - addParents: parents.join(','), - // When set to true shared drives can be used. - supportsAllDrives: true, - }, - ); - - if (resolveData) { - response = await googleApiRequest.call( - this, - 'GET', - `/drive/v3/files/${response.id}`, - {}, - { fields: '*' }, - ); - } - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(response as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } else if (operation === 'update') { - // ---------------------------------- - // file:update - // ---------------------------------- - - const id = this.getNodeParameter('fileId', i, undefined, { - extractValue: true, - }) as string; - const updateFields = this.getNodeParameter('updateFields', i, {}); - - const qs: IDataObject = { - supportsAllDrives: true, - }; - - Object.assign(qs, options); - - qs.fields = queryFields; - - const body: IDataObject = {}; - - if (updateFields.fileName) { - body.name = updateFields.fileName; - } - - if (updateFields.hasOwnProperty('trashed')) { - body.trashed = updateFields.trashed; - } - - if (updateFields.parentId && updateFields.parentId !== '') { - qs.addParents = updateFields.parentId; - } - - const responseData = await googleApiRequest.call( - this, - 'PATCH', - `/drive/v3/files/${id}`, - body, - qs, - ); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } - } - if (resource === 'folder') { - if (operation === 'create') { - // ---------------------------------- - // folder:create - // ---------------------------------- - - const name = this.getNodeParameter('name', i) as string; - - const body = { - name, - mimeType: 'application/vnd.google-apps.folder', - parents: options.parents || [], - }; - - const qs = { - fields: queryFields, - supportsAllDrives: true, - }; - - const response = await googleApiRequest.call(this, 'POST', '/drive/v3/files', body, qs); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(response as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } - } - if (['file', 'folder'].includes(resource)) { - if (operation === 'delete') { - // ---------------------------------- - // delete - // ---------------------------------- - - const fileId = this.getNodeParameter('fileId', i, undefined, { - extractValue: true, - }) as string; - - await googleApiRequest.call( - this, - 'DELETE', - `/drive/v3/files/${fileId}`, - {}, - { supportsTeamDrives: true }, - ); - - // If we are still here it did succeed - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ - fileId, - success: true, - }), - { itemData: { item: i } }, - ); - - returnData.push(...executionData); - } - if (operation === 'share') { - const fileId = this.getNodeParameter('fileId', i, undefined, { - extractValue: true, - }) as string; - - const permissions = this.getNodeParameter('permissionsUi', i) as IDataObject; - - const shareOption = this.getNodeParameter('options', i); - - const body: IDataObject = {}; - - const qs: IDataObject = { - supportsTeamDrives: true, - }; - - if (permissions.permissionsValues) { - Object.assign(body, permissions.permissionsValues); - } - - Object.assign(qs, shareOption); - - const response = await googleApiRequest.call( - this, - 'POST', - `/drive/v3/files/${fileId}/permissions`, - body, - qs, - ); - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(response as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } - } - } catch (error) { - if (this.continueOnFail()) { - if (resource === 'file' && operation === 'download') { - items[i].json = { error: error.message }; - } else { - returnData.push({ json: { error: error.message } }); - } - continue; - } - throw error; - } - } - if (resource === 'file' && operation === 'download') { - // For file downloads the files get attached to the existing items - return this.prepareOutputData(items); - } else { - // For all other ones does the output items get replaced - return this.prepareOutputData(returnData); - } +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; + +import { GoogleDriveV1 } from './v1/GoogleDriveV1.node'; +import { GoogleDriveV2 } from './v2/GoogleDriveV2.node'; + +export class GoogleDrive extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Google Drive', + name: 'googleDrive', + icon: 'file:googleDrive.svg', + group: ['input'], + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Access data on Google Drive', + defaultVersion: 3, + }; + + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new GoogleDriveV1(baseDescription), + 2: new GoogleDriveV1(baseDescription), + 3: new GoogleDriveV2(baseDescription), + }; + + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Google/Drive/GoogleDriveTrigger.node.ts b/packages/nodes-base/nodes/Google/Drive/GoogleDriveTrigger.node.ts index 08299f42a2..093b0a5ca9 100644 --- a/packages/nodes-base/nodes/Google/Drive/GoogleDriveTrigger.node.ts +++ b/packages/nodes-base/nodes/Google/Drive/GoogleDriveTrigger.node.ts @@ -9,10 +9,10 @@ import type { } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; -import { extractId, googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; +import { extractId, googleApiRequest, googleApiRequestAllItems } from './v1/GenericFunctions'; import moment from 'moment'; -import { fileSearch, folderSearch } from './SearchFunctions'; +import { fileSearch, folderSearch } from './v1/SearchFunctions'; export class GoogleDriveTrigger implements INodeType { description: INodeTypeDescription = { diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/create.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/create.test.ts new file mode 100644 index 0000000000..a86a7e8265 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/create.test.ts @@ -0,0 +1,78 @@ +import nock from 'nock'; + +import * as create from '../../../../v2/actions/drive/create.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports +import * as uuid from 'uuid'; + +jest.mock('uuid', () => { + const originalModule = jest.requireActual('uuid'); + return { + ...originalModule, + v4: jest.fn(function () { + return '430c0ca1-2498-472c-9d43-da0163839823'; + }), + }; +}); + +describe('test GoogleDriveV2: drive create', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + resource: 'drive', + name: 'newDrive', + options: { + capabilities: { + canComment: true, + canRename: true, + canTrashChildren: true, + }, + colorRgb: '#451AD3', + hidden: false, + restrictions: { + driveMembersOnly: true, + }, + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await create.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'POST', + '/drive/v3/drives', + { + capabilities: { canComment: true, canRename: true, canTrashChildren: true }, + colorRgb: '#451AD3', + hidden: false, + name: 'newDrive', + restrictions: { driveMembersOnly: true }, + }, + { requestId: '430c0ca1-2498-472c-9d43-da0163839823' }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/deleteDrive.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/deleteDrive.test.ts new file mode 100644 index 0000000000..e18249797e --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/deleteDrive.test.ts @@ -0,0 +1,50 @@ +import nock from 'nock'; + +import * as deleteDrive from '../../../../v2/actions/drive/deleteDrive.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: drive deleteDrive', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + resource: 'drive', + operation: 'deleteDrive', + driveId: { + __rl: true, + value: 'driveIDxxxxxx', + mode: 'id', + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await deleteDrive.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'DELETE', + '/drive/v3/drives/driveIDxxxxxx', + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/get.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/get.test.ts new file mode 100644 index 0000000000..d46cb35fb9 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/get.test.ts @@ -0,0 +1,55 @@ +import nock from 'nock'; + +import * as get from '../../../../v2/actions/drive/get.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: drive get', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + resource: 'drive', + operation: 'get', + driveId: { + __rl: true, + value: 'driveIDxxxxxx', + mode: 'id', + }, + options: { + useDomainAdminAccess: true, + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await get.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'GET', + '/drive/v3/drives/driveIDxxxxxx', + {}, + { useDomainAdminAccess: true }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/list.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/list.test.ts new file mode 100644 index 0000000000..8693a00352 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/list.test.ts @@ -0,0 +1,78 @@ +import nock from 'nock'; + +import * as list from '../../../../v2/actions/drive/list.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function (method: string) { + if (method === 'GET') { + return {}; + } + }), + googleApiRequestAllItems: jest.fn(async function (method: string) { + if (method === 'GET') { + return {}; + } + }), + }; +}); + +describe('test GoogleDriveV2: drive list', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with limit', async () => { + const nodeParameters = { + resource: 'drive', + operation: 'list', + limit: 20, + options: {}, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await list.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'GET', + '/drive/v3/drives', + {}, + { pageSize: 20 }, + ); + }); + + it('shuold be called with returnAll true', async () => { + const nodeParameters = { + resource: 'drive', + operation: 'list', + returnAll: true, + options: {}, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await list.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequestAllItems).toBeCalledTimes(1); + expect(transport.googleApiRequestAllItems).toHaveBeenCalledWith( + 'GET', + 'drives', + '/drive/v3/drives', + {}, + {}, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/update.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/update.test.ts new file mode 100644 index 0000000000..534379fd54 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/drive/update.test.ts @@ -0,0 +1,58 @@ +import nock from 'nock'; + +import * as update from '../../../../v2/actions/drive/update.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: drive update', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + resource: 'drive', + operation: 'update', + driveId: { + __rl: true, + value: 'sharedDriveIDxxxxx', + mode: 'id', + }, + options: { + colorRgb: '#F4BEBE', + name: 'newName', + restrictions: { + driveMembersOnly: true, + }, + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await update.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/drive/v3/drives/sharedDriveIDxxxxx', + { colorRgb: '#F4BEBE', name: 'newName', restrictions: { driveMembersOnly: true } }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/copy.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/copy.test.ts new file mode 100644 index 0000000000..56e224b980 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/copy.test.ts @@ -0,0 +1,76 @@ +import nock from 'nock'; + +import * as copy from '../../../../v2/actions/file/copy.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: file copy', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + operation: 'copy', + fileId: { + __rl: true, + value: 'fileIDxxxxxx', + mode: 'list', + cachedResultName: 'test01.png', + cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk', + }, + name: 'copyImage.png', + sameFolder: false, + folderId: { + __rl: true, + value: 'folderIDxxxxxx', + mode: 'list', + cachedResultName: 'testFolder 3', + cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx', + }, + options: { + copyRequiresWriterPermission: true, + description: 'image copy', + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await copy.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toBeCalledWith( + 'POST', + '/drive/v3/files/fileIDxxxxxx/copy', + { + copyRequiresWriterPermission: true, + description: 'image copy', + name: 'copyImage.png', + parents: ['folderIDxxxxxx'], + }, + { + supportsAllDrives: true, + corpora: 'allDrives', + includeItemsFromAllDrives: true, + spaces: 'appDataFolder, drive', + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/createFromText.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/createFromText.test.ts new file mode 100644 index 0000000000..a9feae6642 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/createFromText.test.ts @@ -0,0 +1,91 @@ +import nock from 'nock'; + +import * as createFromText from '../../../../v2/actions/file/createFromText.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: file createFromText', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + operation: 'createFromText', + content: 'hello drive!', + name: 'helloDrive.txt', + folderId: { + __rl: true, + value: 'folderIDxxxxxx', + mode: 'list', + cachedResultName: 'testFolder 3', + cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx', + }, + options: { + appPropertiesUi: { + appPropertyValues: [ + { + key: 'appKey1', + value: 'appValue1', + }, + ], + }, + propertiesUi: { + propertyValues: [ + { + key: 'prop1', + value: 'value1', + }, + { + key: 'prop2', + value: 'value2', + }, + ], + }, + keepRevisionForever: true, + ocrLanguage: 'en', + useContentAsIndexableText: true, + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await createFromText.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'POST', + '/upload/drive/v3/files', + '\n\t\t\n--XXXXXX\t\t\nContent-Type: application/json; charset=UTF-8\t\t\n\n{"name":"helloDrive.txt","parents":["folderIDxxxxxx"],"mimeType":"text/plain","properties":{"prop1":"value1","prop2":"value2"},"appProperties":{"appKey1":"appValue1"}}\t\t\n--XXXXXX\t\t\nContent-Type: text/plain\t\t\nContent-Transfer-Encoding: base64\t\t\n\nhello drive!\t\t\n--XXXXXX--', + { + corpora: 'allDrives', + includeItemsFromAllDrives: true, + keepRevisionForever: true, + ocrLanguage: 'en', + spaces: 'appDataFolder, drive', + supportsAllDrives: true, + uploadType: 'multipart', + useContentAsIndexableText: true, + }, + undefined, + { headers: { 'Content-Length': 12, 'Content-Type': 'multipart/related; boundary=XXXXXX' } }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/deleteFile.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/deleteFile.test.ts new file mode 100644 index 0000000000..44f4d5e3ab --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/deleteFile.test.ts @@ -0,0 +1,56 @@ +import nock from 'nock'; + +import * as deleteFile from '../../../../v2/actions/file/deleteFile.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: file deleteFile', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + operation: 'deleteFile', + fileId: { + __rl: true, + value: 'fileIDxxxxxx', + mode: 'list', + cachedResultName: 'test.txt', + cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk', + }, + options: { + deletePermanently: true, + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await deleteFile.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'DELETE', + '/drive/v3/files/fileIDxxxxxx', + undefined, + { supportsAllDrives: true }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/download.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/download.test.ts new file mode 100644 index 0000000000..33445637eb --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/download.test.ts @@ -0,0 +1,64 @@ +import nock from 'nock'; + +import * as download from '../../../../v2/actions/file/download.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: file download', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + operation: 'deleteFile', + fileId: { + __rl: true, + value: 'fileIDxxxxxx', + mode: 'list', + cachedResultName: 'test.txt', + cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk', + }, + options: { + deletePermanently: true, + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await download.execute.call(fakeExecuteFunction, 0, { json: {} }); + + expect(transport.googleApiRequest).toBeCalledTimes(2); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'GET', + '/drive/v3/files/fileIDxxxxxx', + {}, + { fields: 'mimeType,name', supportsTeamDrives: true }, + ); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'GET', + '/drive/v3/files/fileIDxxxxxx', + {}, + { alt: 'media' }, + undefined, + { encoding: null, json: false, resolveWithFullResponse: true, useStream: true }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/move.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/move.test.ts new file mode 100644 index 0000000000..11aeb32adc --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/move.test.ts @@ -0,0 +1,84 @@ +import nock from 'nock'; + +import * as move from '../../../../v2/actions/file/move.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function (method: string) { + if (method === 'GET') { + return { + parents: ['parentFolderIDxxxxxx'], + }; + } + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: file move', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + operation: 'move', + fileId: { + __rl: true, + value: 'fileIDxxxxxx', + mode: 'list', + cachedResultName: 'test.txt', + cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk', + }, + folderId: { + __rl: true, + value: 'folderIDxxxxxx', + mode: 'list', + cachedResultName: 'testFolder1', + cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx', + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await move.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(2); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'GET', + '/drive/v3/files/fileIDxxxxxx', + undefined, + { + corpora: 'allDrives', + fields: 'parents', + includeItemsFromAllDrives: true, + spaces: 'appDataFolder, drive', + supportsAllDrives: true, + }, + ); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/drive/v3/files/fileIDxxxxxx', + undefined, + { + addParents: 'folderIDxxxxxx', + removeParents: 'parentFolderIDxxxxxx', + corpora: 'allDrives', + includeItemsFromAllDrives: true, + spaces: 'appDataFolder, drive', + supportsAllDrives: true, + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/share.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/share.test.ts new file mode 100644 index 0000000000..e69ab0adf0 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/share.test.ts @@ -0,0 +1,74 @@ +import nock from 'nock'; + +import * as share from '../../../../v2/actions/file/share.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: file share', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + operation: 'share', + fileId: { + __rl: true, + value: 'fileIDxxxxxx', + mode: 'list', + cachedResultName: 'test.txt', + cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk', + }, + permissionsUi: { + permissionsValues: { + role: 'owner', + type: 'user', + emailAddress: 'user@gmail.com', + }, + }, + options: { + emailMessage: 'some message', + moveToNewOwnersRoot: true, + sendNotificationEmail: true, + transferOwnership: true, + useDomainAdminAccess: true, + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await share.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'POST', + '/drive/v3/files/fileIDxxxxxx/permissions', + { emailAddress: 'user@gmail.com', role: 'owner', type: 'user' }, + { + emailMessage: 'some message', + moveToNewOwnersRoot: true, + sendNotificationEmail: true, + supportsAllDrives: true, + transferOwnership: true, + useDomainAdminAccess: true, + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/update.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/update.test.ts new file mode 100644 index 0000000000..f21a02f02e --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/update.test.ts @@ -0,0 +1,66 @@ +import nock from 'nock'; + +import * as update from '../../../../v2/actions/file/update.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: file update', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + operation: 'update', + fileId: { + __rl: true, + value: 'fileIDxxxxxx', + mode: 'list', + cachedResultName: 'test.txt', + cachedResultUrl: 'https://drive.google.com/file/d/fileIDxxxxxx/view?usp=drivesdk', + }, + newUpdatedFileName: 'test2.txt', + options: { + keepRevisionForever: true, + ocrLanguage: 'en', + useContentAsIndexableText: true, + fields: ['hasThumbnail', 'starred'], + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await update.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/drive/v3/files/fileIDxxxxxx', + { name: 'test2.txt' }, + { + fields: 'hasThumbnail, starred', + keepRevisionForever: true, + ocrLanguage: 'en', + supportsAllDrives: true, + useContentAsIndexableText: true, + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/upload.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/upload.test.ts new file mode 100644 index 0000000000..fdd6e30f68 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/upload.test.ts @@ -0,0 +1,96 @@ +import nock from 'nock'; + +import * as upload from '../../../../v2/actions/file/upload.operation'; + +import * as transport from '../../../../v2/transport'; +import * as utils from '../../../../v2/helpers/utils'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function (method: string) { + if (method === 'POST') { + return { + headers: { location: 'someLocation' }, + }; + } + return {}; + }), + }; +}); + +jest.mock('../../../../v2/helpers/utils', () => { + const originalModule = jest.requireActual('../../../../v2/helpers/utils'); + return { + ...originalModule, + getItemBinaryData: jest.fn(async function () { + return { + contentLength: '123', + fileContent: 'Hello Drive!', + originalFilename: 'original.txt', + mimeType: 'text/plain', + }; + }), + }; +}); + +describe('test GoogleDriveV2: file upload', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + jest.unmock('../../../../v2/helpers/utils'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + name: 'newFile.txt', + folderId: { + __rl: true, + value: 'folderIDxxxxxx', + mode: 'list', + cachedResultName: 'testFolder 3', + cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx', + }, + options: { + simplifyOutput: true, + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await upload.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(2); + + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'POST', + '/upload/drive/v3/files', + undefined, + { uploadType: 'resumable' }, + undefined, + { resolveWithFullResponse: true }, + ); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/drive/v3/files/undefined', + { mimeType: 'text/plain', name: 'newFile.txt', originalFilename: 'original.txt' }, + { + addParents: 'folderIDxxxxxx', + supportsAllDrives: true, + corpora: 'allDrives', + includeItemsFromAllDrives: true, + spaces: 'appDataFolder, drive', + }, + ); + + expect(utils.getItemBinaryData).toBeCalledTimes(1); + expect(utils.getItemBinaryData).toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/fileFolder/search.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/fileFolder/search.test.ts new file mode 100644 index 0000000000..3f10deeac3 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/fileFolder/search.test.ts @@ -0,0 +1,119 @@ +import nock from 'nock'; + +import * as search from '../../../../v2/actions/fileFolder/search.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function (method: string) { + if (method === 'GET') { + return {}; + } + }), + googleApiRequestAllItems: jest.fn(async function (method: string) { + if (method === 'GET') { + return {}; + } + }), + }; +}); + +describe('test GoogleDriveV2: fileFolder search', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('returnAll = false', async () => { + const nodeParameters = { + searchMethod: 'name', + resource: 'fileFolder', + queryString: 'test', + returnAll: false, + limit: 2, + filter: { + whatToSearch: 'files', + fileTypes: ['application/vnd.google-apps.document'], + }, + options: { + fields: ['id', 'name', 'starred', 'version'], + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await search.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toBeCalledWith('GET', '/drive/v3/files', undefined, { + corpora: 'allDrives', + fields: 'nextPageToken, files(id, name, starred, version)', + includeItemsFromAllDrives: true, + pageSize: 2, + q: "name contains 'test' and mimeType != 'application/vnd.google-apps.folder' and trashed = false and (mimeType = 'application/vnd.google-apps.document')", + spaces: 'appDataFolder, drive', + supportsAllDrives: true, + }); + }); + + it('returnAll = true', async () => { + const nodeParameters = { + resource: 'fileFolder', + searchMethod: 'query', + queryString: 'test', + returnAll: true, + filter: { + driveId: { + __rl: true, + value: 'driveID000000123', + mode: 'list', + cachedResultName: 'sharedDrive', + cachedResultUrl: 'https://drive.google.com/drive/folders/driveID000000123', + }, + folderId: { + __rl: true, + value: 'folderID000000123', + mode: 'list', + cachedResultName: 'testFolder 3', + cachedResultUrl: 'https://drive.google.com/drive/folders/folderID000000123', + }, + whatToSearch: 'all', + fileTypes: ['*'], + includeTrashed: true, + }, + options: { + fields: ['permissions', 'mimeType'], + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await search.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequestAllItems).toBeCalledTimes(1); + expect(transport.googleApiRequestAllItems).toBeCalledWith( + 'GET', + 'files', + '/drive/v3/files', + {}, + { + corpora: 'drive', + driveId: 'driveID000000123', + fields: 'nextPageToken, files(permissions, mimeType)', + includeItemsFromAllDrives: true, + q: "test and 'folderID000000123' in parents", + spaces: 'appDataFolder, drive', + supportsAllDrives: true, + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/create.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/create.test.ts new file mode 100644 index 0000000000..37858c78dc --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/create.test.ts @@ -0,0 +1,68 @@ +import nock from 'nock'; + +import * as create from '../../../../v2/actions/folder/create.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: folder create', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + resource: 'folder', + name: 'testFolder 2', + folderId: { + __rl: true, + value: 'root', + mode: 'list', + cachedResultName: 'root', + cachedResultUrl: 'https://drive.google.com/drive', + }, + options: { + folderColorRgb: '#167D08', + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await create.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'POST', + '/drive/v3/files', + { + folderColorRgb: '#167D08', + mimeType: 'application/vnd.google-apps.folder', + name: 'testFolder 2', + parents: ['root'], + }, + { + fields: undefined, + includeItemsFromAllDrives: true, + supportsAllDrives: true, + spaces: 'appDataFolder, drive', + corpora: 'allDrives', + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/deleteFolder.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/deleteFolder.test.ts new file mode 100644 index 0000000000..b141d206a1 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/deleteFolder.test.ts @@ -0,0 +1,80 @@ +import nock from 'nock'; + +import * as deleteFolder from '../../../../v2/actions/folder/deleteFolder.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: folder deleteFolder', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with PATCH', async () => { + const nodeParameters = { + resource: 'folder', + operation: 'deleteFolder', + folderNoRootId: { + __rl: true, + value: 'folderIDxxxxxx', + mode: 'list', + cachedResultName: 'testFolder 2', + cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx', + }, + options: {}, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await deleteFolder.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/drive/v3/files/folderIDxxxxxx', + { trashed: true }, + { supportsAllDrives: true }, + ); + }); + + it('shuold be called with DELETE', async () => { + const nodeParameters = { + resource: 'folder', + operation: 'deleteFolder', + folderNoRootId: { + __rl: true, + value: 'folderIDxxxxxx', + mode: 'list', + cachedResultName: 'testFolder 2', + cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx', + }, + options: { deletePermanently: true }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await deleteFolder.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'DELETE', + '/drive/v3/files/folderIDxxxxxx', + undefined, + { supportsAllDrives: true }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/share.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/share.test.ts new file mode 100644 index 0000000000..d5854e38f5 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/folder/share.test.ts @@ -0,0 +1,64 @@ +import nock from 'nock'; + +import * as share from '../../../../v2/actions/folder/share.operation'; + +import * as transport from '../../../../v2/transport'; + +import { createMockExecuteFunction, driveNode } from '../helpers'; + +jest.mock('../../../../v2/transport', () => { + const originalModule = jest.requireActual('../../../../v2/transport'); + return { + ...originalModule, + googleApiRequest: jest.fn(async function () { + return {}; + }), + }; +}); + +describe('test GoogleDriveV2: folder share', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../../v2/transport'); + }); + + it('shuold be called with', async () => { + const nodeParameters = { + resource: 'folder', + operation: 'share', + folderNoRootId: { + __rl: true, + value: 'folderIDxxxxxx', + mode: 'list', + cachedResultName: 'testFolder 2', + cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx', + }, + permissionsUi: { + permissionsValues: { + role: 'reader', + type: 'anyone', + allowFileDiscovery: true, + }, + }, + options: { + moveToNewOwnersRoot: true, + }, + }; + + const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode); + + await share.execute.call(fakeExecuteFunction, 0); + + expect(transport.googleApiRequest).toBeCalledTimes(1); + expect(transport.googleApiRequest).toHaveBeenCalledWith( + 'POST', + '/drive/v3/files/folderIDxxxxxx/permissions', + { allowFileDiscovery: true, role: 'reader', type: 'anyone' }, + { moveToNewOwnersRoot: true, supportsAllDrives: true }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/helpers.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/helpers.ts new file mode 100644 index 0000000000..b04a33e2e2 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/helpers.ts @@ -0,0 +1,42 @@ +import type { IDataObject, IExecuteFunctions, IGetNodeParameterOptions, INode } from 'n8n-workflow'; + +import { get } from 'lodash'; +import { constructExecutionMetaData, returnJsonArray } from 'n8n-core'; + +export const driveNode: INode = { + id: '11', + name: 'Google Drive node', + typeVersion: 3, + type: 'n8n-nodes-base.googleDrive', + position: [42, 42], + parameters: {}, +}; + +export const createMockExecuteFunction = ( + nodeParameters: IDataObject, + node: INode, + continueOnFail = false, +) => { + const fakeExecuteFunction = { + getNodeParameter( + parameterName: string, + _itemIndex: number, + fallbackValue?: IDataObject | undefined, + options?: IGetNodeParameterOptions | undefined, + ) { + const parameter = options?.extractValue ? `${parameterName}.value` : parameterName; + return get(nodeParameters, parameter, fallbackValue); + }, + getNode() { + return node; + }, + helpers: { + constructExecutionMetaData, + returnJsonArray, + prepareBinaryData: () => {}, + httpRequest: () => {}, + }, + continueOnFail: () => continueOnFail, + } as unknown as IExecuteFunctions; + return fakeExecuteFunction; +}; diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/utils.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/utils.test.ts new file mode 100644 index 0000000000..7af5aa9abc --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/test/v2/utils.test.ts @@ -0,0 +1,125 @@ +import { + prepareQueryString, + setFileProperties, + setUpdateCommonParams, +} from '../../v2/helpers/utils'; + +describe('test GoogleDriveV2, prepareQueryString', () => { + it('should return id, name', () => { + const fields = undefined; + + const result = prepareQueryString(fields); + + expect(result).toEqual('id, name'); + }); + + it('should return *', () => { + const fields = ['*']; + + const result = prepareQueryString(fields); + + expect(result).toEqual('*'); + }); + + it('should return string joined by ,', () => { + const fields = ['id', 'name', 'mimeType']; + + const result = prepareQueryString(fields); + + expect(result).toEqual('id, name, mimeType'); + }); +}); + +describe('test GoogleDriveV2, setFileProperties', () => { + it('should return empty object', () => { + const body = {}; + const options = {}; + + const result = setFileProperties(body, options); + + expect(result).toEqual({}); + }); + + it('should return object with properties', () => { + const body = {}; + const options = { + propertiesUi: { + propertyValues: [ + { + key: 'propertyKey1', + value: 'propertyValue1', + }, + { + key: 'propertyKey2', + value: 'propertyValue2', + }, + ], + }, + }; + + const result = setFileProperties(body, options); + + expect(result).toEqual({ + properties: { + propertyKey1: 'propertyValue1', + propertyKey2: 'propertyValue2', + }, + }); + }); + + it('should return object with appProperties', () => { + const body = {}; + const options = { + appPropertiesUi: { + appPropertyValues: [ + { + key: 'appPropertyKey1', + value: 'appPropertyValue1', + }, + { + key: 'appPropertyKey2', + value: 'appPropertyValue2', + }, + ], + }, + }; + + const result = setFileProperties(body, options); + + expect(result).toEqual({ + appProperties: { + appPropertyKey1: 'appPropertyValue1', + appPropertyKey2: 'appPropertyValue2', + }, + }); + }); +}); + +describe('test GoogleDriveV2, setUpdateCommonParams', () => { + it('should return empty object', () => { + const qs = {}; + const options = {}; + + const result = setUpdateCommonParams(qs, options); + + expect(result).toEqual({}); + }); + + it('should return qs with params', () => { + const options = { + useContentAsIndexableText: true, + keepRevisionForever: true, + ocrLanguage: 'en', + trashed: true, + includePermissionsForView: 'published', + }; + + const qs = setUpdateCommonParams({}, options); + + expect(qs).toEqual({ + useContentAsIndexableText: true, + keepRevisionForever: true, + ocrLanguage: 'en', + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Drive/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Drive/v1/GenericFunctions.ts similarity index 97% rename from packages/nodes-base/nodes/Google/Drive/GenericFunctions.ts rename to packages/nodes-base/nodes/Google/Drive/v1/GenericFunctions.ts index e9c213f486..b3600fca2f 100644 --- a/packages/nodes-base/nodes/Google/Drive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Drive/v1/GenericFunctions.ts @@ -10,7 +10,7 @@ import type { } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; -import { getGoogleAccessToken } from '../GenericFunctions'; +import { getGoogleAccessToken } from '../../GenericFunctions'; export async function googleApiRequest( this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IPollFunctions, diff --git a/packages/nodes-base/nodes/Google/Drive/v1/GoogleDriveV1.node.ts b/packages/nodes-base/nodes/Google/Drive/v1/GoogleDriveV1.node.ts new file mode 100644 index 0000000000..08c254daa2 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v1/GoogleDriveV1.node.ts @@ -0,0 +1,2750 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { + IBinaryKeyData, + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; +import { BINARY_ENCODING } from 'n8n-workflow'; + +import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; + +import { v4 as uuid } from 'uuid'; +import type { Readable } from 'stream'; +import { driveSearch, fileSearch, folderSearch } from './SearchFunctions'; + +import { oldVersionNotice } from '@utils/descriptions'; + +const UPLOAD_CHUNK_SIZE = 256 * 1024; + +const versionDescription: INodeTypeDescription = { + displayName: 'Google Drive', + name: 'googleDrive', + icon: 'file:googleDrive.svg', + group: ['input'], + version: [1, 2], + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Access data on Google Drive', + defaults: { + name: 'Google Drive', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleApi', + required: true, + displayOptions: { + show: { + authentication: ['serviceAccount'], + }, + }, + }, + { + name: 'googleDriveOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + ], + properties: [ + oldVersionNotice, + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Service Account', + value: 'serviceAccount', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'serviceAccount', + displayOptions: { + show: { + '@version': [1], + }, + }, + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'OAuth2 (recommended)', + value: 'oAuth2', + }, + { + name: 'Service Account', + value: 'serviceAccount', + }, + ], + default: 'oAuth2', + displayOptions: { + show: { + '@version': [2], + }, + }, + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Drive', + value: 'drive', + }, + { + name: 'File', + value: 'file', + }, + { + name: 'Folder', + value: 'folder', + }, + ], + default: 'file', + }, + + // ---------------------------------- + // operations + // ---------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['file'], + }, + }, + options: [ + { + name: 'Copy', + value: 'copy', + description: 'Copy a file', + action: 'Copy a file', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a file', + action: 'Delete a file', + }, + { + name: 'Download', + value: 'download', + description: 'Download a file', + action: 'Download a file', + }, + { + name: 'List', + value: 'list', + description: 'List files and folders', + action: 'List a file', + }, + { + name: 'Share', + value: 'share', + description: 'Share a file', + action: 'Share a file', + }, + { + name: 'Update', + value: 'update', + description: 'Update a file', + action: 'Update a file', + }, + { + name: 'Upload', + value: 'upload', + description: 'Upload a file', + action: 'Upload a file', + }, + ], + default: 'upload', + }, + + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['folder'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a folder', + action: 'Create a folder', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a folder', + action: 'Delete a folder', + }, + { + name: 'Share', + value: 'share', + description: 'Share a folder', + action: 'Share a folder', + }, + ], + default: 'create', + }, + + // ---------------------------------- + // file + // ---------------------------------- + + { + displayName: 'File', + name: 'fileId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'File', + name: 'list', + type: 'list', + placeholder: 'Select a file...', + typeOptions: { + searchListMethod: 'fileSearch', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: + 'https://drive.google.com/file/d/1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A/edit', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/(?:drive|docs)\\.google\\.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/(?:drive|docs)\\.google.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive File URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: '1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive File ID', + }, + }, + ], + url: '=https://drive.google.com/file/d/{{$value}}/view', + }, + ], + displayOptions: { + show: { + operation: ['download', 'copy', 'update', 'delete', 'share'], + resource: ['file'], + }, + }, + description: 'The ID of the file', + }, + + { + displayName: 'Folder', + name: 'fileId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'Folder', + name: 'list', + type: 'list', + placeholder: 'Select a folder...', + typeOptions: { + searchListMethod: 'folderSearch', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: 'https://drive.google.com/drive/folders/1Tx9WHbA3wBpPB4C_HcoZDH9WZFWYxAMU', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive Folder URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: '1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive Folder ID', + }, + }, + ], + url: '=https://drive.google.com/drive/folders/{{$value}}', + }, + ], + displayOptions: { + show: { + operation: ['delete', 'share'], + resource: ['folder'], + }, + }, + description: 'The ID of the folder', + }, + + // ---------------------------------- + // file:copy + // ---------------------------------- + + // ---------------------------------- + // file/folder:delete + // ---------------------------------- + + // ---------------------------------- + // file:download + // ---------------------------------- + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + required: true, + default: 'data', + displayOptions: { + show: { + operation: ['download'], + resource: ['file'], + }, + }, + description: 'Name of the binary property to which to write the data of the read file', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['download'], + resource: ['file'], + }, + }, + options: [ + { + displayName: 'Google File Conversion', + name: 'googleFileConversion', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + placeholder: 'Add Conversion', + options: [ + { + displayName: 'Conversion', + name: 'conversion', + values: [ + { + displayName: 'Google Docs', + name: 'docsToFormat', + type: 'options', + options: [ + { + name: 'To HTML', + value: 'text/html', + }, + { + name: 'To MS Word', + value: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + { + name: 'To OpenOffice Doc', + value: 'application/vnd.oasis.opendocument.text', + }, + { + name: 'To PDF', + value: 'application/pdf', + }, + { + name: 'To Rich Text', + value: 'application/rtf', + }, + ], + default: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + description: 'Format used to export when downloading Google Docs files', + }, + { + displayName: 'Google Drawings', + name: 'drawingsToFormat', + type: 'options', + options: [ + { + name: 'To JPEG', + value: 'image/jpeg', + }, + { + name: 'To PNG', + value: 'image/png', + }, + { + name: 'To SVG', + value: 'image/svg+xml', + }, + { + name: 'To PDF', + value: 'application/pdf', + }, + ], + default: 'image/jpeg', + description: 'Format used to export when downloading Google Drawings files', + }, + { + displayName: 'Google Slides', + name: 'slidesToFormat', + type: 'options', + options: [ + { + name: 'To MS PowerPoint', + value: + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + }, + { + name: 'To PDF', + value: 'application/pdf', + }, + { + name: 'To OpenOffice Presentation', + value: 'application/vnd.oasis.opendocument.presentation', + }, + { + name: 'To Plain Text', + value: 'text/plain', + }, + ], + default: + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + description: 'Format used to export when downloading Google Slides files', + }, + { + displayName: 'Google Sheets', + name: 'sheetsToFormat', + type: 'options', + options: [ + { + name: 'To MS Excel', + value: 'application/x-vnd.oasis.opendocument.spreadsheet', + }, + { + name: 'To PDF', + value: 'application/pdf', + }, + { + name: 'To CSV', + value: 'text/csv', + }, + ], + default: 'application/x-vnd.oasis.opendocument.spreadsheet', + description: 'Format used to export when downloading Google Spreadsheets files', + }, + ], + }, + ], + }, + { + displayName: 'File Name', + name: 'fileName', + type: 'string', + default: '', + description: 'File name. Ex: data.pdf.', + }, + ], + }, + + // ---------------------------------- + // file:list + // ---------------------------------- + { + displayName: 'Use Query String', + name: 'useQueryString', + type: 'boolean', + default: false, + displayOptions: { + show: { + operation: ['list'], + resource: ['file'], + }, + }, + description: 'Whether a query string should be used to filter results', + }, + { + displayName: 'Query String', + name: 'queryString', + type: 'string', + default: '', + displayOptions: { + show: { + operation: ['list'], + useQueryString: [true], + resource: ['file'], + }, + }, + placeholder: "name contains 'invoice'", + description: 'Query to use to return only specific files', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: ['list'], + resource: ['file'], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + default: 50, + description: 'Max number of results to return', + }, + { + displayName: 'Filters', + name: 'queryFilters', + placeholder: 'Add Filter', + description: 'Filters to use to return only specific files', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + displayOptions: { + show: { + operation: ['list'], + useQueryString: [false], + resource: ['file'], + }, + }, + options: [ + { + name: 'name', + displayName: 'Name', + values: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Contains', + value: 'contains', + }, + { + name: 'Is', + value: 'is', + }, + { + name: 'Is Not', + value: 'isNot', + }, + ], + default: 'contains', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'The value for operation', + }, + ], + }, + { + name: 'mimeType', + displayName: 'Mime Type', + values: [ + { + displayName: 'Mime Type', + name: 'mimeType', + type: 'options', + options: [ + { + name: '3rd Party Shortcut', + value: 'application/vnd.google-apps.drive-sdk', + }, + { + name: 'Audio', + value: 'application/vnd.google-apps.audio', + }, + { + name: 'Custom Mime Type', + value: 'custom', + }, + { + name: 'Google Apps Scripts', + value: 'application/vnd.google-apps.script', + }, + { + name: 'Google Docs', + value: 'application/vnd.google-apps.document', + }, + { + name: 'Google Drawing', + value: 'application/vnd.google-apps.drawing', + }, + { + name: 'Google Drive File', + value: 'application/vnd.google-apps.file', + }, + { + name: 'Google Drive Folder', + value: 'application/vnd.google-apps.folder', + }, + { + name: 'Google Forms', + value: 'application/vnd.google-apps.form', + }, + { + name: 'Google Fusion Tables', + value: 'application/vnd.google-apps.fusiontable', + }, + { + name: 'Google My Maps', + value: 'application/vnd.google-apps.map', + }, + { + name: 'Google Sheets', + value: 'application/vnd.google-apps.spreadsheet', + }, + { + name: 'Google Sites', + value: 'application/vnd.google-apps.site', + }, + { + name: 'Google Slides', + value: 'application/vnd.google-apps.presentation', + }, + { + name: 'Photo', + value: 'application/vnd.google-apps.photo', + }, + { + name: 'Unknown', + value: 'application/vnd.google-apps.unknown', + }, + { + name: 'Video', + value: 'application/vnd.google-apps.video', + }, + ], + default: 'application/vnd.google-apps.file', + description: 'The Mime-Type of the files to return', + }, + { + displayName: 'Custom Mime Type', + name: 'customMimeType', + type: 'string', + default: '', + displayOptions: { + show: { + mimeType: ['custom'], + }, + }, + }, + ], + }, + ], + }, + + // ---------------------------------- + // file:share + // ---------------------------------- + { + displayName: 'Permissions', + name: 'permissionsUi', + placeholder: 'Add Permission', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: false, + }, + displayOptions: { + show: { + resource: ['file', 'folder'], + operation: ['share'], + }, + }, + options: [ + { + displayName: 'Permission', + name: 'permissionsValues', + values: [ + { + displayName: 'Role', + name: 'role', + type: 'options', + options: [ + { + name: 'Commenter', + value: 'commenter', + }, + { + name: 'File Organizer', + value: 'fileOrganizer', + }, + { + name: 'Organizer', + value: 'organizer', + }, + { + name: 'Owner', + value: 'owner', + }, + { + name: 'Reader', + value: 'reader', + }, + { + name: 'Writer', + value: 'writer', + }, + ], + default: '', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'User', + value: 'user', + }, + { + name: 'Group', + value: 'group', + }, + { + name: 'Domain', + value: 'domain', + }, + { + name: 'Anyone', + value: 'anyone', + }, + ], + default: '', + description: + 'Information about the different types can be found here', + }, + { + displayName: 'Email Address', + name: 'emailAddress', + type: 'string', + displayOptions: { + show: { + type: ['user', 'group'], + }, + }, + default: '', + description: 'The email address of the user or group to which this permission refers', + }, + { + displayName: 'Domain', + name: 'domain', + type: 'string', + displayOptions: { + show: { + type: ['domain'], + }, + }, + default: '', + description: 'The domain to which this permission refers', + }, + { + displayName: 'Allow File Discovery', + name: 'allowFileDiscovery', + type: 'boolean', + displayOptions: { + show: { + type: ['domain', 'anyone'], + }, + }, + default: false, + description: 'Whether the permission allows the file to be discovered through search', + }, + ], + }, + ], + }, + + { + displayName: 'Binary Data', + name: 'binaryData', + type: 'boolean', + default: false, + displayOptions: { + show: { + operation: ['upload'], + resource: ['file'], + }, + }, + description: 'Whether the data to upload should be taken from binary field', + }, + { + displayName: 'File Content', + name: 'fileContent', + type: 'string', + default: '', + displayOptions: { + show: { + operation: ['upload'], + resource: ['file'], + binaryData: [false], + }, + }, + placeholder: '', + description: 'The text content of the file to upload', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + operation: ['upload'], + resource: ['file'], + binaryData: [true], + }, + }, + placeholder: '', + description: + 'Name of the binary property which contains the data for the file to be uploaded', + }, + + // ---------------------------------- + // file:update + // ---------------------------------- + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['update'], + resource: ['file'], + }, + }, + options: [ + { + displayName: 'File Name', + name: 'fileName', + type: 'string', + default: '', + description: 'The name of the file', + }, + { + displayName: 'Keep Revision Forever', + name: 'keepRevisionForever', + type: 'boolean', + default: false, + description: + "Whether to set the 'keepForever' field in the new head revision. This is only applicable to files with binary content in Google Drive. Only 200 revisions for the file can be kept forever. If the limit is reached, try deleting pinned revisions.", + }, + { + displayName: 'Move to Trash', + name: 'trashed', + type: 'boolean', + default: false, + description: 'Whether to move a file to the trash. Only the owner may trash a file.', + }, + { + displayName: 'OCR Language', + name: 'ocrLanguage', + type: 'string', + default: '', + description: 'A language hint for OCR processing during image import (ISO 639-1 code)', + }, + { + displayName: 'Parent ID', + name: 'parentId', + type: 'string', + default: '', + description: 'The ID of the parent to set', + }, + { + displayName: 'Use Content As Indexable Text', + name: 'useContentAsIndexableText', + type: 'boolean', + default: false, + description: 'Whether to use the uploaded content as indexable text', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['update'], + resource: ['file'], + }, + }, + options: [ + { + displayName: 'Fields', + name: 'fields', + type: 'multiOptions', + options: [ + { + name: '[All]', + value: '*', + description: 'All fields', + }, + { + name: 'explicitlyTrashed', + value: 'explicitlyTrashed', + }, + { + name: 'exportLinks', + value: 'exportLinks', + }, + { + name: 'hasThumbnail', + value: 'hasThumbnail', + }, + { + name: 'iconLink', + value: 'iconLink', + }, + { + name: 'ID', + value: 'id', + }, + { + name: 'Kind', + value: 'kind', + }, + { + name: 'mimeType', + value: 'mimeType', + }, + { + name: 'Name', + value: 'name', + }, + { + name: 'Permissions', + value: 'permissions', + }, + { + name: 'Shared', + value: 'shared', + }, + { + name: 'Spaces', + value: 'spaces', + }, + { + name: 'Starred', + value: 'starred', + }, + { + name: 'thumbnailLink', + value: 'thumbnailLink', + }, + { + name: 'Trashed', + value: 'trashed', + }, + { + name: 'Version', + value: 'version', + }, + { + name: 'webViewLink', + value: 'webViewLink', + }, + ], + default: [], + description: 'The fields to return', + }, + ], + }, + // ---------------------------------- + // file:upload + // ---------------------------------- + { + displayName: 'File Name', + name: 'name', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: ['upload'], + resource: ['file'], + }, + }, + placeholder: 'invoice_1.pdf', + description: 'The name the file should be saved as', + }, + // ---------------------------------- + { + displayName: 'Resolve Data', + name: 'resolveData', + type: 'boolean', + default: false, + displayOptions: { + show: { + operation: ['upload'], + resource: ['file'], + }, + }, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: + 'By default the response only contain the ID of the file. If this option gets activated, it will resolve the data automatically.', + }, + { + displayName: 'Parents', + name: 'parents', + type: 'string', + typeOptions: { + multipleValues: true, + }, + default: [], + displayOptions: { + show: { + operation: ['upload'], + resource: ['file'], + }, + }, + description: 'The IDs of the parent folders which contain the file', + }, + + // ---------------------------------- + // folder + // ---------------------------------- + + // ---------------------------------- + // folder:create + // ---------------------------------- + { + displayName: 'Folder', + name: 'name', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: ['create'], + resource: ['folder'], + }, + }, + placeholder: 'invoices', + description: 'The name of folder to create', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + '/operation': ['copy', 'list', 'share', 'create'], + '/resource': ['file', 'folder'], + }, + }, + options: [ + { + displayName: 'Email Message', + name: 'emailMessage', + type: 'string', + displayOptions: { + show: { + '/operation': ['share'], + '/resource': ['file', 'folder'], + }, + }, + default: '', + description: 'A plain text custom message to include in the notification email', + }, + { + displayName: 'Enforce Single Parent', + name: 'enforceSingleParent', + type: 'boolean', + displayOptions: { + show: { + '/operation': ['share'], + '/resource': ['file', 'folder'], + }, + }, + default: false, + description: + 'Whether to opt in to API behavior that aims for all items to have exactly one parent. This parameter only takes effect if the item is not in a shared drive.', + }, + { + displayName: 'Fields', + name: 'fields', + type: 'multiOptions', + displayOptions: { + show: { + '/operation': ['list', 'copy'], + }, + }, + options: [ + { + name: '*', + value: '*', + description: 'All fields', + }, + { + name: 'explicitlyTrashed', + value: 'explicitlyTrashed', + }, + { + name: 'exportLinks', + value: 'exportLinks', + }, + { + name: 'hasThumbnail', + value: 'hasThumbnail', + }, + { + name: 'iconLink', + value: 'iconLink', + }, + { + name: 'ID', + value: 'id', + }, + { + name: 'Kind', + value: 'kind', + }, + { + name: 'mimeType', + value: 'mimeType', + }, + { + name: 'Name', + value: 'name', + }, + { + name: 'Permissions', + value: 'permissions', + }, + { + name: 'Shared', + value: 'shared', + }, + { + name: 'Spaces', + value: 'spaces', + }, + { + name: 'Starred', + value: 'starred', + }, + { + name: 'thumbnailLink', + value: 'thumbnailLink', + }, + { + name: 'Trashed', + value: 'trashed', + }, + { + name: 'Version', + value: 'version', + }, + { + name: 'webViewLink', + value: 'webViewLink', + }, + ], + default: [], + description: 'The fields to return', + }, + { + displayName: 'Move To New Owners Root', + name: 'moveToNewOwnersRoot', + type: 'boolean', + displayOptions: { + show: { + '/operation': ['share'], + '/resource': ['file', 'folder'], + }, + }, + default: false, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: + "

This parameter only takes effect if the item is not in a shared drive and the request is attempting to transfer the ownership of the item.

When set to true, the item is moved to the new owner's My Drive root folder and all prior parents removed.

", + }, + { + displayName: 'Send Notification Email', + name: 'sendNotificationEmail', + type: 'boolean', + displayOptions: { + show: { + '/operation': ['share'], + '/resource': ['file', 'folder'], + }, + }, + default: false, + description: 'Whether to send a notification email when sharing to users or groups', + }, + { + displayName: 'Supports All Drives', + name: 'supportsAllDrives', + type: 'boolean', + displayOptions: { + show: { + '/operation': ['share'], + '/resource': ['file', 'folder'], + }, + }, + default: false, + description: + 'Whether the requesting application supports both My Drives and shared drives', + }, + { + displayName: 'Transfer Ownership', + name: 'transferOwnership', + type: 'boolean', + displayOptions: { + show: { + '/operation': ['share'], + '/resource': ['file', 'folder'], + }, + }, + default: false, + description: + 'Whether to transfer ownership to the specified user and downgrade the current owner to a writer', + }, + { + displayName: 'Use Domain Admin Access', + name: 'useDomainAdminAccess', + type: 'boolean', + displayOptions: { + show: { + '/operation': ['share'], + '/resource': ['file', 'folder'], + }, + }, + default: false, + description: + 'Whether to perform the operation as domain administrator, i.e. if you are an administrator of the domain to which the shared drive belongs, you will be granted access automatically.', + }, + + { + displayName: 'File Name', + name: 'name', + type: 'string', + displayOptions: { + show: { + '/operation': ['copy'], + '/resource': ['file'], + }, + }, + default: '', + placeholder: 'invoice_1.pdf', + description: 'The name the file should be saved as', + }, + { + displayName: 'Parents', + name: 'parents', + type: 'string', + displayOptions: { + show: { + '/operation': ['copy', 'create'], + '/resource': ['file', 'folder'], + }, + }, + typeOptions: { + multipleValues: true, + }, + default: [], + description: 'The IDs of the parent folders the file/folder should be saved in', + }, + { + displayName: 'Spaces', + name: 'spaces', + type: 'multiOptions', + displayOptions: { + show: { + '/operation': ['list'], + '/resource': ['file'], + }, + }, + options: [ + { + name: '[All]', + value: '*', + description: 'All spaces', + }, + { + name: 'appDataFolder', + value: 'appDataFolder', + }, + { + name: 'Drive', + value: 'drive', + }, + { + name: 'Photos', + value: 'photos', + }, + ], + default: [], + description: 'The spaces to operate on', + }, + { + displayName: 'Corpora', + name: 'corpora', + type: 'options', + displayOptions: { + show: { + '/operation': ['list'], + '/resource': ['file'], + }, + }, + options: [ + { + name: 'User', + value: 'user', + description: 'All files in "My Drive" and "Shared with me"', + }, + { + name: 'Domain', + value: 'domain', + description: "All files shared to the user's domain that are searchable", + }, + { + name: 'Drive', + value: 'drive', + description: 'All files contained in a single shared drive', + }, + { + name: 'allDrives', + value: 'allDrives', + description: 'All drives', + }, + ], + default: '', + description: 'The corpora to operate on', + }, + { + displayName: 'Drive ID', + name: 'driveId', + type: 'string', + default: '', + displayOptions: { + show: { + '/operation': ['list'], + '/resource': ['file'], + corpora: ['drive'], + }, + }, + description: + 'ID of the shared drive to search. The driveId parameter must be specified if and only if corpora is set to drive.', + }, + ], + }, + // ---------------------------------- + // drive + // ---------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['drive'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a drive', + action: 'Create a drive', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a drive', + action: 'Delete a drive', + }, + { + name: 'Get', + value: 'get', + description: 'Get a drive', + action: 'Get a drive', + }, + { + name: 'List', + value: 'list', + description: 'List all drives', + action: 'List all drives', + }, + { + name: 'Update', + value: 'update', + description: 'Update a drive', + action: 'Update a drive', + }, + ], + default: 'create', + }, + + { + displayName: 'Drive', + name: 'driveId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + hint: 'The Google Drive drive to operate on', + modes: [ + { + displayName: 'Drive', + name: 'list', + type: 'list', + placeholder: 'Drive', + typeOptions: { + searchListMethod: 'driveSearch', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: 'https://drive.google.com/drive/folders/0AaaaaAAAAAAAaa', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/drive\\.google\\.com\\/\\w+\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive Drive URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + hint: 'The ID of the shared drive', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive Drive ID', + }, + }, + ], + url: '=https://drive.google.com/drive/folders/{{$value}}', + }, + ], + displayOptions: { + show: { + operation: ['delete', 'get', 'update'], + resource: ['drive'], + }, + }, + description: 'The ID of the drive', + }, + + // ---------------------------------- + // drive:create + // ---------------------------------- + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + displayOptions: { + show: { + operation: ['create'], + resource: ['drive'], + }, + }, + description: 'The name of this shared drive', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['create'], + resource: ['drive'], + }, + }, + options: [ + { + displayName: 'Capabilities', + name: 'capabilities', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Can Add Children', + name: 'canAddChildren', + type: 'boolean', + default: false, + description: + 'Whether the current user can add children to folders in this shared drive', + }, + { + displayName: 'Can Change Copy Requires Writer Permission Restriction', + name: 'canChangeCopyRequiresWriterPermissionRestriction', + type: 'boolean', + default: false, + description: + 'Whether the current user can change the copyRequiresWriterPermission restriction of this shared drive', + }, + { + displayName: 'Can Change Domain Users Only Restriction', + name: 'canChangeDomainUsersOnlyRestriction', + type: 'boolean', + default: false, + description: + 'Whether the current user can change the domainUsersOnly restriction of this shared drive', + }, + { + displayName: 'Can Change Drive Background', + name: 'canChangeDriveBackground', + type: 'boolean', + default: false, + description: + 'Whether the current user can change the background of this shared drive', + }, + { + displayName: 'Can Change Drive Members Only Restriction', + name: 'canChangeDriveMembersOnlyRestriction', + type: 'boolean', + default: false, + description: + 'Whether the current user can change the driveMembersOnly restriction of this shared drive', + }, + { + displayName: 'Can Comment', + name: 'canComment', + type: 'boolean', + default: false, + description: 'Whether the current user can comment on files in this shared drive', + }, + { + displayName: 'Can Copy', + name: 'canCopy', + type: 'boolean', + default: false, + description: 'Whether the current user can copy files in this shared drive', + }, + { + displayName: 'Can Delete Children', + name: 'canDeleteChildren', + type: 'boolean', + default: false, + description: + 'Whether the current user can delete children from folders in this shared drive', + }, + { + displayName: 'Can Delete Drive', + name: 'canDeleteDrive', + type: 'boolean', + default: false, + description: + 'Whether the current user can delete this shared drive. Attempting to delete the shared drive may still fail if there are untrashed items inside the shared drive.', + }, + { + displayName: 'Can Download', + name: 'canDownload', + type: 'boolean', + default: false, + description: 'Whether the current user can download files in this shared drive', + }, + { + displayName: 'Can Edit', + name: 'canEdit', + type: 'boolean', + default: false, + description: 'Whether the current user can edit files in this shared drive', + }, + { + displayName: 'Can List Children', + name: 'canListChildren', + type: 'boolean', + default: false, + description: + 'Whether the current user can list the children of folders in this shared drive', + }, + { + displayName: 'Can Manage Members', + name: 'canManageMembers', + type: 'boolean', + default: false, + description: + 'Whether the current user can add members to this shared drive or remove them or change their role', + }, + { + displayName: 'Can Read Revisions', + name: 'canReadRevisions', + type: 'boolean', + default: false, + description: + 'Whether the current user can read the revisions resource of files in this shared drive', + }, + { + displayName: 'Can Rename', + name: 'canRename', + type: 'boolean', + default: false, + description: + 'Whether the current user can rename files or folders in this shared drive', + }, + { + displayName: 'Can Rename Drive', + name: 'canRenameDrive', + type: 'boolean', + default: false, + description: 'Whether the current user can rename this shared drive', + }, + { + displayName: 'Can Share', + name: 'canShare', + type: 'boolean', + default: false, + description: 'Whether the current user can rename this shared drive', + }, + { + displayName: 'Can Trash Children', + name: 'canTrashChildren', + type: 'boolean', + default: false, + description: + 'Whether the current user can trash children from folders in this shared drive', + }, + ], + }, + { + displayName: 'Color RGB', + name: 'colorRgb', + type: 'color', + default: '', + description: 'The color of this shared drive as an RGB hex string', + }, + { + displayName: 'Created Time', + name: 'createdTime', + type: 'dateTime', + default: '', + description: 'The time at which the shared drive was created (RFC 3339 date-time)', + }, + { + displayName: 'Hidden', + name: 'hidden', + type: 'boolean', + default: false, + description: 'Whether the shared drive is hidden from default view', + }, + { + displayName: 'Restrictions', + name: 'restrictions', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Admin Managed Restrictions', + name: 'adminManagedRestrictions', + type: 'boolean', + default: false, + description: + 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', + }, + { + displayName: 'Copy Requires Writer Permission', + name: 'copyRequiresWriterPermission', + type: 'boolean', + default: false, + description: + 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', + }, + { + displayName: 'Domain Users Only', + name: 'domainUsersOnly', + type: 'boolean', + default: false, + description: + 'Whether access to this shared drive and items inside this shared drive is restricted to users of the domain to which this shared drive belongs. This restriction may be overridden by other sharing policies controlled outside of this shared drive.', + }, + { + displayName: 'Drive Members Only', + name: 'driveMembersOnly', + type: 'boolean', + default: false, + description: + 'Whether access to items inside this shared drive is restricted to its members', + }, + ], + }, + ], + }, + // ---------------------------------- + // drive:delete + // ---------------------------------- + + // ---------------------------------- + // drive:get + // ---------------------------------- + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['get'], + resource: ['drive'], + }, + }, + options: [ + { + displayName: 'Use Domain Admin Access', + name: 'useDomainAdminAccess', + type: 'boolean', + default: false, + description: + 'Whether to issue the request as a domain administrator; if set to true, then the requester will be granted access if they are an administrator of the domain to which the shared drive belongs. (Default: false).', + }, + ], + }, + // ---------------------------------- + // drive:list + // ---------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: ['list'], + resource: ['drive'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: ['list'], + resource: ['drive'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 200, + }, + default: 100, + description: 'Max number of results to return', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['list'], + resource: ['drive'], + }, + }, + options: [ + { + displayName: 'Query', + name: 'q', + type: 'string', + default: '', + description: + 'Query string for searching shared drives. See the "Search for shared drives" guide for supported syntax.', + }, + { + displayName: 'Use Domain Admin Access', + name: 'useDomainAdminAccess', + type: 'boolean', + default: false, + description: + 'Whether to issue the request as a domain administrator; if set to true, then the requester will be granted access if they are an administrator of the domain to which the shared drive belongs. (Default: false).', + }, + ], + }, + // ---------------------------------- + // drive:update + // ---------------------------------- + { + displayName: 'Update Fields', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['update'], + resource: ['drive'], + }, + }, + options: [ + { + displayName: 'Color RGB', + name: 'colorRgb', + type: 'color', + default: '', + description: 'The color of this shared drive as an RGB hex string', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'The name of this shared drive', + }, + { + displayName: 'Restrictions', + name: 'restrictions', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Admin Managed Restrictions', + name: 'adminManagedRestrictions', + type: 'boolean', + default: false, + description: + 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', + }, + { + displayName: 'Copy Requires Writer Permission', + name: 'copyRequiresWriterPermission', + type: 'boolean', + default: false, + description: + 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', + }, + { + displayName: 'Domain Users Only', + name: 'domainUsersOnly', + type: 'boolean', + default: false, + description: + 'Whether access to this shared drive and items inside this shared drive is restricted to users of the domain to which this shared drive belongs. This restriction may be overridden by other sharing policies controlled outside of this shared drive.', + }, + { + displayName: 'Drive Members Only', + name: 'driveMembersOnly', + type: 'boolean', + default: false, + description: + 'Whether access to items inside this shared drive is restricted to its members', + }, + ], + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['upload'], + resource: ['file'], + }, + }, + options: [ + { + displayName: 'APP Properties', + name: 'appPropertiesUi', + placeholder: 'Add Property', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + description: + 'A collection of arbitrary key-value pairs which are private to the requesting app', + options: [ + { + name: 'appPropertyValues', + displayName: 'APP Property', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'Name of the key to add', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value to set for the key', + }, + ], + }, + ], + }, + { + displayName: 'Properties', + name: 'propertiesUi', + placeholder: 'Add Property', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + description: 'A collection of arbitrary key-value pairs which are visible to all apps', + options: [ + { + name: 'propertyValues', + displayName: 'Property', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'Name of the key to add', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value to set for the key', + }, + ], + }, + ], + }, + ], + }, + ], +}; + +export class GoogleDriveV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + listSearch: { + fileSearch, + folderSearch, + driveSearch, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + + for (let i = 0; i < items.length; i++) { + try { + const options = this.getNodeParameter('options', i, {}); + + let queryFields = 'id, name'; + if (options?.fields) { + const fields = options.fields as string[]; + if (fields.includes('*')) { + queryFields = '*'; + } else { + queryFields = fields.join(', '); + } + } + + if (resource === 'drive') { + if (operation === 'create') { + // ---------------------------------- + // create + // ---------------------------------- + + const name = this.getNodeParameter('name', i) as string; + + const body: IDataObject = { + name, + }; + + Object.assign(body, options); + + const response = await googleApiRequest.call(this, 'POST', '/drive/v3/drives', body, { + requestId: uuid(), + }); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } + if (operation === 'delete') { + // ---------------------------------- + // delete + // ---------------------------------- + + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + await googleApiRequest.call(this, 'DELETE', `/drive/v3/drives/${driveId}`); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ success: true }), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } + if (operation === 'get') { + // ---------------------------------- + // get + // ---------------------------------- + + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + const qs: IDataObject = {}; + + Object.assign(qs, options); + + const response = await googleApiRequest.call( + this, + 'GET', + `/drive/v3/drives/${driveId}`, + {}, + qs, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } + if (operation === 'list') { + // ---------------------------------- + // list + // ---------------------------------- + const returnAll = this.getNodeParameter('returnAll', i); + + const qs: IDataObject = {}; + + let response: IDataObject[] = []; + + Object.assign(qs, options); + + if (returnAll) { + response = await googleApiRequestAllItems.call( + this, + 'drives', + 'GET', + '/drive/v3/drives', + {}, + qs, + ); + } else { + qs.pageSize = this.getNodeParameter('limit', i); + const data = await googleApiRequest.call(this, 'GET', '/drive/v3/drives', {}, qs); + response = data.drives as IDataObject[]; + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } + if (operation === 'update') { + // ---------------------------------- + // update + // ---------------------------------- + + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + const body: IDataObject = {}; + + Object.assign(body, options); + + const response = await googleApiRequest.call( + this, + 'PATCH', + `/drive/v3/drives/${driveId}`, + body, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } + } + if (resource === 'file') { + if (operation === 'copy') { + // ---------------------------------- + // copy + // ---------------------------------- + + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; + + const body: IDataObject = { + fields: queryFields, + }; + + const optionProperties = ['name', 'parents']; + for (const propertyName of optionProperties) { + if (options[propertyName] !== undefined) { + body[propertyName] = options[propertyName]; + } + } + + const qs = { + supportsAllDrives: true, + }; + + const response = await googleApiRequest.call( + this, + 'POST', + `/drive/v3/files/${fileId}/copy`, + body, + qs, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } else if (operation === 'download') { + // ---------------------------------- + // download + // ---------------------------------- + + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; + const downloadOptions = this.getNodeParameter('options', i); + + const requestOptions = { + useStream: true, + resolveWithFullResponse: true, + encoding: null, + json: false, + }; + + const file = await googleApiRequest.call( + this, + 'GET', + `/drive/v3/files/${fileId}`, + {}, + { fields: 'mimeType,name', supportsTeamDrives: true }, + ); + let response; + + if (file.mimeType.includes('vnd.google-apps')) { + const parameterKey = 'options.googleFileConversion.conversion'; + const type = file.mimeType.split('.')[2]; + let mime; + if (type === 'document') { + mime = this.getNodeParameter( + `${parameterKey}.docsToFormat`, + i, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) as string; + } else if (type === 'presentation') { + mime = this.getNodeParameter( + `${parameterKey}.slidesToFormat`, + i, + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ) as string; + } else if (type === 'spreadsheet') { + mime = this.getNodeParameter( + `${parameterKey}.sheetsToFormat`, + i, + 'application/x-vnd.oasis.opendocument.spreadsheet', + ) as string; + } else { + mime = this.getNodeParameter( + `${parameterKey}.drawingsToFormat`, + i, + 'image/jpeg', + ) as string; + } + response = await googleApiRequest.call( + this, + 'GET', + `/drive/v3/files/${fileId}/export`, + {}, + { mimeType: mime }, + undefined, + requestOptions, + ); + } else { + response = await googleApiRequest.call( + this, + 'GET', + `/drive/v3/files/${fileId}`, + {}, + { alt: 'media' }, + undefined, + requestOptions, + ); + } + + const mimeType = response.headers['content-type'] ?? file.mimeType ?? undefined; + const fileName = downloadOptions.fileName ?? file.name ?? undefined; + + const newItem: INodeExecutionData = { + json: items[i].json, + binary: {}, + }; + + 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 as IBinaryKeyData, items[i].binary); + } + + items[i] = newItem; + + const dataPropertyNameDownload = this.getNodeParameter('binaryPropertyName', i); + + items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData( + response.body as unknown as Readable, + fileName as string, + mimeType as string, + ); + } else if (operation === 'list') { + // ---------------------------------- + // list + // ---------------------------------- + + let querySpaces = ''; + if (options.spaces) { + const spaces = options.spaces as string[]; + if (spaces.includes('*')) { + querySpaces = 'appDataFolder, drive, photos'; + } else { + querySpaces = spaces.join(', '); + } + } + + let queryCorpora = ''; + if (options.corpora) { + queryCorpora = options.corpora as string; + } + + let driveId: string | undefined; + driveId = options.driveId as string; + if (driveId === '') { + driveId = undefined; + } + + let queryString = ''; + const useQueryString = this.getNodeParameter('useQueryString', i) as boolean; + if (useQueryString) { + // Use the user defined query string + queryString = this.getNodeParameter('queryString', i) as string; + } else { + // Build query string out of parameters set by user + const queryFilters = this.getNodeParameter('queryFilters', i) as IDataObject; + + const queryFilterFields: string[] = []; + if (queryFilters.name) { + (queryFilters.name as IDataObject[]).forEach((nameFilter) => { + let filterOperation = nameFilter.operation; + if (filterOperation === 'is') { + filterOperation = '='; + } else if (filterOperation === 'isNot') { + filterOperation = '!='; + } + queryFilterFields.push(`name ${filterOperation} '${nameFilter.value}'`); + }); + + queryString += queryFilterFields.join(' or '); + } + + queryFilterFields.length = 0; + if (queryFilters.mimeType) { + (queryFilters.mimeType as IDataObject[]).forEach((mimeTypeFilter) => { + let mimeType = mimeTypeFilter.mimeType; + if (mimeTypeFilter.mimeType === 'custom') { + mimeType = mimeTypeFilter.customMimeType; + } + queryFilterFields.push(`mimeType = '${mimeType}'`); + }); + + if (queryFilterFields.length) { + if (queryString !== '') { + queryString += ' and '; + } + + queryString += queryFilterFields.join(' or '); + } + } + } + + const pageSize = this.getNodeParameter('limit', i); + + const qs = { + pageSize, + orderBy: 'modifiedTime', + fields: `nextPageToken, files(${queryFields})`, + spaces: querySpaces, + q: queryString, + includeItemsFromAllDrives: queryCorpora !== '' || driveId !== '', + supportsAllDrives: queryCorpora !== '' || driveId !== '', + }; + + const response = await googleApiRequest.call(this, 'GET', '/drive/v3/files', {}, qs); + + const files = response.files; + + const version = this.getNode().typeVersion; + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(files as IDataObject[]), + { itemData: { item: i } }, + ); + + if (version === 1) { + return [executionData]; + } + + returnData.push(...executionData); + } else if (operation === 'upload') { + // ---------------------------------- + // upload + // ---------------------------------- + const resolveData = this.getNodeParameter('resolveData', 0); + + let contentLength: number; + let fileContent: Buffer | Readable; + let originalFilename: string | undefined; + let mimeType = 'text/plain'; + + if (this.getNodeParameter('binaryData', i)) { + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i); + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + if (binaryData.id) { + // Stream data in 256KB chunks, and upload the via the resumable upload api + fileContent = this.helpers.getBinaryStream(binaryData.id, UPLOAD_CHUNK_SIZE); + const metadata = await this.helpers.getBinaryMetadata(binaryData.id); + contentLength = metadata.fileSize; + originalFilename = metadata.fileName; + if (metadata.mimeType) mimeType = binaryData.mimeType; + } else { + fileContent = Buffer.from(binaryData.data, BINARY_ENCODING); + contentLength = fileContent.length; + originalFilename = binaryData.fileName; + mimeType = binaryData.mimeType; + } + } else { + // Is text file + fileContent = Buffer.from(this.getNodeParameter('fileContent', i) as string, 'utf8'); + contentLength = fileContent.byteLength; + } + + const name = this.getNodeParameter('name', i) as string; + const parents = this.getNodeParameter('parents', i) as string[]; + + let uploadId; + if (Buffer.isBuffer(fileContent)) { + const response = await googleApiRequest.call( + this, + 'POST', + '/upload/drive/v3/files', + fileContent, + { + fields: queryFields, + uploadType: 'media', + }, + undefined, + { + headers: { + 'Content-Type': mimeType, + 'Content-Length': contentLength, + }, + encoding: null, + json: false, + }, + ); + uploadId = JSON.parse(response as string).id; + } else { + const resumableUpload = await googleApiRequest.call( + this, + 'POST', + '/upload/drive/v3/files', + undefined, + { uploadType: 'resumable' }, + undefined, + { + resolveWithFullResponse: true, + }, + ); + const uploadUrl = resumableUpload.headers.location; + + let offset = 0; + for await (const chunk of fileContent) { + const nextOffset = offset + Number(chunk.length); + try { + const response = await this.helpers.httpRequest({ + method: 'PUT', + url: uploadUrl, + headers: { + 'Content-Length': chunk.length, + 'Content-Range': `bytes ${offset}-${nextOffset - 1}/${contentLength}`, + }, + body: chunk, + }); + uploadId = response.id; + } catch (error) { + if (error.response?.status !== 308) throw error; + } + offset = nextOffset; + } + } + + const requestBody = { + mimeType, + name, + originalFilename, + }; + + const properties = this.getNodeParameter( + 'options.propertiesUi.propertyValues', + i, + [], + ) as IDataObject[]; + + if (properties.length) { + Object.assign(requestBody, { + properties: properties.reduce( + (obj, value) => Object.assign(obj, { [`${value.key}`]: value.value }), + {}, + ), + }); + } + + const appProperties = this.getNodeParameter( + 'options.appPropertiesUi.appPropertyValues', + i, + [], + ) as IDataObject[]; + + if (properties.length) { + Object.assign(requestBody, { + appProperties: appProperties.reduce( + (obj, value) => Object.assign(obj, { [`${value.key}`]: value.value }), + {}, + ), + }); + } + + let response = await googleApiRequest.call( + this, + 'PATCH', + `/drive/v3/files/${uploadId}`, + requestBody, + { + addParents: parents.join(','), + // When set to true shared drives can be used. + supportsAllDrives: true, + }, + ); + + if (resolveData) { + response = await googleApiRequest.call( + this, + 'GET', + `/drive/v3/files/${response.id}`, + {}, + { fields: '*' }, + ); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } else if (operation === 'update') { + // ---------------------------------- + // file:update + // ---------------------------------- + + const id = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; + const updateFields = this.getNodeParameter('updateFields', i, {}); + + const qs: IDataObject = { + supportsAllDrives: true, + }; + + Object.assign(qs, options); + + qs.fields = queryFields; + + const body: IDataObject = {}; + + if (updateFields.fileName) { + body.name = updateFields.fileName; + } + + if (updateFields.hasOwnProperty('trashed')) { + body.trashed = updateFields.trashed; + } + + if (updateFields.parentId && updateFields.parentId !== '') { + qs.addParents = updateFields.parentId; + } + + const responseData = await googleApiRequest.call( + this, + 'PATCH', + `/drive/v3/files/${id}`, + body, + qs, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } + } + if (resource === 'folder') { + if (operation === 'create') { + // ---------------------------------- + // folder:create + // ---------------------------------- + + const name = this.getNodeParameter('name', i) as string; + + const body = { + name, + mimeType: 'application/vnd.google-apps.folder', + parents: options.parents || [], + }; + + const qs = { + fields: queryFields, + supportsAllDrives: true, + }; + + const response = await googleApiRequest.call(this, 'POST', '/drive/v3/files', body, qs); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } + } + if (['file', 'folder'].includes(resource)) { + if (operation === 'delete') { + // ---------------------------------- + // delete + // ---------------------------------- + + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; + + await googleApiRequest.call( + this, + 'DELETE', + `/drive/v3/files/${fileId}`, + {}, + { supportsTeamDrives: true }, + ); + + // If we are still here it did succeed + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ + fileId, + success: true, + }), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } + if (operation === 'share') { + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; + + const permissions = this.getNodeParameter('permissionsUi', i) as IDataObject; + + const shareOption = this.getNodeParameter('options', i); + + const body: IDataObject = {}; + + const qs: IDataObject = { + supportsTeamDrives: true, + }; + + if (permissions.permissionsValues) { + Object.assign(body, permissions.permissionsValues); + } + + Object.assign(qs, shareOption); + + const response = await googleApiRequest.call( + this, + 'POST', + `/drive/v3/files/${fileId}/permissions`, + body, + qs, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } + } + } catch (error) { + if (this.continueOnFail()) { + if (resource === 'file' && operation === 'download') { + items[i].json = { error: error.message }; + } else { + returnData.push({ json: { error: error.message } }); + } + continue; + } + throw error; + } + } + if (resource === 'file' && operation === 'download') { + // For file downloads the files get attached to the existing items + return this.prepareOutputData(items); + } else { + // For all other ones does the output items get replaced + return this.prepareOutputData(returnData); + } + } +} diff --git a/packages/nodes-base/nodes/Google/Drive/SearchFunctions.ts b/packages/nodes-base/nodes/Google/Drive/v1/SearchFunctions.ts similarity index 100% rename from packages/nodes-base/nodes/Google/Drive/SearchFunctions.ts rename to packages/nodes-base/nodes/Google/Drive/v1/SearchFunctions.ts diff --git a/packages/nodes-base/nodes/Google/Drive/v2/GoogleDriveV2.node.ts b/packages/nodes-base/nodes/Google/Drive/v2/GoogleDriveV2.node.ts new file mode 100644 index 0000000000..7a807dafe2 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/GoogleDriveV2.node.ts @@ -0,0 +1,28 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { + IExecuteFunctions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { versionDescription } from './actions/versionDescription'; +import { listSearch } from './methods'; +import { router } from './actions/router'; + +export class GoogleDriveV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { listSearch }; + + async execute(this: IExecuteFunctions) { + return router.call(this); + } +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/common.descriptions.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/common.descriptions.ts new file mode 100644 index 0000000000..d9179ff185 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/common.descriptions.ts @@ -0,0 +1,623 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { DRIVE, RLC_DRIVE_DEFAULT } from '../helpers/interfaces'; + +export const fileRLC: INodeProperties = { + displayName: 'File', + name: 'fileId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'File', + name: 'list', + type: 'list', + placeholder: 'Select a file...', + typeOptions: { + searchListMethod: 'fileSearch', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: + 'e.g. https://drive.google.com/file/d/1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A/edit', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/(?:drive|docs)\\.google\\.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/(?:drive|docs)\\.google.com\\/\\w+\\/d\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive File URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive File ID', + }, + }, + ], + url: '=https://drive.google.com/file/d/{{$value}}/view', + }, + ], + description: 'The file to operate on', +}; + +export const folderNoRootRLC: INodeProperties = { + displayName: 'Folder', + name: 'folderNoRootId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'Folder', + name: 'list', + type: 'list', + placeholder: 'Select a folder...', + typeOptions: { + searchListMethod: 'folderSearch', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: 'e.g. https://drive.google.com/drive/folders/1Tx9WHbA3wBpPB4C_HcoZDH9WZFWYxAMU', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive Folder URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive Folder ID', + }, + }, + ], + url: '=https://drive.google.com/drive/folders/{{$value}}', + }, + ], + description: 'The folder to operate on', +}; + +export const folderRLC: INodeProperties = { + displayName: 'Folder', + name: 'folderId', + type: 'resourceLocator', + default: { mode: 'list', value: 'root', cachedResultName: '/ (Root folder)' }, + required: true, + modes: [ + { + displayName: 'Folder', + name: 'list', + type: 'list', + placeholder: 'Select a folder...', + typeOptions: { + searchListMethod: 'folderSearchWithDefault', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: 'e.g. https://drive.google.com/drive/folders/1Tx9WHbA3wBpPB4C_HcoZDH9WZFWYxAMU', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive Folder URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 1anGBg0b5re2VtF2bKu201_a-Vnz5BHq9Y4r-yBDAj5A', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive Folder ID', + }, + }, + ], + url: '=https://drive.google.com/drive/folders/{{$value}}', + }, + ], + description: 'The folder to operate on', +}; + +export const driveRLC: INodeProperties = { + displayName: 'Drive', + name: 'driveId', + type: 'resourceLocator', + default: { mode: 'list', value: RLC_DRIVE_DEFAULT }, + required: true, + modes: [ + { + displayName: 'Drive', + name: 'list', + type: 'list', + placeholder: 'Select a drive...', + typeOptions: { + searchListMethod: 'driveSearchWithDefault', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: 'https://drive.google.com/drive/folders/0AaaaaAAAAAAAaa', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive Drive URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + hint: 'The ID of the shared drive', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive Drive ID', + }, + }, + ], + url: '=https://drive.google.com/drive/folders/{{$value}}', + }, + ], + description: 'The ID of the drive', +}; + +export const sharedDriveRLC: INodeProperties = { + displayName: 'Shared Drive', + name: 'driveId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'Drive', + name: 'list', + type: 'list', + placeholder: 'Select a shared drive...', + typeOptions: { + searchListMethod: 'driveSearch', + searchable: true, + }, + }, + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: 'e.g. https://drive.google.com/drive/u/1/folders/0AIjtcbwnjtcbwn9PVA', + extractValue: { + type: 'regex', + regex: + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: + 'https:\\/\\/drive\\.google\\.com(?:\\/.*|)\\/folders\\/([0-9a-zA-Z\\-_]+)(?:\\/.*|)', + errorMessage: 'Not a valid Google Drive Drive URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + // hint: 'The ID of the shared drive', + placeholder: 'e.g. 0AMXTKI5ZSiM7Uk9PVA', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\-_]{2,}', + errorMessage: 'Not a valid Google Drive Drive ID', + }, + }, + ], + url: '=https://drive.google.com/drive/folders/{{$value}}', + }, + ], + description: 'The shared drive to operate on', +}; + +export const shareOptions: INodeProperties = { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Email Message', + name: 'emailMessage', + type: 'string', + default: '', + description: 'A plain text custom message to include in the notification email', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Move To New Owners Root', + name: 'moveToNewOwnersRoot', + type: 'boolean', + default: false, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: + "

This parameter only takes effect if the item is not in a shared drive and the request is attempting to transfer the ownership of the item.

When set to true, the item is moved to the new owner's My Drive root folder and all prior parents removed.

", + }, + { + displayName: 'Send Notification Email', + name: 'sendNotificationEmail', + type: 'boolean', + default: false, + description: 'Whether to send a notification email when sharing to users or groups', + }, + { + displayName: 'Transfer Ownership', + name: 'transferOwnership', + type: 'boolean', + default: false, + description: + 'Whether to transfer ownership to the specified user and downgrade the current owner to a writer', + }, + { + displayName: 'Use Domain Admin Access', + name: 'useDomainAdminAccess', + type: 'boolean', + default: false, + description: + 'Whether to perform the operation as domain administrator, i.e. if you are an administrator of the domain to which the shared drive belongs, you will be granted access automatically.', + }, + ], +}; + +export const permissionsOptions: INodeProperties = { + displayName: 'Permissions', + name: 'permissionsUi', + placeholder: 'Add Permission', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: false, + }, + options: [ + { + displayName: 'Permission', + name: 'permissionsValues', + values: [ + { + displayName: 'Role', + name: 'role', + type: 'options', + description: 'Defines what users can do with the file or folder', + options: [ + { + name: 'Commenter', + value: 'commenter', + }, + { + name: 'File Organizer', + value: 'fileOrganizer', + }, + { + name: 'Organizer', + value: 'organizer', + }, + { + name: 'Owner', + value: 'owner', + }, + { + name: 'Reader', + value: 'reader', + }, + { + name: 'Writer', + value: 'writer', + }, + ], + default: '', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'User', + value: 'user', + }, + { + name: 'Group', + value: 'group', + }, + { + name: 'Domain', + value: 'domain', + }, + { + name: 'Anyone', + value: 'anyone', + }, + ], + default: '', + description: + 'The scope of the permission. A permission with type=user applies to a specific user whereas a permission with type=domain applies to everyone in a specific domain.', + }, + { + displayName: 'Email Address', + name: 'emailAddress', + type: 'string', + displayOptions: { + show: { + type: ['user', 'group'], + }, + }, + placeholder: '“e.g. name@mail.com', + default: '', + description: 'The email address of the user or group to which this permission refers', + }, + { + displayName: 'Domain', + name: 'domain', + type: 'string', + displayOptions: { + show: { + type: ['domain'], + }, + }, + placeholder: 'e.g. mycompany.com', + default: '', + description: 'The domain to which this permission refers', + }, + { + displayName: 'Allow File Discovery', + name: 'allowFileDiscovery', + type: 'boolean', + displayOptions: { + show: { + type: ['domain', 'anyone'], + }, + }, + default: false, + description: 'Whether to allow the file to be discovered through search', + }, + ], + }, + ], +}; + +export const updateCommonOptions: INodeProperties[] = [ + { + displayName: 'APP Properties', + name: 'appPropertiesUi', + placeholder: 'Add Property', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + description: + 'A collection of arbitrary key-value pairs which are private to the requesting app', + options: [ + { + name: 'appPropertyValues', + displayName: 'APP Property', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'Name of the key to add', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value to set for the key', + }, + ], + }, + ], + }, + { + displayName: 'Properties', + name: 'propertiesUi', + placeholder: 'Add Property', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + description: 'A collection of arbitrary key-value pairs which are visible to all apps', + options: [ + { + name: 'propertyValues', + displayName: 'Property', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'Name of the key to add', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value to set for the key', + }, + ], + }, + ], + }, + { + displayName: 'Keep Revision Forever', + name: 'keepRevisionForever', + type: 'boolean', + default: false, + description: + "Whether to set the 'keepForever' field in the new head revision. This is only applicable to files with binary content in Google Drive. Only 200 revisions for the file can be kept forever. If the limit is reached, try deleting pinned revisions.", + }, + { + displayName: 'OCR Language', + name: 'ocrLanguage', + type: 'string', + default: '', + placeholder: 'e.g. en', + description: 'A language hint for OCR processing during image import (ISO 639-1 code)', + }, + { + displayName: 'Use Content As Indexable Text', + name: 'useContentAsIndexableText', + type: 'boolean', + default: false, + description: 'Whether to use the uploaded content as indexable text', + }, +]; + +export const fileTypesOptions = [ + { + name: 'All', + value: '*', + description: 'Return all file types', + }, + { + name: '3rd Party Shortcut', + value: DRIVE.SDK, + }, + { + name: 'Audio', + value: DRIVE.AUDIO, + }, + { + name: 'Folder', + value: DRIVE.FOLDER, + }, + { + name: 'Google Apps Scripts', + value: DRIVE.APP_SCRIPTS, + }, + { + name: 'Google Docs', + value: DRIVE.DOCUMENT, + }, + { + name: 'Google Drawing', + value: DRIVE.DRAWING, + }, + { + name: 'Google Forms', + value: DRIVE.FORM, + }, + { + name: 'Google Fusion Tables', + value: DRIVE.FUSIONTABLE, + }, + { + name: 'Google My Maps', + value: DRIVE.MAP, + }, + { + name: 'Google Sheets', + value: DRIVE.SPREADSHEET, + }, + { + name: 'Google Sites', + value: DRIVE.SITES, + }, + { + name: 'Google Slides', + value: DRIVE.PRESENTATION, + }, + { + name: 'Photo', + value: DRIVE.PHOTO, + }, + { + name: 'Unknown', + value: DRIVE.UNKNOWN, + }, + { + name: 'Video', + value: DRIVE.VIDEO, + }, +]; diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/Drive.resource.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/Drive.resource.ts new file mode 100644 index 0000000000..19d82cb1eb --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/Drive.resource.ts @@ -0,0 +1,61 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as create from './create.operation'; +import * as deleteDrive from './deleteDrive.operation'; +import * as get from './get.operation'; +import * as list from './list.operation'; +import * as update from './update.operation'; + +export { create, deleteDrive, get, list, update }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a shared drive', + action: 'Create shared drive', + }, + { + name: 'Delete', + value: 'deleteDrive', + description: 'Permanently delete a shared drive', + action: 'Delete shared drive', + }, + { + name: 'Get', + value: 'get', + description: 'Get a shared drive', + action: 'Get shared drive', + }, + { + name: 'Get Many', + value: 'list', + description: 'Get the list of shared drives', + action: 'Get many shared drives', + }, + { + name: 'Update', + value: 'update', + description: 'Update a shared drive', + action: 'Update shared drive', + }, + ], + default: 'create', + displayOptions: { + show: { + resource: ['drive'], + }, + }, + }, + ...create.description, + ...deleteDrive.description, + ...get.description, + ...list.description, + ...update.description, +]; diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/create.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/create.operation.ts new file mode 100644 index 0000000000..47f3be8a0b --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/create.operation.ts @@ -0,0 +1,263 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; + +import { v4 as uuid } from 'uuid'; + +const properties: INodeProperties[] = [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. New Shared Drive', + description: 'The name of the shared drive to create', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Capabilities', + name: 'capabilities', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Can Add Children', + name: 'canAddChildren', + type: 'boolean', + default: false, + description: + 'Whether the current user can add children to folders in this shared drive', + }, + { + displayName: 'Can Change Copy Requires Writer Permission Restriction', + name: 'canChangeCopyRequiresWriterPermissionRestriction', + type: 'boolean', + default: false, + description: + 'Whether the current user can change the copyRequiresWriterPermission restriction of this shared drive', + }, + { + displayName: 'Can Change Domain Users Only Restriction', + name: 'canChangeDomainUsersOnlyRestriction', + type: 'boolean', + default: false, + description: + 'Whether the current user can change the domainUsersOnly restriction of this shared drive', + }, + { + displayName: 'Can Change Drive Background', + name: 'canChangeDriveBackground', + type: 'boolean', + default: false, + description: 'Whether the current user can change the background of this shared drive', + }, + { + displayName: 'Can Change Drive Members Only Restriction', + name: 'canChangeDriveMembersOnlyRestriction', + type: 'boolean', + default: false, + description: + 'Whether the current user can change the driveMembersOnly restriction of this shared drive', + }, + { + displayName: 'Can Comment', + name: 'canComment', + type: 'boolean', + default: false, + description: 'Whether the current user can comment on files in this shared drive', + }, + { + displayName: 'Can Copy', + name: 'canCopy', + type: 'boolean', + default: false, + description: 'Whether the current user can copy files in this shared drive', + }, + { + displayName: 'Can Delete Children', + name: 'canDeleteChildren', + type: 'boolean', + default: false, + description: + 'Whether the current user can delete children from folders in this shared drive', + }, + { + displayName: 'Can Delete Drive', + name: 'canDeleteDrive', + type: 'boolean', + default: false, + description: + 'Whether the current user can delete this shared drive. Attempting to delete the shared drive may still fail if there are untrashed items inside the shared drive.', + }, + { + displayName: 'Can Download', + name: 'canDownload', + type: 'boolean', + default: false, + description: 'Whether the current user can download files in this shared drive', + }, + { + displayName: 'Can Edit', + name: 'canEdit', + type: 'boolean', + default: false, + description: 'Whether the current user can edit files in this shared drive', + }, + { + displayName: 'Can List Children', + name: 'canListChildren', + type: 'boolean', + default: false, + description: + 'Whether the current user can list the children of folders in this shared drive', + }, + { + displayName: 'Can Manage Members', + name: 'canManageMembers', + type: 'boolean', + default: false, + description: + 'Whether the current user can add members to this shared drive or remove them or change their role', + }, + { + displayName: 'Can Read Revisions', + name: 'canReadRevisions', + type: 'boolean', + default: false, + description: + 'Whether the current user can read the revisions resource of files in this shared drive', + }, + { + displayName: 'Can Rename', + name: 'canRename', + type: 'boolean', + default: false, + description: + 'Whether the current user can rename files or folders in this shared drive', + }, + { + displayName: 'Can Rename Drive', + name: 'canRenameDrive', + type: 'boolean', + default: false, + description: 'Whether the current user can rename this shared drive', + }, + { + displayName: 'Can Share', + name: 'canShare', + type: 'boolean', + default: false, + description: 'Whether the current user can rename this shared drive', + }, + { + displayName: 'Can Trash Children', + name: 'canTrashChildren', + type: 'boolean', + default: false, + description: + 'Whether the current user can trash children from folders in this shared drive', + }, + ], + }, + { + displayName: 'Color RGB', + name: 'colorRgb', + type: 'color', + default: '', + description: 'The color of this shared drive as an RGB hex string', + }, + { + displayName: 'Hidden', + name: 'hidden', + type: 'boolean', + default: false, + description: 'Whether the shared drive is hidden from default view', + }, + { + displayName: 'Restrictions', + name: 'restrictions', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Admin Managed Restrictions', + name: 'adminManagedRestrictions', + type: 'boolean', + default: false, + description: + 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', + }, + { + displayName: 'Copy Requires Writer Permission', + name: 'copyRequiresWriterPermission', + type: 'boolean', + default: false, + description: + 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', + }, + { + displayName: 'Domain Users Only', + name: 'domainUsersOnly', + type: 'boolean', + default: false, + description: + 'Whether access to this shared drive and items inside this shared drive is restricted to users of the domain to which this shared drive belongs. This restriction may be overridden by other sharing policies controlled outside of this shared drive.', + }, + { + displayName: 'Drive Members Only', + name: 'driveMembersOnly', + type: 'boolean', + default: false, + description: + 'Whether access to items inside this shared drive is restricted to its members', + }, + ], + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['drive'], + operation: ['create'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const returnData: INodeExecutionData[] = []; + const options = this.getNodeParameter('options', i); + + const name = this.getNodeParameter('name', i) as string; + + const body: IDataObject = { + name, + }; + + Object.assign(body, options); + + const response = await googleApiRequest.call(this, 'POST', '/drive/v3/drives', body, { + requestId: uuid(), + }); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/deleteDrive.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/deleteDrive.operation.ts new file mode 100644 index 0000000000..080c103eab --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/deleteDrive.operation.ts @@ -0,0 +1,41 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { sharedDriveRLC } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + ...sharedDriveRLC, + description: 'The shared drive to delete', + }, +]; + +const displayOptions = { + show: { + resource: ['drive'], + operation: ['deleteDrive'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const returnData: INodeExecutionData[] = []; + + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + await googleApiRequest.call(this, 'DELETE', `/drive/v3/drives/${driveId}`); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ success: true }), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/get.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/get.operation.ts new file mode 100644 index 0000000000..cab2ee8a48 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/get.operation.ts @@ -0,0 +1,63 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { sharedDriveRLC } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + ...sharedDriveRLC, + description: 'The shared drive to get', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Use Domain Admin Access', + name: 'useDomainAdminAccess', + type: 'boolean', + default: false, + description: + 'Whether to issue the request as a domain administrator; if set to true, then the requester will be granted access if they are an administrator of the domain to which the shared drive belongs', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['drive'], + operation: ['get'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const returnData: INodeExecutionData[] = []; + const options = this.getNodeParameter('options', i); + + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + const qs: IDataObject = {}; + + Object.assign(qs, options); + + const response = await googleApiRequest.call(this, 'GET', `/drive/v3/drives/${driveId}`, {}, qs); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/list.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/list.operation.ts new file mode 100644 index 0000000000..cd0fffa2ca --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/list.operation.ts @@ -0,0 +1,103 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; + +import { googleApiRequest, googleApiRequestAllItems } from '../../transport'; + +const properties: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 200, + }, + default: 100, + description: 'Max number of results to return', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Query', + name: 'q', + type: 'string', + default: '', + description: + 'Query string for searching shared drives. See the "Search for shared drives" guide for supported syntax.', + }, + { + displayName: 'Use Domain Admin Access', + name: 'useDomainAdminAccess', + type: 'boolean', + default: false, + description: + 'Whether to issue the request as a domain administrator; if set to true, then the requester will be granted access if they are an administrator of the domain to which the shared drive belongs', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['drive'], + operation: ['list'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const returnData: INodeExecutionData[] = []; + const options = this.getNodeParameter('options', i); + + const returnAll = this.getNodeParameter('returnAll', i); + + const qs: IDataObject = {}; + + let response: IDataObject[] = []; + + Object.assign(qs, options); + + if (returnAll) { + response = await googleApiRequestAllItems.call( + this, + 'GET', + 'drives', + '/drive/v3/drives', + {}, + qs, + ); + } else { + qs.pageSize = this.getNodeParameter('limit', i); + const data = await googleApiRequest.call(this, 'GET', '/drive/v3/drives', {}, qs); + response = data.drives as IDataObject[]; + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/update.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/update.operation.ts new file mode 100644 index 0000000000..04e51cfa06 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/update.operation.ts @@ -0,0 +1,116 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { sharedDriveRLC } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + ...sharedDriveRLC, + description: 'The shared drive to update', + }, + { + displayName: 'Update Fields', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['update'], + resource: ['drive'], + }, + }, + options: [ + { + displayName: 'Color RGB', + name: 'colorRgb', + type: 'color', + default: '', + description: 'The color of this shared drive as an RGB hex string', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'The updated name of the shared drive', + }, + { + displayName: 'Restrictions', + name: 'restrictions', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Admin Managed Restrictions', + name: 'adminManagedRestrictions', + type: 'boolean', + default: false, + description: + 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', + }, + { + displayName: 'Copy Requires Writer Permission', + name: 'copyRequiresWriterPermission', + type: 'boolean', + default: false, + description: + 'Whether the options to copy, print, or download files inside this shared drive, should be disabled for readers and commenters. When this restriction is set to true, it will override the similarly named field to true for any file inside this shared drive.', + }, + { + displayName: 'Domain Users Only', + name: 'domainUsersOnly', + type: 'boolean', + default: false, + description: + 'Whether access to this shared drive and items inside this shared drive is restricted to users of the domain to which this shared drive belongs. This restriction may be overridden by other sharing policies controlled outside of this shared drive.', + }, + { + displayName: 'Drive Members Only', + name: 'driveMembersOnly', + type: 'boolean', + default: false, + description: + 'Whether access to items inside this shared drive is restricted to its members', + }, + ], + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['drive'], + operation: ['update'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const returnData: INodeExecutionData[] = []; + const options = this.getNodeParameter('options', i, {}); + + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + const body: IDataObject = {}; + + Object.assign(body, options); + + const response = await googleApiRequest.call(this, 'PATCH', `/drive/v3/drives/${driveId}`, body); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/File.resource.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/File.resource.ts new file mode 100644 index 0000000000..2c7b34b3c3 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/File.resource.ts @@ -0,0 +1,85 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as copy from './copy.operation'; +import * as createFromText from './createFromText.operation'; +import * as deleteFile from './deleteFile.operation'; +import * as download from './download.operation'; +import * as move from './move.operation'; +import * as share from './share.operation'; +import * as update from './update.operation'; +import * as upload from './upload.operation'; + +export { copy, createFromText, deleteFile, download, move, share, update, upload }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['file'], + }, + }, + options: [ + { + name: 'Copy', + value: 'copy', + description: 'Create a copy of an existing file', + action: 'Copy file', + }, + { + name: 'Create From Text', + value: 'createFromText', + description: 'Create a file from a provided text', + action: 'Create file from text', + }, + { + name: 'Delete', + value: 'deleteFile', + description: 'Permanently delete a file', + action: 'Delete a file', + }, + { + name: 'Download', + value: 'download', + description: 'Download a file', + action: 'Download file', + }, + { + name: 'Move', + value: 'move', + description: 'Move a file to another folder', + action: 'Move file', + }, + { + name: 'Share', + value: 'share', + description: 'Add sharing permissions to a file', + action: 'Share file', + }, + { + name: 'Update', + value: 'update', + description: 'Update a file', + action: 'Update file', + }, + { + name: 'Upload', + value: 'upload', + description: 'Upload an existing file to Google Drive', + action: 'Upload file', + }, + ], + default: 'upload', + }, + ...copy.description, + ...deleteFile.description, + ...createFromText.description, + ...download.description, + ...move.description, + ...share.description, + ...update.description, + ...upload.description, +]; diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/copy.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/copy.operation.ts new file mode 100644 index 0000000000..20cad67780 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/copy.operation.ts @@ -0,0 +1,136 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { + IDataObject, + INodeExecutionData, + INodeParameterResourceLocator, + INodeProperties, +} from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { driveRLC, fileRLC, folderRLC } from '../common.descriptions'; +import { setParentFolder } from '../../helpers/utils'; + +const properties: INodeProperties[] = [ + { + ...fileRLC, + description: 'The file to copy', + }, + { + displayName: 'File Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. My File', + description: + 'The name of the new file. If not set, “Copy of {original file name}” will be used.', + }, + { + displayName: 'Copy In The Same Folder', + name: 'sameFolder', + type: 'boolean', + default: true, + description: 'Whether to copy the file in the same folder as the original file', + }, + { + ...driveRLC, + displayName: 'Parent Drive', + description: 'The drive where to save the copied file', + displayOptions: { show: { sameFolder: [false] } }, + }, + { + ...folderRLC, + displayName: 'Parent Folder', + description: 'The folder where to save the copied file', + displayOptions: { show: { sameFolder: [false] } }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Copy Requires Writer Permission', + name: 'copyRequiresWriterPermission', + type: 'boolean', + default: false, + description: + 'Whether the options to copy, print, or download this file, should be disabled for readers and commenters', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'A short description of the file', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['copy'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const file = this.getNodeParameter('fileId', i) as INodeParameterResourceLocator; + + const fileId = file.value; + + const options = this.getNodeParameter('options', i, {}); + + let name = this.getNodeParameter('name', i) as string; + name = name ? name : `Copy of ${file.cachedResultName}`; + + const copyRequiresWriterPermission = options.copyRequiresWriterPermission || false; + + const qs = { + includeItemsFromAllDrives: true, + supportsAllDrives: true, + spaces: 'appDataFolder, drive', + corpora: 'allDrives', + }; + + const parents: string[] = []; + const sameFolder = this.getNodeParameter('sameFolder', i) as boolean; + + if (!sameFolder) { + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + const folderId = this.getNodeParameter('folderId', i, undefined, { + extractValue: true, + }) as string; + + parents.push(setParentFolder(folderId, driveId)); + } + + const body: IDataObject = { copyRequiresWriterPermission, parents, name }; + + if (options.description) { + body.description = options.description; + } + + const response = await googleApiRequest.call( + this, + 'POST', + `/drive/v3/files/${fileId}/copy`, + body, + qs, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/createFromText.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/createFromText.operation.ts new file mode 100644 index 0000000000..2d109c51ca --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/createFromText.operation.ts @@ -0,0 +1,183 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { driveRLC, folderRLC, updateCommonOptions } from '../common.descriptions'; +import { googleApiRequest } from '../../transport'; +import { DRIVE } from '../../helpers/interfaces'; +import { setFileProperties, setParentFolder, setUpdateCommonParams } from '../../helpers/utils'; + +const properties: INodeProperties[] = [ + { + displayName: 'File Content', + name: 'content', + type: 'string', + default: '', + typeOptions: { + rows: 2, + }, + description: 'The text to create the file with', + }, + { + displayName: 'File Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. My New File', + description: + "The name of the file you want to create. If not specified, 'Untitled' will be used.", + }, + { + ...driveRLC, + displayName: 'Parent Drive', + required: false, + description: 'The drive where to create the new file', + }, + { + ...folderRLC, + displayName: 'Parent Folder', + required: false, + description: 'The folder where to create the new file', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + ...updateCommonOptions, + { + displayName: 'Convert to Google Document', + name: 'convertToGoogleDocument', + type: 'boolean', + default: false, + description: 'Whether to create a Google Document (instead of the .txt default format)', + hint: 'Google Docs API has to be enabled in the Google API Console.', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['createFromText'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const name = (this.getNodeParameter('name', i) as string) || 'Untitled'; + + const options = this.getNodeParameter('options', i, {}); + const convertToGoogleDocument = (options.convertToGoogleDocument as boolean) || false; + const mimeType = convertToGoogleDocument ? DRIVE.DOCUMENT : 'text/plain'; + + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + const folderId = this.getNodeParameter('folderId', i, undefined, { + extractValue: true, + }) as string; + + const bodyParameters = setFileProperties( + { + name, + parents: [setParentFolder(folderId, driveId)], + mimeType, + }, + options, + ); + + const boundary = 'XXXXXX'; + + const qs = setUpdateCommonParams( + { + includeItemsFromAllDrives: true, + supportsAllDrives: true, + spaces: 'appDataFolder, drive', + corpora: 'allDrives', + }, + options, + ); + + let response; + if (convertToGoogleDocument) { + const document = await googleApiRequest.call( + this, + 'POST', + '/drive/v3/files', + bodyParameters, + qs, + ); + + const text = this.getNodeParameter('content', i, '') as string; + + const body = { + requests: [ + { + insertText: { + text, + endOfSegmentLocation: { + segmentId: '', //empty segment ID signifies the document's body + }, + }, + }, + ], + }; + + const updateResponse = await googleApiRequest.call( + this, + 'POST', + '', + body, + undefined, + `https://docs.googleapis.com/v1/documents/${document.id}:batchUpdate`, + ); + + response = { id: updateResponse.documentId }; + } else { + const content = Buffer.from(this.getNodeParameter('content', i, '') as string, 'utf8'); + const contentLength = content.byteLength; + + const body = ` + \n--${boundary}\ + \nContent-Type: application/json; charset=UTF-8\ + \n\n${JSON.stringify(bodyParameters)}\ + \n--${boundary}\ + \nContent-Type: text/plain\ + \nContent-Transfer-Encoding: base64\ + \n\n${content}\ + \n--${boundary}--`; + + const responseData = await googleApiRequest.call( + this, + 'POST', + '/upload/drive/v3/files', + body, + { + uploadType: 'multipart', + ...qs, + }, + undefined, + { + headers: { + 'Content-Type': `multipart/related; boundary=${boundary}`, + 'Content-Length': contentLength, + }, + }, + ); + + response = { id: responseData.id }; + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject), + { itemData: { item: i } }, + ); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/deleteFile.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/deleteFile.operation.ts new file mode 100644 index 0000000000..3d76965e39 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/deleteFile.operation.ts @@ -0,0 +1,67 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { fileRLC } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + ...fileRLC, + description: 'The file to delete', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Delete Permanently', + name: 'deletePermanently', + type: 'boolean', + default: false, + description: + 'Whether to delete the file immediately. If false, the file will be moved to the trash.', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['deleteFile'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; + + const deletePermanently = this.getNodeParameter('options.deletePermanently', i, false) as boolean; + + const qs = { + supportsAllDrives: true, + }; + + if (deletePermanently) { + await googleApiRequest.call(this, 'DELETE', `/drive/v3/files/${fileId}`, undefined, qs); + } else { + await googleApiRequest.call(this, 'PATCH', `/drive/v3/files/${fileId}`, { trashed: true }, qs); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ + id: fileId, + success: true, + }), + { itemData: { item: i } }, + ); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/download.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/download.operation.ts new file mode 100644 index 0000000000..47341f5f30 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/download.operation.ts @@ -0,0 +1,284 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { + IBinaryKeyData, + IDataObject, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { fileRLC } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + ...fileRLC, + description: 'The file to download', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + placeholder: 'e.g. data', + default: 'data', + description: 'Use this field name in the following nodes, to use the binary file data', + hint: 'The name of the output field to put the binary file data in', + }, + { + displayName: 'Google File Conversion', + name: 'googleFileConversion', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + placeholder: 'Add Conversion', + options: [ + { + displayName: 'Conversion', + name: 'conversion', + values: [ + { + displayName: 'Google Docs', + name: 'docsToFormat', + type: 'options', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'HTML', + value: 'text/html', + }, + { + name: 'MS Word Document', + value: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + { + name: 'Open Office Document', + value: 'application/vnd.oasis.opendocument.text', + }, + { + name: 'PDF', + value: 'application/pdf', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'Rich Text (rtf)', + value: 'application/rtf', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'Text (txt)', + value: 'text/plain', + }, + ], + default: 'text/html', + description: 'Format used to export when downloading Google Docs files', + }, + { + displayName: 'Google Drawings', + name: 'drawingsToFormat', + type: 'options', + options: [ + { + name: 'JPEG', + value: 'image/jpeg', + }, + { + name: 'PDF', + value: 'application/pdf', + }, + { + name: 'PNG', + value: 'image/png', + }, + { + name: 'SVG', + value: 'image/svg+xml', + }, + ], + default: 'image/jpeg', + description: 'Format used to export when downloading Google Drawings files', + }, + { + displayName: 'Google Slides', + name: 'slidesToFormat', + type: 'options', + options: [ + { + name: 'MS PowerPoint', + value: + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + }, + { + name: 'OpenOffice Presentation', + value: 'application/vnd.oasis.opendocument.presentation', + }, + { + name: 'PDF', + value: 'application/pdf', + }, + ], + default: + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + description: 'Format used to export when downloading Google Slides files', + }, + { + displayName: 'Google Sheets', + name: 'sheetsToFormat', + type: 'options', + options: [ + { + name: 'CSV', + value: 'text/csv', + }, + { + name: 'MS Excel', + value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + { + name: 'Open Office Sheet', + value: 'application/vnd.oasis.opendocument.spreadsheet', + }, + { + name: 'PDF', + value: 'application/pdf', + }, + ], + default: 'text/csv', + description: 'Format used to export when downloading Google Sheets files', + }, + ], + }, + ], + }, + { + displayName: 'File Name', + name: 'fileName', + type: 'string', + default: '', + description: 'File name. Ex: data.pdf.', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['download'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + i: number, + item: INodeExecutionData, +): Promise { + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; + + const downloadOptions = this.getNodeParameter('options', i); + + const requestOptions = { + useStream: true, + resolveWithFullResponse: true, + encoding: null, + json: false, + }; + + const file = await googleApiRequest.call( + this, + 'GET', + `/drive/v3/files/${fileId}`, + {}, + { fields: 'mimeType,name', supportsTeamDrives: true }, + ); + let response; + + if (file.mimeType?.includes('vnd.google-apps')) { + const parameterKey = 'options.googleFileConversion.conversion'; + const type = file.mimeType.split('.')[2]; + let mime; + if (type === 'document') { + mime = this.getNodeParameter( + `${parameterKey}.docsToFormat`, + i, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) as string; + } else if (type === 'presentation') { + mime = this.getNodeParameter( + `${parameterKey}.slidesToFormat`, + i, + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ) as string; + } else if (type === 'spreadsheet') { + mime = this.getNodeParameter( + `${parameterKey}.sheetsToFormat`, + i, + 'application/x-vnd.oasis.opendocument.spreadsheet', + ) as string; + } else { + mime = this.getNodeParameter(`${parameterKey}.drawingsToFormat`, i, 'image/jpeg') as string; + } + response = await googleApiRequest.call( + this, + 'GET', + `/drive/v3/files/${fileId}/export`, + {}, + { mimeType: mime }, + undefined, + requestOptions, + ); + } else { + response = await googleApiRequest.call( + this, + 'GET', + `/drive/v3/files/${fileId}`, + {}, + { alt: 'media' }, + undefined, + requestOptions, + ); + } + + const mimeType = + (response.headers as IDataObject)?.['content-type'] ?? file.mimeType ?? undefined; + const fileName = downloadOptions.fileName ?? file.name ?? undefined; + + const newItem: INodeExecutionData = { + json: item.json, + binary: {}, + }; + + if (item.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 as IBinaryKeyData, item.binary); + } + + item = newItem; + + const dataPropertyNameDownload = (downloadOptions.binaryPropertyName as string) || 'data'; + + item.binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData( + response.body as Buffer, + fileName as string, + mimeType as string, + ); + + const executionData = this.helpers.constructExecutionMetaData([item], { itemData: { item: i } }); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/move.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/move.operation.ts new file mode 100644 index 0000000000..e2b4aac1d5 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/move.operation.ts @@ -0,0 +1,84 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { driveRLC, fileRLC, folderRLC } from '../common.descriptions'; +import { googleApiRequest } from '../../transport'; +import { setParentFolder } from '../../helpers/utils'; + +const properties: INodeProperties[] = [ + { + ...fileRLC, + description: 'The file to move', + }, + { + ...driveRLC, + displayName: 'Parent Drive', + description: 'The drive where to move the file', + }, + { + ...folderRLC, + displayName: 'Parent Folder', + description: 'The folder where to move the file', + }, +]; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['move'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }); + + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + const folderId = this.getNodeParameter('folderId', i, undefined, { + extractValue: true, + }) as string; + + const qs = { + includeItemsFromAllDrives: true, + supportsAllDrives: true, + spaces: 'appDataFolder, drive', + corpora: 'allDrives', + }; + + const { parents } = await googleApiRequest.call( + this, + 'GET', + `/drive/v3/files/${fileId}`, + undefined, + { + ...qs, + fields: 'parents', + }, + ); + + const response = await googleApiRequest.call( + this, + 'PATCH', + `/drive/v3/files/${fileId}`, + undefined, + { + ...qs, + addParents: setParentFolder(folderId, driveId), + removeParents: ((parents as string[]) || []).join(','), + }, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/share.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/share.operation.ts new file mode 100644 index 0000000000..9db3dd3882 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/share.operation.ts @@ -0,0 +1,64 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { fileRLC, permissionsOptions, shareOptions } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + ...fileRLC, + description: 'The file to share', + }, + permissionsOptions, + shareOptions, +]; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['share'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const returnData: INodeExecutionData[] = []; + + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; + + const permissions = this.getNodeParameter('permissionsUi', i) as IDataObject; + + const shareOption = this.getNodeParameter('options', i); + + const body: IDataObject = {}; + + const qs: IDataObject = { + supportsAllDrives: true, + }; + + if (permissions.permissionsValues) { + Object.assign(body, permissions.permissionsValues); + } + + Object.assign(qs, shareOption); + + const response = await googleApiRequest.call( + this, + 'POST', + `/drive/v3/files/${fileId}/permissions`, + body, + qs, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/update.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/update.operation.ts new file mode 100644 index 0000000000..3e5c8a595a --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/update.operation.ts @@ -0,0 +1,274 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { + getItemBinaryData, + prepareQueryString, + setFileProperties, + setUpdateCommonParams, +} from '../../helpers/utils'; +import { googleApiRequest } from '../../transport'; +import { fileRLC, updateCommonOptions } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + ...fileRLC, + displayName: 'File to Update', + description: 'The file to update', + }, + { + displayName: 'Change File Content', + name: 'changeFileContent', + type: 'boolean', + default: false, + description: 'Whether to send a new binary data to update the file', + }, + { + displayName: 'Input Data Field Name', + name: 'inputDataFieldName', + type: 'string', + placeholder: 'e.g. data', + default: 'data', + hint: 'The name of the input field containing the binary file data to update the file', + description: + 'Find the name of input field containing the binary data to update the file in the Input panel on the left, in the Binary tab', + displayOptions: { + show: { + changeFileContent: [true], + }, + }, + }, + { + displayName: 'New Updated File Name', + name: 'newUpdatedFileName', + type: 'string', + default: '', + placeholder: 'e.g. My New File', + description: 'If not specified, the file name will not be changed', + }, + + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + ...updateCommonOptions, + { + displayName: 'Move to Trash', + name: 'trashed', + type: 'boolean', + default: false, + description: 'Whether to move a file to the trash. Only the owner may trash a file.', + }, + { + displayName: 'Return Fields', + name: 'fields', + type: 'multiOptions', + options: [ + { + name: '[All]', + value: '*', + description: 'All fields', + }, + { + name: 'explicitlyTrashed', + value: 'explicitlyTrashed', + }, + { + name: 'exportLinks', + value: 'exportLinks', + }, + { + name: 'hasThumbnail', + value: 'hasThumbnail', + }, + { + name: 'iconLink', + value: 'iconLink', + }, + { + name: 'ID', + value: 'id', + }, + { + name: 'Kind', + value: 'kind', + }, + { + name: 'mimeType', + value: 'mimeType', + }, + { + name: 'Name', + value: 'name', + }, + { + name: 'Permissions', + value: 'permissions', + }, + { + name: 'Shared', + value: 'shared', + }, + { + name: 'Spaces', + value: 'spaces', + }, + { + name: 'Starred', + value: 'starred', + }, + { + name: 'thumbnailLink', + value: 'thumbnailLink', + }, + { + name: 'Trashed', + value: 'trashed', + }, + { + name: 'Version', + value: 'version', + }, + { + name: 'webViewLink', + value: 'webViewLink', + }, + ], + default: [], + description: 'The fields to return', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['update'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const fileId = this.getNodeParameter('fileId', i, undefined, { + extractValue: true, + }) as string; + + const changeFileContent = this.getNodeParameter('changeFileContent', i, false) as boolean; + + let mimeType; + + // update file binary data + if (changeFileContent) { + const inputDataFieldName = this.getNodeParameter('inputDataFieldName', i) as string; + + const binaryData = await getItemBinaryData.call(this, inputDataFieldName, i); + + const { contentLength, fileContent } = binaryData; + mimeType = binaryData.mimeType; + + if (Buffer.isBuffer(fileContent)) { + await googleApiRequest.call( + this, + 'PATCH', + `/upload/drive/v3/files/${fileId}`, + fileContent, + { + uploadType: 'media', + }, + undefined, + { + headers: { + 'Content-Type': mimeType, + 'Content-Length': contentLength, + }, + }, + ); + } else { + const resumableUpload = await googleApiRequest.call( + this, + 'PATCH', + `/upload/drive/v3/files/${fileId}`, + undefined, + { uploadType: 'resumable' }, + undefined, + { + resolveWithFullResponse: true, + }, + ); + const uploadUrl = resumableUpload.headers.location; + + let offset = 0; + for await (const chunk of fileContent) { + const nextOffset = offset + Number(chunk.length); + try { + await this.helpers.httpRequest({ + method: 'PUT', + url: uploadUrl, + headers: { + 'Content-Length': chunk.length, + 'Content-Range': `bytes ${offset}-${nextOffset - 1}/${contentLength}`, + }, + body: chunk, + }); + } catch (error) { + if (error.response?.status !== 308) { + throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i }); + } + } + offset = nextOffset; + } + } + } + + const options = this.getNodeParameter('options', i, {}); + + const qs: IDataObject = setUpdateCommonParams( + { + supportsAllDrives: true, + }, + options, + ); + + if (options.fields) { + const queryFields = prepareQueryString(options.fields as string[]); + qs.fields = queryFields; + } + + if (options.trashed) { + qs.trashed = options.trashed; + } + + const body: IDataObject = setFileProperties({}, options); + + const newUpdatedFileName = this.getNodeParameter('newUpdatedFileName', i, '') as string; + if (newUpdatedFileName) { + body.name = newUpdatedFileName; + } + + if (mimeType) { + body.mimeType = mimeType; + } + + // update file metadata + const responseData = await googleApiRequest.call( + this, + 'PATCH', + `/drive/v3/files/${fileId}`, + body, + qs, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/upload.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/upload.operation.ts new file mode 100644 index 0000000000..0ed1107d96 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/upload.operation.ts @@ -0,0 +1,189 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { driveRLC, folderRLC, updateCommonOptions } from '../common.descriptions'; +import { + getItemBinaryData, + setFileProperties, + setUpdateCommonParams, + setParentFolder, +} from '../../helpers/utils'; + +const properties: INodeProperties[] = [ + { + displayName: 'Input Data Field Name', + name: 'inputDataFieldName', + type: 'string', + placeholder: '“e.g. data', + default: 'data', + required: true, + hint: 'The name of the input field containing the binary file data to update the file', + description: + 'Find the name of input field containing the binary data to update the file in the Input panel on the left, in the Binary tab', + }, + { + displayName: 'File Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. My New File', + description: 'If not specified, the original file name will be used', + }, + { + ...driveRLC, + displayName: 'Parent Drive', + description: 'The drive where to upload the file', + }, + { + ...folderRLC, + displayName: 'Parent Folder', + description: 'The folder where to upload the file', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + ...updateCommonOptions, + { + displayName: 'Simplify Output', + name: 'simplifyOutput', + type: 'boolean', + default: true, + description: 'Whether to return a simplified version of the response instead of all fields', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['upload'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const returnData: INodeExecutionData[] = []; + + const inputDataFieldName = this.getNodeParameter('inputDataFieldName', i) as string; + + const { contentLength, fileContent, originalFilename, mimeType } = await getItemBinaryData.call( + this, + inputDataFieldName, + i, + ); + + const name = (this.getNodeParameter('name', i) as string) || originalFilename; + + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + const folderId = this.getNodeParameter('folderId', i, undefined, { + extractValue: true, + }) as string; + + let uploadId; + if (Buffer.isBuffer(fileContent)) { + const response = await googleApiRequest.call( + this, + 'POST', + '/upload/drive/v3/files', + fileContent, + { + uploadType: 'media', + }, + undefined, + { + headers: { + 'Content-Type': mimeType, + 'Content-Length': contentLength, + }, + }, + ); + + uploadId = response.id; + } else { + const resumableUpload = await googleApiRequest.call( + this, + 'POST', + '/upload/drive/v3/files', + undefined, + { uploadType: 'resumable' }, + undefined, + { + resolveWithFullResponse: true, + }, + ); + const uploadUrl = resumableUpload.headers.location; + + let offset = 0; + for await (const chunk of fileContent) { + const nextOffset = offset + Number(chunk.length); + try { + const response = await this.helpers.httpRequest({ + method: 'PUT', + url: uploadUrl, + headers: { + 'Content-Length': chunk.length, + 'Content-Range': `bytes ${offset}-${nextOffset - 1}/${contentLength}`, + }, + body: chunk, + }); + uploadId = response?.id; + } catch (error) { + if (error.response?.status !== 308) throw error; + } + offset = nextOffset; + } + } + + const options = this.getNodeParameter('options', i, {}); + + const qs = setUpdateCommonParams( + { + addParents: setParentFolder(folderId, driveId), + includeItemsFromAllDrives: true, + supportsAllDrives: true, + spaces: 'appDataFolder, drive', + corpora: 'allDrives', + }, + options, + ); + + if (!options.simplifyOutput) { + qs.fields = '*'; + } + + const body = setFileProperties( + { + mimeType, + name, + originalFilename, + }, + options, + ); + + const response = await googleApiRequest.call( + this, + 'PATCH', + `/drive/v3/files/${uploadId}`, + body, + qs, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/fileFolder/FileFolder.resource.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/fileFolder/FileFolder.resource.ts new file mode 100644 index 0000000000..94b3840762 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/fileFolder/FileFolder.resource.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as search from './search.operation'; + +export { search }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['fileFolder'], + }, + }, + options: [ + { + name: 'Search', + value: 'search', + description: 'Search or list files and folders', + action: 'Search files and folders', + }, + ], + default: 'search', + }, + ...search.description, +]; diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/fileFolder/search.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/fileFolder/search.operation.ts new file mode 100644 index 0000000000..597b15cf72 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/fileFolder/search.operation.ts @@ -0,0 +1,359 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { driveRLC, fileTypesOptions, folderRLC } from '../common.descriptions'; +import { googleApiRequest, googleApiRequestAllItems } from '../../transport'; +import { prepareQueryString, updateDriveScopes } from '../../helpers/utils'; +import type { SearchFilter } from '../../helpers/interfaces'; +import { DRIVE, RLC_FOLDER_DEFAULT } from '../../helpers/interfaces'; + +const properties: INodeProperties[] = [ + { + displayName: 'Search Method', + name: 'searchMethod', + type: 'options', + options: [ + { + name: 'Search File/Folder Name', + value: 'name', + }, + { + name: 'Advanced Search', + value: 'query', + }, + ], + default: 'name', + description: 'Whether to search for the file/folder name or use a query string', + }, + { + displayName: 'Search Query', + name: 'queryString', + type: 'string', + default: '', + displayOptions: { + show: { + searchMethod: ['name'], + }, + }, + placeholder: 'e.g. My File / My Folder', + description: + 'The name of the file or folder to search for. Returns also files and folders whose names partially match this search term.', + }, + { + displayName: 'Query String', + name: 'queryString', + type: 'string', + default: '', + displayOptions: { + show: { + searchMethod: ['query'], + }, + }, + placeholder: "e.g. not name contains 'hello'", + description: + 'Use the Google query strings syntax to search for a specific set of files or folders. Learn more.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'Max number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + returnAll: [false], + }, + }, + }, + { + displayName: 'Filter', + name: 'filter', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + options: [ + { + ...driveRLC, + description: + 'The drive you want to search in. By default, the personal "My Drive" is used.', + required: false, + }, + { + ...folderRLC, + description: + 'The folder you want to search in. By default, the root folder of the drive is used. If you select a folder other than the root folder, only the direct children will be included.', + required: false, + }, + { + displayName: 'What to Search', + name: 'whatToSearch', + type: 'options', + default: 'all', + options: [ + { + name: 'Files and Folders', + value: 'all', + }, + { + name: 'Files', + value: 'files', + }, + { + name: 'Folders', + value: 'folders', + }, + ], + }, + { + displayName: 'File Types', + name: 'fileTypes', + type: 'multiOptions', + default: [], + description: 'Return only items corresponding to the selected MIME types', + options: fileTypesOptions, + displayOptions: { + show: { + whatToSearch: ['all'], + }, + }, + }, + { + displayName: 'File Types', + name: 'fileTypes', + type: 'multiOptions', + default: [], + description: 'Return only items corresponding to the selected MIME types', + options: fileTypesOptions.filter((option) => option.name !== 'Folder'), + displayOptions: { + show: { + whatToSearch: ['files'], + }, + }, + }, + { + displayName: 'Include Trashed Items', + name: 'includeTrashed', + type: 'boolean', + default: false, + description: "Whether to return also items in the Drive's bin", + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Fields', + name: 'fields', + type: 'multiOptions', + options: [ + { + name: '*', + value: '*', + description: 'All fields', + }, + { + name: 'explicitlyTrashed', + value: 'explicitlyTrashed', + }, + { + name: 'exportLinks', + value: 'exportLinks', + }, + { + name: 'hasThumbnail', + value: 'hasThumbnail', + }, + { + name: 'iconLink', + value: 'iconLink', + }, + { + name: 'ID', + value: 'id', + }, + { + name: 'Kind', + value: 'kind', + }, + { + name: 'mimeType', + value: 'mimeType', + }, + { + name: 'Name', + value: 'name', + }, + { + name: 'Permissions', + value: 'permissions', + }, + { + name: 'Shared', + value: 'shared', + }, + { + name: 'Spaces', + value: 'spaces', + }, + { + name: 'Starred', + value: 'starred', + }, + { + name: 'thumbnailLink', + value: 'thumbnailLink', + }, + { + name: 'Trashed', + value: 'trashed', + }, + { + name: 'Version', + value: 'version', + }, + { + name: 'webViewLink', + value: 'webViewLink', + }, + ], + default: [], + description: 'The fields to return', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['fileFolder'], + operation: ['search'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const searchMethod = this.getNodeParameter('searchMethod', i) as string; + const options = this.getNodeParameter('options', i, {}); + + const query = []; + + const queryString = this.getNodeParameter('queryString', i) as string; + + if (searchMethod === 'name') { + query.push(`name contains '${queryString}'`); + } else { + query.push(queryString); + } + + const filter = this.getNodeParameter('filter', i, {}) as SearchFilter; + + let driveId = ''; + let folderId = ''; + const returnedTypes: string[] = []; + + if (Object.keys(filter)?.length) { + if (filter.folderId) { + if (filter.folderId.mode === 'url') { + folderId = this.getNodeParameter('filter.folderId', i, undefined, { + extractValue: true, + }) as string; + } else { + folderId = filter.folderId.value; + } + } + + if (folderId && folderId !== RLC_FOLDER_DEFAULT) { + query.push(`'${folderId}' in parents`); + } + + if (filter.driveId) { + let value; + if (filter.driveId.mode === 'url') { + value = this.getNodeParameter('filter.driveId', i, undefined, { + extractValue: true, + }) as string; + } else { + value = filter.driveId.value; + } + driveId = value; + } + + const whatToSearch = filter.whatToSearch || 'all'; + if (whatToSearch === 'folders') { + query.push(`mimeType = '${DRIVE.FOLDER}'`); + } else { + if (whatToSearch === 'files') { + query.push(`mimeType != '${DRIVE.FOLDER}'`); + } + + if (filter?.fileTypes?.length && !filter.fileTypes.includes('*')) { + filter.fileTypes.forEach((fileType: string) => { + returnedTypes.push(`mimeType = '${fileType}'`); + }); + } + } + + if (!filter.includeTrashed) { + query.push('trashed = false'); + } + } + + if (returnedTypes.length) { + query.push(`(${returnedTypes.join(' or ')})`); + } + + const queryFields = prepareQueryString(options.fields as string[]); + + const qs: IDataObject = { + fields: `nextPageToken, files(${queryFields})`, + q: query.filter((q) => q).join(' and '), + includeItemsFromAllDrives: true, + supportsAllDrives: true, + spaces: 'appDataFolder, drive', + corpora: 'allDrives', + }; + + updateDriveScopes(qs, driveId); + + if (!driveId && folderId === RLC_FOLDER_DEFAULT) { + qs.corpora = 'user'; + qs.spaces = 'drive'; + qs.includeItemsFromAllDrives = false; + qs.supportsAllDrives = false; + } + + const returnAll = this.getNodeParameter('returnAll', i, false); + + let response; + if (returnAll) { + response = await googleApiRequestAllItems.call(this, 'GET', 'files', '/drive/v3/files', {}, qs); + } else { + qs.pageSize = this.getNodeParameter('limit', i); + response = await googleApiRequest.call(this, 'GET', '/drive/v3/files', undefined, qs); + response = response.files; + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/Folder.resource.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/Folder.resource.ts new file mode 100644 index 0000000000..370e7188b0 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/Folder.resource.ts @@ -0,0 +1,45 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as create from './create.operation'; +import * as deleteFolder from './deleteFolder.operation'; +import * as share from './share.operation'; + +export { create, deleteFolder, share }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['folder'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a folder', + action: 'Create folder', + }, + { + name: 'Delete', + value: 'deleteFolder', + description: 'Permanently delete a folder', + action: 'Delete folder', + }, + { + name: 'Share', + value: 'share', + description: 'Add sharing permissions to a folder', + action: 'Share folder', + }, + ], + default: 'create', + }, + ...create.description, + ...deleteFolder.description, + ...share.description, +]; diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/create.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/create.operation.ts new file mode 100644 index 0000000000..8b2165bf32 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/create.operation.ts @@ -0,0 +1,109 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { driveRLC, folderRLC } from '../common.descriptions'; +import { DRIVE } from '../../helpers/interfaces'; +import { setParentFolder } from '../../helpers/utils'; + +const properties: INodeProperties[] = [ + { + displayName: 'Folder Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. New Folder', + description: "The name of the new folder. If not set, 'Untitled' will be used.", + }, + { + ...driveRLC, + displayName: 'Parent Drive', + description: 'The drive where to create the new folder', + }, + { + ...folderRLC, + displayName: 'Parent Folder', + description: 'The parent folder where to create the new folder', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Simplify Output', + name: 'simplifyOutput', + type: 'boolean', + default: true, + description: 'Whether to return a simplified version of the response instead of all fields', + }, + { + displayName: 'Folder Color', + name: 'folderColorRgb', + type: 'color', + default: '', + description: + 'The color of the folder as an RGB hex string. If an unsupported color is specified, the closest color in the palette will be used instead.', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['folder'], + operation: ['create'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const name = (this.getNodeParameter('name', i) as string) || 'Untitled'; + + const driveId = this.getNodeParameter('driveId', i, undefined, { + extractValue: true, + }) as string; + + const folderId = this.getNodeParameter('folderId', i, undefined, { + extractValue: true, + }) as string; + + const body: IDataObject = { + name, + mimeType: DRIVE.FOLDER, + parents: [setParentFolder(folderId, driveId)], + }; + + const folderColorRgb = + (this.getNodeParameter('options.folderColorRgb', i, '') as string) || undefined; + if (folderColorRgb) { + body.folderColorRgb = folderColorRgb; + } + + const simplifyOutput = this.getNodeParameter('options.simplifyOutput', i, true) as boolean; + let fields; + if (!simplifyOutput) { + fields = '*'; + } + + const qs = { + fields, + includeItemsFromAllDrives: true, + supportsAllDrives: true, + spaces: 'appDataFolder, drive', + corpora: 'allDrives', + }; + + const response = await googleApiRequest.call(this, 'POST', '/drive/v3/files', body, qs); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/deleteFolder.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/deleteFolder.operation.ts new file mode 100644 index 0000000000..d6dc077009 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/deleteFolder.operation.ts @@ -0,0 +1,77 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { folderNoRootRLC } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + ...folderNoRootRLC, + description: 'The folder to delete', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Delete Permanently', + name: 'deletePermanently', + type: 'boolean', + default: false, + description: + 'Whether to delete the folder immediately. If false, the folder will be moved to the trash.', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['folder'], + operation: ['deleteFolder'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const returnData: INodeExecutionData[] = []; + + const folderId = this.getNodeParameter('folderNoRootId', i, undefined, { + extractValue: true, + }) as string; + + const deletePermanently = this.getNodeParameter('options.deletePermanently', i, false) as boolean; + + const qs = { + supportsAllDrives: true, + }; + + if (deletePermanently) { + await googleApiRequest.call(this, 'DELETE', `/drive/v3/files/${folderId}`, undefined, qs); + } else { + await googleApiRequest.call( + this, + 'PATCH', + `/drive/v3/files/${folderId}`, + { trashed: true }, + qs, + ); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ + fileId: folderId, + success: true, + }), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/share.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/share.operation.ts new file mode 100644 index 0000000000..e597ec3919 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/folder/share.operation.ts @@ -0,0 +1,64 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../../utils/utilities'; +import { googleApiRequest } from '../../transport'; +import { folderNoRootRLC, permissionsOptions, shareOptions } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + ...folderNoRootRLC, + description: 'The folder to share', + }, + permissionsOptions, + shareOptions, +]; + +const displayOptions = { + show: { + resource: ['folder'], + operation: ['share'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const returnData: INodeExecutionData[] = []; + + const folderId = this.getNodeParameter('folderNoRootId', i, undefined, { + extractValue: true, + }) as string; + + const permissions = this.getNodeParameter('permissionsUi', i) as IDataObject; + + const shareOption = this.getNodeParameter('options', i); + + const body: IDataObject = {}; + + const qs: IDataObject = { + supportsAllDrives: true, + }; + + if (permissions.permissionsValues) { + Object.assign(body, permissions.permissionsValues); + } + + Object.assign(qs, shareOption); + + const response = await googleApiRequest.call( + this, + 'POST', + `/drive/v3/files/${folderId}/permissions`, + body, + qs, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/node.type.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/node.type.ts new file mode 100644 index 0000000000..61a7b40834 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/node.type.ts @@ -0,0 +1,18 @@ +import type { AllEntities } from 'n8n-workflow'; + +type NodeMap = { + drive: 'create' | 'deleteDrive' | 'get' | 'list' | 'update'; + file: + | 'copy' + | 'createFromText' + | 'download' + | 'deleteFile' + | 'move' + | 'share' + | 'upload' + | 'update'; + folder: 'create' | 'deleteFolder' | 'share'; + fileFolder: 'search'; +}; + +export type GoogleDriveType = AllEntities; diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/router.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/router.ts new file mode 100644 index 0000000000..bb369933bd --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/router.ts @@ -0,0 +1,55 @@ +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import type { GoogleDriveType } from './node.type'; + +import * as drive from './drive/Drive.resource'; +import * as file from './file/File.resource'; +import * as fileFolder from './fileFolder/FileFolder.resource'; +import * as folder from './folder/Folder.resource'; + +export async function router(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + + const googleDrive = { + resource, + operation, + } as GoogleDriveType; + + for (let i = 0; i < items.length; i++) { + try { + switch (googleDrive.resource) { + case 'drive': + returnData.push(...(await drive[googleDrive.operation].execute.call(this, i))); + break; + case 'file': + returnData.push(...(await file[googleDrive.operation].execute.call(this, i, items[i]))); + break; + case 'fileFolder': + returnData.push(...(await fileFolder[googleDrive.operation].execute.call(this, i))); + break; + case 'folder': + returnData.push(...(await folder[googleDrive.operation].execute.call(this, i))); + break; + default: + throw new NodeOperationError(this.getNode(), `The resource "${resource}" is not known`); + } + } catch (error) { + if (this.continueOnFail()) { + if (resource === 'file' && operation === 'download') { + items[i].json = { error: error.message }; + } else { + returnData.push({ json: { error: error.message } }); + } + continue; + } + throw error; + } + } + + return this.prepareOutputData(returnData); +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/versionDescription.ts new file mode 100644 index 0000000000..7659ddf551 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/versionDescription.ts @@ -0,0 +1,90 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { INodeTypeDescription } from 'n8n-workflow'; + +import * as drive from './drive/Drive.resource'; +import * as file from './file/File.resource'; +import * as fileFolder from './fileFolder/FileFolder.resource'; +import * as folder from './folder/Folder.resource'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Google Drive', + name: 'googleDrive', + icon: 'file:googleDrive.svg', + group: ['input'], + version: 3, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Access data on Google Drive', + defaults: { + name: 'Google Drive', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleApi', + required: true, + displayOptions: { + show: { + authentication: ['serviceAccount'], + }, + }, + }, + { + name: 'googleDriveOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'OAuth2 (recommended)', + value: 'oAuth2', + }, + { + name: 'Service Account', + value: 'serviceAccount', + }, + ], + default: 'oAuth2', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'File', + value: 'file', + }, + { + name: 'File/Folder', + value: 'fileFolder', + }, + { + name: 'Folder', + value: 'folder', + }, + { + name: 'Shared Drive', + value: 'drive', + }, + ], + default: 'file', + }, + ...drive.description, + ...file.description, + ...fileFolder.description, + ...folder.description, + ], +}; diff --git a/packages/nodes-base/nodes/Google/Drive/v2/helpers/interfaces.ts b/packages/nodes-base/nodes/Google/Drive/v2/helpers/interfaces.ts new file mode 100644 index 0000000000..8da73ac857 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/helpers/interfaces.ts @@ -0,0 +1,37 @@ +export const UPLOAD_CHUNK_SIZE = 256 * 1024; + +export type SearchFilter = { + driveId?: { + value: string; + mode: string; + }; + folderId?: { + value: string; + mode: string; + }; + whatToSearch?: 'all' | 'files' | 'folders'; + fileTypes?: string[]; + includeTrashed?: boolean; +}; + +export const RLC_DRIVE_DEFAULT = 'My Drive'; +export const RLC_FOLDER_DEFAULT = 'root'; + +export const enum DRIVE { + FOLDER = 'application/vnd.google-apps.folder', + AUDIO = 'application/vnd.google-apps.audio', + DOCUMENT = 'application/vnd.google-apps.document', + SDK = 'application/vnd.google-apps.drive-sdk', + DRAWING = 'application/vnd.google-apps.drawing', + FILE = 'application/vnd.google-apps.file', + FORM = 'application/vnd.google-apps.form', + FUSIONTABLE = 'application/vnd.google-apps.fusiontable', + MAP = 'application/vnd.google-apps.map', + PHOTO = 'application/vnd.google-apps.photo', + PRESENTATION = 'application/vnd.google-apps.presentation', + APP_SCRIPTS = 'application/vnd.google-apps.script', + SITES = 'application/vnd.google-apps.sites', + SPREADSHEET = 'application/vnd.google-apps.spreadsheet', + UNKNOWN = 'application/vnd.google-apps.unknown', + VIDEO = 'application/vnd.google-apps.video', +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/helpers/utils.ts b/packages/nodes-base/nodes/Google/Drive/v2/helpers/utils.ts new file mode 100644 index 0000000000..76df7b01f9 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/helpers/utils.ts @@ -0,0 +1,133 @@ +import type { IDataObject, IExecuteFunctions } from 'n8n-workflow'; +import { BINARY_ENCODING, NodeOperationError } from 'n8n-workflow'; + +import type { Readable } from 'stream'; +import { RLC_DRIVE_DEFAULT, RLC_FOLDER_DEFAULT, UPLOAD_CHUNK_SIZE } from './interfaces'; + +export function prepareQueryString(fields: string[] | undefined) { + let queryFields = 'id, name'; + if (fields) { + if (fields.includes('*')) { + queryFields = '*'; + } else { + queryFields = fields.join(', '); + } + } + return queryFields; +} + +export async function getItemBinaryData( + this: IExecuteFunctions, + inputDataFieldName: string, + i: number, + chunkSize = UPLOAD_CHUNK_SIZE, +) { + let contentLength: number; + let fileContent: Buffer | Readable; + let originalFilename: string | undefined; + let mimeType; + + if (!inputDataFieldName) { + throw new NodeOperationError( + this.getNode(), + 'The name of the input field containing the binary file data must be set', + { + itemIndex: i, + }, + ); + } + const binaryData = this.helpers.assertBinaryData(i, inputDataFieldName); + + if (binaryData.id) { + // Stream data in 256KB chunks, and upload the via the resumable upload api + fileContent = this.helpers.getBinaryStream(binaryData.id, chunkSize); + const metadata = await this.helpers.getBinaryMetadata(binaryData.id); + contentLength = metadata.fileSize; + originalFilename = metadata.fileName; + if (metadata.mimeType) mimeType = binaryData.mimeType; + } else { + fileContent = Buffer.from(binaryData.data, BINARY_ENCODING); + contentLength = fileContent.length; + originalFilename = binaryData.fileName; + mimeType = binaryData.mimeType; + } + + return { + contentLength, + fileContent, + originalFilename, + mimeType, + }; +} + +export function setFileProperties(body: IDataObject, options: IDataObject) { + if (options.propertiesUi) { + const values = ((options.propertiesUi as IDataObject).propertyValues as IDataObject[]) || []; + + body.properties = values.reduce( + (acc, value) => Object.assign(acc, { [`${value.key}`]: value.value }), + {} as IDataObject, + ); + } + + if (options.appPropertiesUi) { + const values = + ((options.appPropertiesUi as IDataObject).appPropertyValues as IDataObject[]) || []; + + body.appProperties = values.reduce( + (acc, value) => Object.assign(acc, { [`${value.key}`]: value.value }), + {} as IDataObject, + ); + } + + return body; +} + +export function setUpdateCommonParams(qs: IDataObject, options: IDataObject) { + if (options.keepRevisionForever) { + qs.keepRevisionForever = options.keepRevisionForever; + } + + if (options.ocrLanguage) { + qs.ocrLanguage = options.ocrLanguage; + } + + if (options.useContentAsIndexableText) { + qs.useContentAsIndexableText = options.useContentAsIndexableText; + } + + return qs; +} + +export function updateDriveScopes( + qs: IDataObject, + driveId: string, + defaultDrive = RLC_DRIVE_DEFAULT, +) { + if (driveId) { + if (driveId === defaultDrive) { + qs.includeItemsFromAllDrives = false; + qs.supportsAllDrives = false; + qs.spaces = 'appDataFolder, drive'; + qs.corpora = 'user'; + } else { + qs.driveId = driveId; + qs.corpora = 'drive'; + } + } +} + +export function setParentFolder( + folderId: string, + driveId: string, + folderIdDefault = RLC_FOLDER_DEFAULT, + driveIdDefault = RLC_DRIVE_DEFAULT, +) { + if (folderId !== folderIdDefault) { + return folderId; + } else if (driveId && driveId !== driveIdDefault) { + return driveId; + } else { + return 'root'; + } +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/methods/index.ts b/packages/nodes-base/nodes/Google/Drive/v2/methods/index.ts new file mode 100644 index 0000000000..c7fb720e47 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/methods/index.ts @@ -0,0 +1 @@ +export * as listSearch from './listSearch'; diff --git a/packages/nodes-base/nodes/Google/Drive/v2/methods/listSearch.ts b/packages/nodes-base/nodes/Google/Drive/v2/methods/listSearch.ts new file mode 100644 index 0000000000..8c634e2699 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/methods/listSearch.ts @@ -0,0 +1,199 @@ +import type { + IDataObject, + ILoadOptionsFunctions, + INodeListSearchItems, + INodeListSearchResult, +} from 'n8n-workflow'; +import { googleApiRequest } from '../transport'; +import type { SearchFilter } from '../helpers/interfaces'; +import { DRIVE, RLC_DRIVE_DEFAULT, RLC_FOLDER_DEFAULT } from '../helpers/interfaces'; +import { updateDriveScopes } from '../helpers/utils'; + +interface FilesItem { + id: string; + name: string; + mimeType: string; + webViewLink: string; +} + +interface DriveItem { + id: string; + name: string; +} + +export async function fileSearch( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const query: string[] = ['trashed = false']; + if (filter) { + query.push(`name contains '${filter.replace("'", "\\'")}'`); + } + query.push(`mimeType != '${DRIVE.FOLDER}'`); + const res = await googleApiRequest.call(this, 'GET', '/drive/v3/files', undefined, { + q: query.join(' and '), + pageToken: paginationToken, + fields: 'nextPageToken,files(id,name,mimeType,webViewLink)', + orderBy: 'name_natural', + includeItemsFromAllDrives: true, + supportsAllDrives: true, + spaces: 'appDataFolder, drive', + corpora: 'allDrives', + }); + return { + results: res.files.map((file: FilesItem) => ({ + name: file.name, + value: file.id, + url: file.webViewLink, + })), + paginationToken: res.nextPageToken, + }; +} + +export async function driveSearch( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + let res = { drives: [], nextPageToken: undefined }; + + res = await googleApiRequest.call(this, 'GET', '/drive/v3/drives', undefined, { + q: filter ? `name contains '${filter.replace("'", "\\'")}'` : undefined, + pageToken: paginationToken, + }); + + const results: INodeListSearchItems[] = []; + + res.drives.forEach((drive: DriveItem) => { + results.push({ + name: drive.name, + value: drive.id, + url: `https://drive.google.com/drive/folders/${drive.id}`, + }); + }); + + return { + results, + paginationToken: res.nextPageToken, + }; +} + +export async function driveSearchWithDefault( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const drives = await driveSearch.call(this, filter, paginationToken); + + let results: INodeListSearchItems[] = []; + + if (filter && !RLC_DRIVE_DEFAULT.toLowerCase().includes(filter.toLowerCase())) { + results = drives.results; + } else { + results = [ + { + name: RLC_DRIVE_DEFAULT, + value: RLC_DRIVE_DEFAULT, + url: 'https://drive.google.com/drive/my-drive', + }, + ...drives.results, + ]; + } + + return { + results, + paginationToken: drives.paginationToken, + }; +} + +export async function folderSearch( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const query: string[] = []; + if (filter) { + query.push(`name contains '${filter.replace("'", "\\'")}'`); + } + query.push(`mimeType = '${DRIVE.FOLDER}'`); + + const qs: IDataObject = { + q: query.join(' and '), + pageToken: paginationToken, + fields: 'nextPageToken,files(id,name,mimeType,webViewLink,parents,driveId)', + orderBy: 'name_natural', + includeItemsFromAllDrives: true, + supportsAllDrives: true, + spaces: 'appDataFolder, drive', + corpora: 'allDrives', + }; + + let driveId; + + driveId = this.getNodeParameter('driveId', '') as IDataObject; + + if (!driveId) { + const searchFilter = this.getNodeParameter('filter', {}) as SearchFilter; + if (searchFilter?.driveId?.mode === 'url') { + searchFilter.driveId.value = this.getNodeParameter('filter.folderId', undefined, { + extractValue: true, + }) as string; + } + driveId = searchFilter.driveId; + } + updateDriveScopes(qs, driveId?.value as string); + + const res = await googleApiRequest.call(this, 'GET', '/drive/v3/files', undefined, qs); + + const results: INodeListSearchItems[] = []; + + res.files.forEach((i: FilesItem) => { + results.push({ + name: i.name, + value: i.id, + url: i.webViewLink, + }); + }); + + return { + results, + paginationToken: res.nextPageToken, + }; +} + +export async function folderSearchWithDefault( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const folders = await folderSearch.call(this, filter, paginationToken); + + let results: INodeListSearchItems[] = []; + const rootDefaultDisplayName = '/ (Root folder)'; + + if ( + filter && + !( + RLC_FOLDER_DEFAULT.toLowerCase().includes(filter.toLowerCase()) || + rootDefaultDisplayName.toLowerCase().includes(filter.toLowerCase()) + ) + ) { + results = folders.results; + } else { + results = [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: rootDefaultDisplayName, + value: RLC_FOLDER_DEFAULT, + url: 'https://drive.google.com/drive', + }, + ...folders.results, + ]; + } + + return { + results, + paginationToken: folders.paginationToken, + }; +} diff --git a/packages/nodes-base/nodes/Google/Drive/v2/transport/index.ts b/packages/nodes-base/nodes/Google/Drive/v2/transport/index.ts new file mode 100644 index 0000000000..417ef06c7c --- /dev/null +++ b/packages/nodes-base/nodes/Google/Drive/v2/transport/index.ts @@ -0,0 +1,111 @@ +import type { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, + IDataObject, + IPollFunctions, + JsonObject, + IHttpRequestOptions, + IHttpRequestMethods, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; +import { getGoogleAccessToken } from '../../../GenericFunctions'; + +export async function googleApiRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IPollFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject | string | Buffer = {}, + qs: IDataObject = {}, + uri?: string, + option: IDataObject = {}, +) { + const authenticationMethod = this.getNodeParameter( + 'authentication', + 0, + 'serviceAccount', + ) as string; + + let options: IHttpRequestOptions = { + headers: { + 'Content-Type': 'application/json', + }, + + method, + body, + qs, + url: uri || `https://www.googleapis.com${resource}`, + json: true, + }; + + options = Object.assign({}, options, option); + + try { + if (Object.keys(body).length === 0) { + delete options.body; + } + + if (authenticationMethod === 'serviceAccount') { + const credentials = await this.getCredentials('googleApi'); + + const { access_token } = await getGoogleAccessToken.call(this, credentials, 'drive'); + + options.headers!.Authorization = `Bearer ${access_token}`; + return await this.helpers.httpRequest(options); + } else { + return await this.helpers.requestOAuth2.call(this, 'googleDriveOAuth2Api', options); + } + } catch (error) { + if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') { + error.statusCode = '401'; + } + + const apiError = new NodeApiError( + this.getNode(), + { + reason: error.error, + } as JsonObject, + { httpCode: String(error.statusCode) }, + ); + + if ( + apiError.message && + apiError.description && + (apiError.message.toLowerCase().includes('bad request') || + apiError.message.toLowerCase().includes('forbidden') || + apiError.message.toUpperCase().includes('UNKNOWN ERROR')) + ) { + const message = apiError.message; + apiError.message = apiError.description; + apiError.description = message; + } + + throw apiError; + } +} + +export async function googleApiRequestAllItems( + this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, + method: IHttpRequestMethods, + propertyName: string, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {}, +) { + const returnData: IDataObject[] = []; + + let responseData; + query.maxResults = query.maxResults || 100; + query.pageSize = query.pageSize || 100; + + do { + responseData = await googleApiRequest.call(this, method, endpoint, body, query); + returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]); + + if (responseData.nextPageToken) { + query.pageToken = responseData.nextPageToken as string; + } + } while (responseData.nextPageToken !== undefined && responseData.nextPageToken !== ''); + + return returnData; +} From f89ef83c766fafb1d0497ed91a74b93e8d2af1ec Mon Sep 17 00:00:00 2001 From: OlegIvaniv Date: Tue, 27 Jun 2023 13:05:20 +0200 Subject: [PATCH 17/46] feat(editor): Prevent saving of workflow when canvas is loading (#6497) * feat(editor): Prevent saving of pristine workflow Signed-off-by: Oleg Ivaniv * Prevent saving if loading Signed-off-by: Oleg Ivaniv * Fix 7-workflow-actions spec Signed-off-by: Oleg Ivaniv * Restrict delay intercept to GET only Signed-off-by: Oleg Ivaniv * Wait for WF patch Signed-off-by: Oleg Ivaniv * Add helper to remove all active WFs in e2e Signed-off-by: Oleg Ivaniv * Use META_KEY env var * Remove cy.wait * Delete debugging DB reset console log Signed-off-by: Oleg Ivaniv * Fix clashin mixins `isReadOnly` property Signed-off-by: Oleg Ivaniv --------- Signed-off-by: Oleg Ivaniv --- cypress/e2e/7-workflow-actions.cy.ts | 160 +++++++++++------- cypress/pages/workflow.ts | 2 +- cypress/pages/workflows.ts | 2 + packages/cli/src/api/e2e.api.ts | 9 + .../src/components/ExpressionEdit.vue | 2 +- .../src/components/NodeCredentials.vue | 2 +- packages/editor-ui/src/components/RunData.vue | 10 +- .../src/components/RunDataJsonActions.vue | 2 +- .../editor-ui/src/mixins/genericHelpers.ts | 4 +- .../editor-ui/src/mixins/workflowHelpers.ts | 7 +- packages/editor-ui/src/views/NodeView.vue | 30 ++-- 11 files changed, 146 insertions(+), 84 deletions(-) diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index b7f948744c..5775ab9502 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -5,6 +5,7 @@ import { SCHEDULE_TRIGGER_NODE_NAME, } from '../constants'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; +import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; const NEW_WORKFLOW_NAME = 'Something else'; const IMPORT_WORKFLOW_URL = 'https://gist.githubusercontent.com/OlegIvaniv/010bd3f45c8a94f8eb7012e663a8b671/raw/3afea1aec15573cc168d9af7e79395bd76082906/test-workflow.json'; @@ -12,6 +13,7 @@ const DUPLICATE_WORKFLOW_NAME = 'Duplicated workflow'; const DUPLICATE_WORKFLOW_TAG = 'Duplicate'; const WorkflowPage = new WorkflowPageClass(); +const WorkflowPages = new WorkflowsPageClass(); describe('Workflow Actions', () => { before(() => { @@ -66,6 +68,42 @@ describe('Workflow Actions', () => { .should('eq', NEW_WORKFLOW_NAME); }); + it('should not save workflow if canvas is loading', () => { + let interceptCalledCount = 0; + + // There's no way in Cypress to check if intercept was not called + // so we'll count the number of times it was called + cy.intercept('PATCH', '/rest/workflows/*', () => { + interceptCalledCount++; + }).as('saveWorkflow'); + + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.actions.saveWorkflowOnButtonClick(); + cy.intercept( + { + url: '/rest/workflows/*', + method: 'GET', + middleware: true, + }, + (req) => { + // Delay the response to give time for the save to be triggered + req.on('response', async (res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)) + res.send(); + }) + } + ) + cy.reload(); + cy.get('.el-loading-mask').should('exist'); + cy.get('body').type(META_KEY, { release: false }).type('s'); + cy.get('body').type(META_KEY, { release: false }).type('s'); + cy.get('body').type(META_KEY, { release: false }).type('s'); + cy.wrap(null).then(() => expect(interceptCalledCount).to.eq(0)); + WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); + cy.get('body').type(META_KEY, { release: false }).type('s'); + cy.wait('@saveWorkflow'); + cy.wrap(null).then(() => expect(interceptCalledCount).to.eq(1)); + }) it('should copy nodes', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); @@ -110,64 +148,70 @@ describe('Workflow Actions', () => { }); it('should update workflow settings', () => { - WorkflowPage.actions.visit(); - // Open settings dialog - WorkflowPage.actions.saveWorkflowOnButtonClick(); - WorkflowPage.getters.workflowMenu().should('be.visible'); - WorkflowPage.getters.workflowMenu().click(); - WorkflowPage.getters.workflowMenuItemSettings().should('be.visible'); - WorkflowPage.getters.workflowMenuItemSettings().click(); - // Change all settings - WorkflowPage.getters.workflowSettingsErrorWorkflowSelect().find('li').should('have.length', 7); - WorkflowPage.getters - .workflowSettingsErrorWorkflowSelect() - .find('li') - .last() - .click({ force: true }); - WorkflowPage.getters.workflowSettingsTimezoneSelect().find('li').should('exist'); - WorkflowPage.getters.workflowSettingsTimezoneSelect().find('li').eq(1).click({ force: true }); - WorkflowPage.getters - .workflowSettingsSaveFiledExecutionsSelect() - .find('li') - .should('have.length', 3); - WorkflowPage.getters - .workflowSettingsSaveFiledExecutionsSelect() - .find('li') - .last() - .click({ force: true }); - WorkflowPage.getters - .workflowSettingsSaveSuccessExecutionsSelect() - .find('li') - .should('have.length', 3); - WorkflowPage.getters - .workflowSettingsSaveSuccessExecutionsSelect() - .find('li') - .last() - .click({ force: true }); - WorkflowPage.getters - .workflowSettingsSaveManualExecutionsSelect() - .find('li') - .should('have.length', 3); - WorkflowPage.getters - .workflowSettingsSaveManualExecutionsSelect() - .find('li') - .last() - .click({ force: true }); - WorkflowPage.getters - .workflowSettingsSaveExecutionProgressSelect() - .find('li') - .should('have.length', 3); - WorkflowPage.getters - .workflowSettingsSaveExecutionProgressSelect() - .find('li') - .last() - .click({ force: true }); - WorkflowPage.getters.workflowSettingsTimeoutWorkflowSwitch().click(); - WorkflowPage.getters.workflowSettingsTimeoutForm().find('input').first().type('1'); - // Save settings - WorkflowPage.getters.workflowSettingsSaveButton().click(); - WorkflowPage.getters.workflowSettingsModal().should('not.exist'); - WorkflowPage.getters.successToast().should('exist'); + cy.visit(WorkflowPages.url); + WorkflowPages.getters.workflowCards().then((cards) => { + const totalWorkflows = cards.length; + + WorkflowPage.actions.visit(); + // Open settings dialog + WorkflowPage.actions.saveWorkflowOnButtonClick(); + WorkflowPage.getters.workflowMenu().should('be.visible'); + WorkflowPage.getters.workflowMenu().click(); + WorkflowPage.getters.workflowMenuItemSettings().should('be.visible'); + WorkflowPage.getters.workflowMenuItemSettings().click(); + // Change all settings + // totalWorkflows + 1 (current workflow) + 1 (no workflow option) + WorkflowPage.getters.workflowSettingsErrorWorkflowSelect().find('li').should('have.length', totalWorkflows + 2); + WorkflowPage.getters + .workflowSettingsErrorWorkflowSelect() + .find('li') + .last() + .click({ force: true }); + WorkflowPage.getters.workflowSettingsTimezoneSelect().find('li').should('exist'); + WorkflowPage.getters.workflowSettingsTimezoneSelect().find('li').eq(1).click({ force: true }); + WorkflowPage.getters + .workflowSettingsSaveFiledExecutionsSelect() + .find('li') + .should('have.length', 3); + WorkflowPage.getters + .workflowSettingsSaveFiledExecutionsSelect() + .find('li') + .last() + .click({ force: true }); + WorkflowPage.getters + .workflowSettingsSaveSuccessExecutionsSelect() + .find('li') + .should('have.length', 3); + WorkflowPage.getters + .workflowSettingsSaveSuccessExecutionsSelect() + .find('li') + .last() + .click({ force: true }); + WorkflowPage.getters + .workflowSettingsSaveManualExecutionsSelect() + .find('li') + .should('have.length', 3); + WorkflowPage.getters + .workflowSettingsSaveManualExecutionsSelect() + .find('li') + .last() + .click({ force: true }); + WorkflowPage.getters + .workflowSettingsSaveExecutionProgressSelect() + .find('li') + .should('have.length', 3); + WorkflowPage.getters + .workflowSettingsSaveExecutionProgressSelect() + .find('li') + .last() + .click({ force: true }); + WorkflowPage.getters.workflowSettingsTimeoutWorkflowSwitch().click(); + WorkflowPage.getters.workflowSettingsTimeoutForm().find('input').first().type('1'); + // Save settings + WorkflowPage.getters.workflowSettingsSaveButton().click(); + WorkflowPage.getters.workflowSettingsModal().should('not.exist'); + WorkflowPage.getters.successToast().should('exist'); + }) }); it('should not be able to delete unsaved workflow', () => { diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index a17c52c9cf..2a91adf783 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -177,7 +177,7 @@ export class WorkflowPage extends BasePage { }, saveWorkflowUsingKeyboardShortcut: () => { cy.intercept('POST', '/rest/workflows').as('createWorkflow'); - cy.get('body').type('{meta}', { release: false }).type('s'); + cy.get('body').type(META_KEY, { release: false }).type('s'); }, deleteNode: (name: string) => { this.getters.canvasNodeByName(name).first().click(); diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 4a30b92ecb..d5700d0784 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -36,8 +36,10 @@ export class WorkflowsPage extends BasePage { cy.visit(this.url); this.getters.workflowCardActions(name).click(); this.getters.workflowDeleteButton().click(); + cy.intercept('DELETE', '/rest/workflows/*').as('deleteWorkflow'); cy.get('button').contains('delete').click(); + cy.wait('@deleteWorkflow'); }, }; } diff --git a/packages/cli/src/api/e2e.api.ts b/packages/cli/src/api/e2e.api.ts index efecd7c34c..ba972f89bf 100644 --- a/packages/cli/src/api/e2e.api.ts +++ b/packages/cli/src/api/e2e.api.ts @@ -12,6 +12,7 @@ import { Container } from 'typedi'; import config from '@/config'; import * as Db from '@/Db'; import type { Role } from '@db/entities/Role'; +import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { RoleRepository } from '@db/repositories'; import { hashPassword } from '@/UserManagement/UserManagementHelper'; import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; @@ -108,10 +109,18 @@ const resetLogStreaming = async () => { } }; +const removeActiveWorkflows = async () => { + const workflowRunner = Container.get(ActiveWorkflowRunner); + + workflowRunner.removeAllQueuedWorkflowActivations(); + await workflowRunner.removeAll(); +}; + export const e2eController = Router(); e2eController.post('/db/reset', async (req, res) => { await resetLogStreaming(); + await removeActiveWorkflows(); await truncateAll(); await setupUserManagement(); diff --git a/packages/editor-ui/src/components/ExpressionEdit.vue b/packages/editor-ui/src/components/ExpressionEdit.vue index 51d17d6690..c7110a45ac 100644 --- a/packages/editor-ui/src/components/ExpressionEdit.vue +++ b/packages/editor-ui/src/components/ExpressionEdit.vue @@ -45,7 +45,7 @@
-
+
{{ $locale.baseText('runData.pindata.thisDataIsPinned') }} - + @@ -917,7 +919,7 @@ export default defineComponent({ if ( value && value.length > 0 && - !this.isReadOnly && + !this.isReadOnlyRoute && !localStorage.getItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG) ) { this.pinDataDiscoveryComplete(); diff --git a/packages/editor-ui/src/components/RunDataJsonActions.vue b/packages/editor-ui/src/components/RunDataJsonActions.vue index 4d94fd6911..d2a9831af8 100644 --- a/packages/editor-ui/src/components/RunDataJsonActions.vue +++ b/packages/editor-ui/src/components/RunDataJsonActions.vue @@ -212,7 +212,7 @@ export default defineComponent({ copy_type: copyType, workflow_id: this.workflowsStore.workflowId, pane: this.paneType, - in_execution_log: this.isReadOnly, + in_execution_log: this.isReadOnlyRoute, }); this.copyToClipboard(value); diff --git a/packages/editor-ui/src/mixins/genericHelpers.ts b/packages/editor-ui/src/mixins/genericHelpers.ts index c7139bf31b..4a83b3d48e 100644 --- a/packages/editor-ui/src/mixins/genericHelpers.ts +++ b/packages/editor-ui/src/mixins/genericHelpers.ts @@ -17,7 +17,7 @@ export const genericHelpers = defineComponent({ }; }, computed: { - isReadOnly(): boolean { + isReadOnlyRoute(): boolean { return ![VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.LOG_STREAMING_SETTINGS].includes( this.$route.name as VIEWS, ); @@ -50,7 +50,7 @@ export const genericHelpers = defineComponent({ return { date, time }; }, editAllowedCheck(): boolean { - if (this.isReadOnly) { + if (this.isReadOnlyRoute) { this.showMessage({ // title: 'Workflow can not be changed!', title: this.$locale.baseText('genericHelpers.showMessage.title'), diff --git a/packages/editor-ui/src/mixins/workflowHelpers.ts b/packages/editor-ui/src/mixins/workflowHelpers.ts index 93fbefec78..c5f1c2fd87 100644 --- a/packages/editor-ui/src/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/mixins/workflowHelpers.ts @@ -43,6 +43,7 @@ import type { import { externalHooks } from '@/mixins/externalHooks'; import { nodeHelpers } from '@/mixins/nodeHelpers'; +import { genericHelpers } from '@/mixins/genericHelpers'; import { useToast, useMessage } from '@/composables'; import { isEqual } from 'lodash-es'; @@ -329,7 +330,7 @@ function executeData( } export const workflowHelpers = defineComponent({ - mixins: [externalHooks, nodeHelpers], + mixins: [externalHooks, nodeHelpers, genericHelpers], setup() { return { ...useToast(), @@ -699,6 +700,7 @@ export const workflowHelpers = defineComponent({ forceSave = false, ): Promise { const currentWorkflow = id || this.$route.params.name; + const isLoading = this.loadingService !== null; if (!currentWorkflow || ['new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(currentWorkflow)) { return this.saveAsNewWorkflow({ name, tags }, redirect); @@ -706,6 +708,9 @@ export const workflowHelpers = defineComponent({ // Workflow exists already so update it try { + if (!forceSave && isLoading) { + return true; + } this.uiStore.addActiveAction('workflowSaving'); const workflowDataRequest: IWorkflowDataUpdate = await this.getWorkflowDataToSave(); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index c9573d2b7a..87d96726e4 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -54,7 +54,7 @@ @run="onNodeRun" :key="`${nodeData.id}_node`" :name="nodeData.name" - :isReadOnly="isReadOnly || readOnlyEnv" + :isReadOnly="isReadOnlyRoute || readOnlyEnv" :instance="instance" :isActive="!!activeNode && activeNode.name === nodeData.name" :hideActions="pullConnActive" @@ -76,7 +76,7 @@ @removeNode="(name) => removeNode(name, true)" :key="`${nodeData.id}_sticky`" :name="nodeData.name" - :isReadOnly="isReadOnly || readOnlyEnv" + :isReadOnly="isReadOnlyRoute || readOnlyEnv" :instance="instance" :isActive="!!activeNode && activeNode.name === nodeData.name" :nodeViewScale="nodeViewScale" @@ -87,7 +87,7 @@
-
+
{ @@ -2211,7 +2211,7 @@ export default defineComponent({ } if ( - this.isReadOnly || + this.isReadOnlyRoute || this.readOnlyEnv || this.enterTimer || !connection || @@ -2242,7 +2242,7 @@ export default defineComponent({ } if ( - this.isReadOnly || + this.isReadOnlyRoute || this.readOnlyEnv || !connection || this.activeConnection?.id !== connection.id @@ -2609,7 +2609,7 @@ export default defineComponent({ // Create connections in DOM this.instance?.connect({ uuids: uuid, - detachable: !this.isReadOnly, + detachable: !this.isReadOnlyRoute, }); setTimeout(() => { From 5b5f30fd331344f0a488229269c6e5e84e7de3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 27 Jun 2023 16:15:29 +0200 Subject: [PATCH 18/46] ci: Update release process to support `major` releases (no-changelog) (#6552) --- .github/scripts/update-changelog.mjs | 22 ++++++++++++++-------- .github/workflows/release-create-pr.yml | 22 +++++++++------------- .github/workflows/release-publish.yml | 4 ++-- .gitignore | 1 + 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/.github/scripts/update-changelog.mjs b/.github/scripts/update-changelog.mjs index fda9ae1fd0..1b41a671be 100644 --- a/.github/scripts/update-changelog.mjs +++ b/.github/scripts/update-changelog.mjs @@ -7,10 +7,13 @@ import { dirname } from 'path'; import { fileURLToPath } from 'url'; import stream from 'stream'; import { promisify } from 'util'; +import packageJson from '../../package.json' assert { type: 'json' }; const pipeline = promisify(stream.pipeline); -const changelogFile = resolve(dirname(fileURLToPath(import.meta.url)), '../../CHANGELOG.md'); +const baseDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const fullChangelogFile = resolve(baseDir, 'CHANGELOG.md'); +const versionChangelogFile = resolve(baseDir, `CHANGELOG-${packageJson.version}.md`); const changelogStream = conventionalChangelog({ preset: 'angular', @@ -24,13 +27,16 @@ const changelogStream = conventionalChangelog({ process.exit(1); }); +// We need to duplicate the stream here to pipe the changelog into two separate files +const stream1 = new stream.PassThrough(); +const stream2 = new stream.PassThrough(); +changelogStream.pipe(stream1); +changelogStream.pipe(stream2); + +await pipeline(stream1, createWriteStream(versionChangelogFile)); + // Since we can't read and write from the same file at the same time, // we use a temporary file to output the updated changelog to. const tmpFile = createTempFile(); -await pipeline( - changelogStream, - addStream(createReadStream(changelogFile)), - createWriteStream(tmpFile), -); - -await pipeline(createReadStream(tmpFile), createWriteStream(changelogFile)); +await pipeline(stream2, addStream(createReadStream(fullChangelogFile)), createWriteStream(tmpFile)), + await pipeline(createReadStream(tmpFile), createWriteStream(fullChangelogFile)); diff --git a/.github/workflows/release-create-pr.yml b/.github/workflows/release-create-pr.yml index 79cf65e6b6..7617a35476 100644 --- a/.github/workflows/release-create-pr.yml +++ b/.github/workflows/release-create-pr.yml @@ -35,37 +35,33 @@ jobs: fetch-depth: 0 ref: ${{ github.event.inputs.base-branch }} - - name: Push the base branch - run: | - git checkout -b "release/${{ github.event.inputs.release-type }}" - git push -f origin "release/${{ github.event.inputs.release-type }}" - - uses: pnpm/action-setup@v2.2.4 - uses: actions/setup-node@v3 with: node-version: 18.x + - run: npm install --prefix=.github/scripts --no-package-lock - name: Bump package versions run: | echo "NEXT_RELEASE=$(node .github/scripts/bump-versions.mjs)" >> $GITHUB_ENV - pnpm i --lockfile-only env: RELEASE_TYPE: ${{ github.event.inputs.release-type }} - name: Update Changelog run: node .github/scripts/update-changelog.mjs + - name: Push the base branch + run: | + git push -f origin refs/remotes/origin/${{ github.event.inputs.base-branch }}:refs/heads/release/${{ env.NEXT_RELEASE }} + - name: Push the release branch, and Create the PR - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@v5 with: - base: 'release/${{ github.event.inputs.release-type }}' - branch: 'release/${{ env.NEXT_RELEASE }}' + base: 'release/${{ env.NEXT_RELEASE }}' + branch: '${{ env.NEXT_RELEASE }}-pr' commit-message: ':rocket: Release ${{ env.NEXT_RELEASE }}' delete-branch: true labels: 'release' title: ':rocket: Release ${{ env.NEXT_RELEASE }}' - # 'TODO: add generated changelog to the body. create a script to generate custom changelog' - body: '' - - # TODO: post PR link to slack + body-path: 'CHANGELOG-${{ env.NEXT_RELEASE }}.md' diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 6a577c6da8..6882b4a9bb 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -5,8 +5,7 @@ on: types: - closed branches: - - 'release/patch' - - 'release/minor' + - 'release/*' jobs: publish-release: @@ -50,6 +49,7 @@ jobs: tag: 'n8n@${{env.RELEASE}}' prerelease: true makeLatest: false + body: ${{github.event.pull_request.body}} - name: Trigger a release note continue-on-error: true diff --git a/.gitignore b/.gitignore index b25164b547..3060b870c1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ packages/**/.turbo cypress/videos/* cypress/screenshots/* *.swp +CHANGELOG-*.md From 2e8dfb86d4636781b319d6190e8be12e7661ee16 Mon Sep 17 00:00:00 2001 From: Marten Steketee Date: Tue, 27 Jun 2023 17:22:39 +0200 Subject: [PATCH 19/46] fix(Sendy Node): Fix issue with brand id not being sent (#6530) --- packages/nodes-base/nodes/Sendy/Sendy.node.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/nodes-base/nodes/Sendy/Sendy.node.ts b/packages/nodes-base/nodes/Sendy/Sendy.node.ts index fb5865f5a4..3bb2055a6c 100644 --- a/packages/nodes-base/nodes/Sendy/Sendy.node.ts +++ b/packages/nodes-base/nodes/Sendy/Sendy.node.ts @@ -86,6 +86,11 @@ export class Sendy implements INodeType { const additionalFields = this.getNodeParameter('additionalFields', i); + let brandId = null; + if (!sendCampaign) { + brandId = this.getNodeParameter('brandId', i) as string; + } + const body: IDataObject = { from_name: fromName, from_email: fromEmail, @@ -96,6 +101,10 @@ export class Sendy implements INodeType { html_text: htmlText, }; + if (brandId) { + body.brand_id = brandId as string; + } + if (additionalFields.plainText) { body.plain_text = additionalFields.plainText; } @@ -116,10 +125,6 @@ export class Sendy implements INodeType { body.exclude_segments_ids = additionalFields.excludeSegmentIds as string; } - if (additionalFields.brandId) { - body.brand_id = additionalFields.brandId as string; - } - if (additionalFields.queryString) { body.query_string = additionalFields.queryString as string; } From b19833d673bd554ba86c0b234e8d13633912563a Mon Sep 17 00:00:00 2001 From: Alberto Pasqualetto <39854348+albertopasqualetto@users.noreply.github.com> Date: Wed, 28 Jun 2023 09:55:40 +0200 Subject: [PATCH 20/46] feat(Microsoft To Do Node): Add an option to set a reminder when creating a task (#5757) --- .../nodes-base/nodes/Microsoft/ToDo/MicrosoftToDo.node.ts | 8 ++++++++ .../nodes-base/nodes/Microsoft/ToDo/TaskDescription.ts | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/packages/nodes-base/nodes/Microsoft/ToDo/MicrosoftToDo.node.ts b/packages/nodes-base/nodes/Microsoft/ToDo/MicrosoftToDo.node.ts index c6ef0442cb..869eb34b69 100644 --- a/packages/nodes-base/nodes/Microsoft/ToDo/MicrosoftToDo.node.ts +++ b/packages/nodes-base/nodes/Microsoft/ToDo/MicrosoftToDo.node.ts @@ -221,6 +221,14 @@ export class MicrosoftToDo implements INodeType { }; } + if (body.reminderDateTime) { + body.reminderDateTime = { + dateTime: moment.tz(body.reminderDateTime, timezone).format(), + timeZone: timezone, + }; + body.isReminderOn = true; + } + responseData = await microsoftApiRequest.call( this, 'POST', diff --git a/packages/nodes-base/nodes/Microsoft/ToDo/TaskDescription.ts b/packages/nodes-base/nodes/Microsoft/ToDo/TaskDescription.ts index a048139881..9e7105a6e9 100644 --- a/packages/nodes-base/nodes/Microsoft/ToDo/TaskDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/ToDo/TaskDescription.ts @@ -105,6 +105,13 @@ export const taskFields: INodeProperties[] = [ default: '', description: 'The date in the specified time zone that the task is to be finished', }, + { + displayName: 'Reminder', + name: 'reminderDateTime', + type: 'dateTime', + default: '', + description: 'The date in the specified time zone that the task is to be reminded', + }, { displayName: 'Importance', name: 'importance', From 4b755fb0b441a37eb804c9e70d4b071a341f7155 Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Wed, 28 Jun 2023 11:06:40 +0200 Subject: [PATCH 21/46] fix(core): Use owners file to export wf owners (#6547) * remove owner from exported workflow * use owners file to export wf owners * update sharedworkflow owners * fix logic * further update logic * add updatetAt to local changes * additional filter for cred export * optimize query * remove transactions and optimize query * reduce array size and add updated at to tags status --- .../environments/sourceControl/constants.ts | 1 + .../sourceControl/sourceControl.service.ee.ts | 53 ++- .../sourceControlExport.service.ee.ts | 16 +- .../sourceControlImport.service.ee.ts | 316 ++++++++++-------- .../sourceControl/types/exportableWorkflow.ts | 1 - .../types/sourceControlledFile.ts | 1 + 6 files changed, 235 insertions(+), 153 deletions(-) diff --git a/packages/cli/src/environments/sourceControl/constants.ts b/packages/cli/src/environments/sourceControl/constants.ts index ab74559ce7..3ef023f349 100644 --- a/packages/cli/src/environments/sourceControl/constants.ts +++ b/packages/cli/src/environments/sourceControl/constants.ts @@ -5,6 +5,7 @@ export const SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER = 'workflows'; export const SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credential_stubs'; export const SOURCE_CONTROL_VARIABLES_EXPORT_FILE = 'variable_stubs.json'; export const SOURCE_CONTROL_TAGS_EXPORT_FILE = 'tags.json'; +export const SOURCE_CONTROL_OWNERS_EXPORT_FILE = 'owners.json'; export const SOURCE_CONTROL_SSH_FOLDER = 'ssh'; export const SOURCE_CONTROL_SSH_KEY_NAME = 'key'; export const SOURCE_CONTROL_DEFAULT_BRANCH = 'main'; diff --git a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts index d1c5a3ccf6..35125ecc96 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts @@ -32,6 +32,8 @@ import type { import { SourceControlPreferencesService } from './sourceControlPreferences.service.ee'; import { writeFileSync } from 'fs'; import { SourceControlImportService } from './sourceControlImport.service.ee'; +import type { WorkflowEntity } from '../../databases/entities/WorkflowEntity'; +import type { CredentialsEntity } from '../../databases/entities/CredentialsEntity'; @Service() export class SourceControlService { private sshKeyName: string; @@ -252,6 +254,7 @@ export class SourceControlService { ...status.modified, ]); } + mergedFileNames.add(this.sourceControlExportService.getOwnersPath()); const deletedFiles = new Set(status.deleted); deletedFiles.forEach((e) => mergedFileNames.delete(e)); await this.unstage(); @@ -285,6 +288,20 @@ export class SourceControlService { let conflict = false; let status: SourceControlledFileStatus = 'unknown'; let type: SourceControlledFileType = 'file'; + let updatedAt = ''; + + const allWorkflows: Map = new Map(); + (await Db.collections.Workflow.find({ select: ['id', 'name', 'updatedAt'] })).forEach( + (workflow) => { + allWorkflows.set(workflow.id, workflow); + }, + ); + const allCredentials: Map = new Map(); + (await Db.collections.Credentials.find({ select: ['id', 'name', 'updatedAt'] })).forEach( + (credential) => { + allCredentials.set(credential.id, credential); + }, + ); // initialize status from git status result if (statusResult.not_added.find((e) => e === fileName)) status = 'new'; @@ -303,14 +320,14 @@ export class SourceControlService { .replace(/[\/,\\]/, '') .replace('.json', ''); if (location === 'remote') { - const existingWorkflow = await Db.collections.Workflow.find({ - where: { id }, - }); - if (existingWorkflow?.length > 0) { - name = existingWorkflow[0].name; + const existingWorkflow = allWorkflows.get(id); + if (existingWorkflow) { + name = existingWorkflow.name; + updatedAt = existingWorkflow.updatedAt.toISOString(); } } else { name = '(deleted)'; + // todo: once we have audit log, this deletion date could be looked up } } else { const workflow = await this.sourceControlExportService.getWorkflowFromFile(fileName); @@ -326,6 +343,11 @@ export class SourceControlService { id = workflow.id; name = workflow.name; } + const existingWorkflow = allWorkflows.get(id); + if (existingWorkflow) { + name = existingWorkflow.name; + updatedAt = existingWorkflow.updatedAt.toISOString(); + } } } if (fileName.startsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER)) { @@ -336,11 +358,10 @@ export class SourceControlService { .replace(/[\/,\\]/, '') .replace('.json', ''); if (location === 'remote') { - const existingCredential = await Db.collections.Credentials.find({ - where: { id }, - }); - if (existingCredential?.length > 0) { - name = existingCredential[0].name; + const existingCredential = allCredentials.get(id); + if (existingCredential) { + name = existingCredential.name; + updatedAt = existingCredential.updatedAt.toISOString(); } } else { name = '(deleted)'; @@ -359,6 +380,11 @@ export class SourceControlService { id = credential.id; name = credential.name; } + const existingCredential = allCredentials.get(id); + if (existingCredential) { + name = existingCredential.name; + updatedAt = existingCredential.updatedAt.toISOString(); + } } } @@ -369,9 +395,15 @@ export class SourceControlService { } if (fileName.startsWith(SOURCE_CONTROL_TAGS_EXPORT_FILE)) { + const lastUpdatedTag = await Db.collections.Tag.find({ + order: { updatedAt: 'DESC' }, + take: 1, + select: ['updatedAt'], + }); id = 'tags'; name = 'tags'; type = 'tags'; + updatedAt = lastUpdatedTag[0]?.updatedAt.toISOString(); } if (!id) return; @@ -384,6 +416,7 @@ export class SourceControlService { status, location, conflict, + updatedAt, }; } diff --git a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts index 1352c8c7b8..b8f24e03e0 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts @@ -3,6 +3,7 @@ import path from 'path'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_GIT_FOLDER, + SOURCE_CONTROL_OWNERS_EXPORT_FILE, SOURCE_CONTROL_TAGS_EXPORT_FILE, SOURCE_CONTROL_VARIABLES_EXPORT_FILE, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, @@ -50,6 +51,10 @@ export class SourceControlExportService { return path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE); } + getOwnersPath(): string { + return path.join(this.gitFolder, SOURCE_CONTROL_OWNERS_EXPORT_FILE); + } + getVariablesPath(): string { return path.join(this.gitFolder, SOURCE_CONTROL_VARIABLES_EXPORT_FILE); } @@ -160,7 +165,6 @@ export class SourceControlExportService { connections: e.workflow?.connections, settings: e.workflow?.settings, triggerCount: e.workflow?.triggerCount, - owner: e.user.email, versionId: e.workflow?.versionId, }; LoggerProxy.debug(`Writing workflow ${e.workflowId} to ${fileName}`); @@ -186,6 +190,11 @@ export class SourceControlExportService { const removedFiles = await this.rmDeletedWorkflowsFromExportFolder(sharedWorkflows); // write the workflows to the export folder as json files await this.writeExportableWorkflowsToExportFolder(sharedWorkflows); + // write list of owners to file + const ownersFileName = this.getOwnersPath(); + const owners: Record = {}; + sharedWorkflows.forEach((e) => (owners[e.workflowId] = e.user.email)); + await fsWriteFile(ownersFileName, JSON.stringify(owners, null, 2)); return { count: sharedWorkflows.length, folder: this.workflowExportFolder, @@ -280,7 +289,10 @@ export class SourceControlExportService { } else if (typeof data[key] === 'object') { data[key] = this.replaceCredentialData(data[key] as ICredentialDataDecryptedObject); } else if (typeof data[key] === 'string') { - data[key] = (data[key] as string)?.startsWith('={{') ? data[key] : ''; + data[key] = + (data[key] as string)?.startsWith('={{') && (data[key] as string)?.includes('$secret') + ? data[key] + : ''; } else if (typeof data[key] === 'number') { // TODO: leaving numbers in for now, but maybe we should remove them continue; diff --git a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts index af4d5946d8..e1e680b338 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts @@ -3,6 +3,7 @@ import path from 'path'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_GIT_FOLDER, + SOURCE_CONTROL_OWNERS_EXPORT_FILE, SOURCE_CONTROL_TAGS_EXPORT_FILE, SOURCE_CONTROL_VARIABLES_EXPORT_FILE, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, @@ -14,15 +15,12 @@ import { readFile as fsReadFile } from 'fs/promises'; import { Credentials, UserSettings } from 'n8n-core'; import type { IWorkflowToImport } from '@/Interfaces'; import type { ExportableCredential } from './types/exportableCredential'; -import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; -import { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; import { Variables } from '@/databases/entities/Variables'; import type { ImportResult } from './types/importResult'; import { UM_FIX_INSTRUCTION } from '@/commands/BaseCommand'; import { SharedCredentials } from '@/databases/entities/SharedCredentials'; -import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; -import { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping'; -import { TagEntity } from '@/databases/entities/TagEntity'; +import type { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping'; +import type { TagEntity } from '@/databases/entities/TagEntity'; import { ActiveWorkflowRunner } from '../../ActiveWorkflowRunner'; import type { SourceControllPullOptions } from './types/sourceControlPullWorkFolder'; import { In } from 'typeorm'; @@ -94,56 +92,54 @@ export class SourceControlImportService { const ownerGlobalRole = await this.getOwnerGlobalRole(); const encryptionKey = await UserSettings.getEncryptionKey(); let importCredentialsResult: Array<{ id: string; name: string; type: string }> = []; - await Db.transaction(async (transactionManager) => { - importCredentialsResult = await Promise.all( - credentialFiles.map(async (file) => { - LoggerProxy.debug(`Importing credentials file ${file}`); - const credential = jsonParse( - await fsReadFile(file, { encoding: 'utf8' }), - ); - const existingCredential = existingCredentials.find( - (e) => e.id === credential.id && e.type === credential.type, - ); - const sharedOwner = await Db.collections.SharedCredentials.findOne({ - select: ['userId'], - where: { - credentialsId: credential.id, - roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]), - }, - }); + importCredentialsResult = await Promise.all( + credentialFiles.map(async (file) => { + LoggerProxy.debug(`Importing credentials file ${file}`); + const credential = jsonParse( + await fsReadFile(file, { encoding: 'utf8' }), + ); + const existingCredential = existingCredentials.find( + (e) => e.id === credential.id && e.type === credential.type, + ); + const sharedOwner = await Db.collections.SharedCredentials.findOne({ + select: ['userId'], + where: { + credentialsId: credential.id, + roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]), + }, + }); - const { name, type, data, id, nodesAccess } = credential; - const newCredentialObject = new Credentials({ id, name }, type, []); - if (existingCredential?.data) { - newCredentialObject.data = existingCredential.data; - } else { - newCredentialObject.setData(data, encryptionKey); - } - newCredentialObject.nodesAccess = nodesAccess || existingCredential?.nodesAccess || []; + const { name, type, data, id, nodesAccess } = credential; + const newCredentialObject = new Credentials({ id, name }, type, []); + if (existingCredential?.data) { + newCredentialObject.data = existingCredential.data; + } else { + newCredentialObject.setData(data, encryptionKey); + } + newCredentialObject.nodesAccess = nodesAccess || existingCredential?.nodesAccess || []; - LoggerProxy.debug(`Updating credential id ${newCredentialObject.id as string}`); - await transactionManager.upsert(CredentialsEntity, newCredentialObject, ['id']); + LoggerProxy.debug(`Updating credential id ${newCredentialObject.id as string}`); + await Db.collections.Credentials.upsert(newCredentialObject, ['id']); - if (!sharedOwner) { - const newSharedCredential = new SharedCredentials(); - newSharedCredential.credentialsId = newCredentialObject.id as string; - newSharedCredential.userId = userId; - newSharedCredential.roleId = ownerGlobalRole.id; + if (!sharedOwner) { + const newSharedCredential = new SharedCredentials(); + newSharedCredential.credentialsId = newCredentialObject.id as string; + newSharedCredential.userId = userId; + newSharedCredential.roleId = ownerGlobalRole.id; - await transactionManager.upsert(SharedCredentials, { ...newSharedCredential }, [ - 'credentialsId', - 'userId', - ]); - } + await Db.collections.SharedCredentials.upsert({ ...newSharedCredential }, [ + 'credentialsId', + 'userId', + ]); + } - return { - id: newCredentialObject.id as string, - name: newCredentialObject.name, - type: newCredentialObject.type, - }; - }), - ); - }); + return { + id: newCredentialObject.id as string, + name: newCredentialObject.name, + type: newCredentialObject.type, + }; + }), + ); return importCredentialsResult.filter((e) => e !== undefined); } @@ -224,35 +220,31 @@ export class SourceControlImportService { ).map((e) => e.id), ); - await Db.transaction(async (transactionManager) => { - await Promise.all( - mappedTags.tags.map(async (tag) => { - await transactionManager.upsert( - TagEntity, - { - ...tag, - }, - { - skipUpdateIfNoValuesChanged: true, - conflictPaths: { id: true }, - }, - ); - }), - ); - await Promise.all( - mappedTags.mappings.map(async (mapping) => { - if (!existingWorkflowIds.has(String(mapping.workflowId))) return; - await transactionManager.upsert( - WorkflowTagMapping, - { tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) }, - { - skipUpdateIfNoValuesChanged: true, - conflictPaths: { tagId: true, workflowId: true }, - }, - ); - }), - ); - }); + await Promise.all( + mappedTags.tags.map(async (tag) => { + await Db.collections.Tag.upsert( + { + ...tag, + }, + { + skipUpdateIfNoValuesChanged: true, + conflictPaths: { id: true }, + }, + ); + }), + ); + await Promise.all( + mappedTags.mappings.map(async (mapping) => { + if (!existingWorkflowIds.has(String(mapping.workflowId))) return; + await Db.collections.WorkflowTagMapping.upsert( + { tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) }, + { + skipUpdateIfNoValuesChanged: true, + conflictPaths: { tagId: true, workflowId: true }, + }, + ); + }), + ); return mappedTags; } return { tags: [], mappings: [] }; @@ -273,74 +265,118 @@ export class SourceControlImportService { const ownerWorkflowRole = await this.getOwnerWorkflowRole(); const workflowRunner = Container.get(ActiveWorkflowRunner); - let importWorkflowsResult = new Array<{ id: string; name: string }>(); - await Db.transaction(async (transactionManager) => { - importWorkflowsResult = await Promise.all( - workflowFiles.map(async (file) => { - LoggerProxy.debug(`Parsing workflow file ${file}`); - const importedWorkflow = jsonParse( - await fsReadFile(file, { encoding: 'utf8' }), + // read owner file if it exists and map workflow ids to owner emails + // then find existing users with those emails or fallback to passed in userId + const ownerRecords: Record = {}; + const ownersFile = await glob(SOURCE_CONTROL_OWNERS_EXPORT_FILE, { + cwd: this.gitFolder, + absolute: true, + }); + if (ownersFile.length > 0) { + LoggerProxy.debug(`Reading workflow owners from file ${ownersFile[0]}`); + const ownerEmails = jsonParse>( + await fsReadFile(ownersFile[0], { encoding: 'utf8' }), + { fallbackValue: {} }, + ); + if (ownerEmails) { + const uniqueOwnerEmails = new Set(Object.values(ownerEmails)); + const existingUsers = await Db.collections.User.find({ + where: { email: In([...uniqueOwnerEmails]) }, + }); + Object.keys(ownerEmails).forEach((workflowId) => { + ownerRecords[workflowId] = + existingUsers.find((e) => e.email === ownerEmails[workflowId])?.id ?? userId; + }); + } + } + + let importWorkflowsResult = new Array<{ id: string; name: string } | undefined>(); + + const allSharedWorkflows = await Db.collections.SharedWorkflow.find({ + select: ['workflowId', 'roleId', 'userId'], + }); + + importWorkflowsResult = await Promise.all( + workflowFiles.map(async (file) => { + LoggerProxy.debug(`Parsing workflow file ${file}`); + const importedWorkflow = jsonParse( + await fsReadFile(file, { encoding: 'utf8' }), + ); + if (!importedWorkflow?.id) { + return; + } + const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); + if (existingWorkflow?.versionId === importedWorkflow.versionId) { + LoggerProxy.debug( + `Skipping import of workflow ${importedWorkflow.id ?? 'n/a'} - versionId is up to date`, ); - const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); - if (existingWorkflow?.versionId === importedWorkflow.versionId) { - LoggerProxy.debug( - `Skipping import of workflow ${ - importedWorkflow.id ?? 'n/a' - } - versionId is up to date`, - ); - return { - id: importedWorkflow.id ?? 'n/a', - name: 'skipped', - }; - } - LoggerProxy.debug(`Importing workflow ${importedWorkflow.id ?? 'n/a'}`); - importedWorkflow.active = existingWorkflow?.active ?? false; - LoggerProxy.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`); - const upsertResult = await transactionManager.upsert( - WorkflowEntity, - { ...importedWorkflow }, - ['id'], - ); - if (upsertResult?.identifiers?.length !== 1) { - throw new Error(`Failed to upsert workflow ${importedWorkflow.id ?? 'new'}`); - } - // due to sequential Ids, this may have changed during the insert - // TODO: once IDs are unique and we removed autoincrement, remove this - const upsertedWorkflowId = upsertResult.identifiers[0].id as string; - await transactionManager.upsert( - SharedWorkflow, + return { + id: importedWorkflow.id ?? 'n/a', + name: 'skipped', + }; + } + LoggerProxy.debug(`Importing workflow ${importedWorkflow.id ?? 'n/a'}`); + importedWorkflow.active = existingWorkflow?.active ?? false; + LoggerProxy.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`); + const upsertResult = await Db.collections.Workflow.upsert({ ...importedWorkflow }, ['id']); + if (upsertResult?.identifiers?.length !== 1) { + throw new Error(`Failed to upsert workflow ${importedWorkflow.id ?? 'new'}`); + } + // Update workflow owner to the user who exported the workflow, if that user exists + // in the instance, and the workflow doesn't already have an owner + const workflowOwnerId = ownerRecords[importedWorkflow.id] ?? userId; + const existingSharedWorkflowOwnerByRoleId = allSharedWorkflows.find( + (e) => e.workflowId === importedWorkflow.id && e.roleId === ownerWorkflowRole.id, + ); + const existingSharedWorkflowOwnerByUserId = allSharedWorkflows.find( + (e) => e.workflowId === importedWorkflow.id && e.userId === workflowOwnerId, + ); + if (!existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) { + // no owner exists yet, so create one + await Db.collections.SharedWorkflow.insert({ + workflowId: importedWorkflow.id, + userId: workflowOwnerId, + roleId: ownerWorkflowRole.id, + }); + } else if (existingSharedWorkflowOwnerByRoleId) { + // skip, because the workflow already has a global owner + } else if (existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) { + // if the worklflow has a non-global owner that is referenced by the owner file, + // and no existing global owner, update the owner to the user referenced in the owner file + await Db.collections.SharedWorkflow.update( + { + workflowId: importedWorkflow.id, + userId: workflowOwnerId, + }, { - workflowId: upsertedWorkflowId, - userId, roleId: ownerWorkflowRole.id, }, - ['workflowId', 'userId'], ); - - if (existingWorkflow?.active) { - try { - // remove active pre-import workflow - LoggerProxy.debug(`Deactivating workflow id ${existingWorkflow.id}`); - await workflowRunner.remove(existingWorkflow.id); - // try activating the imported workflow - LoggerProxy.debug(`Reactivating workflow id ${existingWorkflow.id}`); - await workflowRunner.add(existingWorkflow.id, 'activate'); - } catch (error) { - LoggerProxy.error( - `Failed to activate workflow ${existingWorkflow.id}`, - error as Error, - ); - } + } + if (existingWorkflow?.active) { + try { + // remove active pre-import workflow + LoggerProxy.debug(`Deactivating workflow id ${existingWorkflow.id}`); + await workflowRunner.remove(existingWorkflow.id); + // try activating the imported workflow + LoggerProxy.debug(`Reactivating workflow id ${existingWorkflow.id}`); + await workflowRunner.add(existingWorkflow.id, 'activate'); + } catch (error) { + LoggerProxy.error(`Failed to activate workflow ${existingWorkflow.id}`, error as Error); } + } - return { - id: importedWorkflow.id ?? 'unknown', - name: file, - }; - }), - ); - }); - return importWorkflowsResult; + return { + id: importedWorkflow.id ?? 'unknown', + name: file, + }; + }), + ); + + return importWorkflowsResult.filter((e) => e !== undefined) as Array<{ + id: string; + name: string; + }>; } async importFromWorkFolder(options: SourceControllPullOptions): Promise { diff --git a/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts b/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts index ca0f7087f9..15d405fbb7 100644 --- a/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts +++ b/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts @@ -8,6 +8,5 @@ export interface ExportableWorkflow { connections: IConnections; settings?: IWorkflowSettings; triggerCount: number; - owner: string; versionId: string; } diff --git a/packages/cli/src/environments/sourceControl/types/sourceControlledFile.ts b/packages/cli/src/environments/sourceControl/types/sourceControlledFile.ts index 12b99457b7..165621ebc6 100644 --- a/packages/cli/src/environments/sourceControl/types/sourceControlledFile.ts +++ b/packages/cli/src/environments/sourceControl/types/sourceControlledFile.ts @@ -16,4 +16,5 @@ export type SourceControlledFile = { status: SourceControlledFileStatus; location: SourceControlledFileLocation; conflict: boolean; + updatedAt: string; }; From d0e777961743c0155cc2081d04f4630ac666ad22 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 28 Jun 2023 11:07:11 +0200 Subject: [PATCH 22/46] :rocket: Release 0.235.0 (#6557) Co-authored-by: netroy --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/editor-ui/package.json | 2 +- packages/node-dev/package.json | 2 +- packages/nodes-base/package.json | 2 +- packages/workflow/package.json | 2 +- 8 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 012b11285d..269e98ea45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +# [0.235.0](https://github.com/n8n-io/n8n/compare/n8n@0.234.0...n8n@0.235.0) (2023-06-28) + + +### Bug Fixes + +* **core:** Add empty credential value marker to show empty pw field ([#6532](https://github.com/n8n-io/n8n/issues/6532)) ([9294e2d](https://github.com/n8n-io/n8n/commit/9294e2da3c7c99c2099f5865e610fa7217bf06be)) +* **core:** All migrations should run in a transaction ([#6519](https://github.com/n8n-io/n8n/issues/6519)) ([e152cfe](https://github.com/n8n-io/n8n/commit/e152cfe27cf3396f4b278614f1d46d9dd723f36e)) +* **core:** Rename to credential_stubs and variable_stubs.json ([#6528](https://github.com/n8n-io/n8n/issues/6528)) ([b06462f](https://github.com/n8n-io/n8n/commit/b06462f4415bd1143a00b4a66e6e626da8c52196)) +* **Edit Image Node:** Fix transparent operation ([#6513](https://github.com/n8n-io/n8n/issues/6513)) ([4a4bcbc](https://github.com/n8n-io/n8n/commit/4a4bcbca298bf90c54d3597103e6a231855abbd2)) +* **editor:** Add default author name and email to source control settings ([#6543](https://github.com/n8n-io/n8n/issues/6543)) ([e1a02c7](https://github.com/n8n-io/n8n/commit/e1a02c76257de30e08878279dea33d7854d46938)) +* **editor:** Change default branchColor and remove label ([#6541](https://github.com/n8n-io/n8n/issues/6541)) ([186271e](https://github.com/n8n-io/n8n/commit/186271e939bca19ec9c94d9455e9430d8b8cf9d7)) +* **Google Drive Node:** URL parsing ([#6527](https://github.com/n8n-io/n8n/issues/6527)) ([d9ed0b3](https://github.com/n8n-io/n8n/commit/d9ed0b31b538320a67ee4e5c0cae34656c9f4334)) +* **Google Sheets Node:** Incorrect read of 0 and false ([#6525](https://github.com/n8n-io/n8n/issues/6525)) ([806d134](https://github.com/n8n-io/n8n/commit/806d13460240abe94843e569b1820cd8d0d8edd1)) +* **Merge Node:** Enrich input 2 fix ([#6526](https://github.com/n8n-io/n8n/issues/6526)) ([c82c7f1](https://github.com/n8n-io/n8n/commit/c82c7f19128df3a11d6d0f18e8d8dab57e6a3b8f)) +* **Notion Node:** Version fix ([#6531](https://github.com/n8n-io/n8n/issues/6531)) ([38dc784](https://github.com/n8n-io/n8n/commit/38dc784d2eed25aae777c5c3c3fda1a35e20bd24)) +* **Sendy Node:** Fix issue with brand id not being sent ([#6530](https://github.com/n8n-io/n8n/issues/6530)) ([2e8dfb8](https://github.com/n8n-io/n8n/commit/2e8dfb86d4636781b319d6190e8be12e7661ee16)) + + +### Features + +* Add missing input panels to some trigger nodes ([#6518](https://github.com/n8n-io/n8n/issues/6518)) ([fdf8a42](https://github.com/n8n-io/n8n/commit/fdf8a428ed38bb3ceb2bc0e50b002b34843d8fc4)) +* **editor:** Prevent saving of workflow when canvas is loading ([#6497](https://github.com/n8n-io/n8n/issues/6497)) ([f89ef83](https://github.com/n8n-io/n8n/commit/f89ef83c766fafb1d0497ed91a74b93e8d2af1ec)) +* **editor:** SQL editor overhaul ([#6282](https://github.com/n8n-io/n8n/issues/6282)) ([beedfb6](https://github.com/n8n-io/n8n/commit/beedfb609ccde2ef202e08566580a2e1a6b6eafa)) +* **Google Drive Node:** Overhaul ([#5941](https://github.com/n8n-io/n8n/issues/5941)) ([d70a1cb](https://github.com/n8n-io/n8n/commit/d70a1cb0c82ee0a4b92776684c6c9079020d028f)) +* **HTTP Request Node:** Notice about dev console ([#6516](https://github.com/n8n-io/n8n/issues/6516)) ([d431117](https://github.com/n8n-io/n8n/commit/d431117c9e5db9ff0ec6a1e7371bbf58698957c9)) +* **Matrix Node:** Allow setting filename if the binary data has none ([#6536](https://github.com/n8n-io/n8n/issues/6536)) ([8b76e98](https://github.com/n8n-io/n8n/commit/8b76e980852062b192a95593035697c43d6f808e)) + + + # [0.234.0](https://github.com/n8n-io/n8n/compare/n8n@0.233.0...n8n@0.234.0) (2023-06-22) diff --git a/package.json b/package.json index 5d6c77cf6c..cb3965743f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.234.0", + "version": "0.235.0", "private": true, "homepage": "https://n8n.io", "engines": { diff --git a/packages/cli/package.json b/packages/cli/package.json index a00f90620e..32bb56c577 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.234.0", + "version": "0.235.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/core/package.json b/packages/core/package.json index 51caa50a81..331d110463 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.173.0", + "version": "0.174.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 361c14fdf7..530976b463 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.200.0", + "version": "0.201.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index c1d17b7c97..b9b8126e1c 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "0.112.0", + "version": "0.113.0", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e260be8b82..6e85e8a1d1 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.232.0", + "version": "0.233.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 24a24e9627..a1ce633088 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "0.154.0", + "version": "0.155.0", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 42721dba80077fb796086a2bf0ecce256bf3a50f Mon Sep 17 00:00:00 2001 From: Giulio Andreini Date: Wed, 28 Jun 2023 12:19:25 +0200 Subject: [PATCH 23/46] feat(Twitter Node): Node overhaul (#4788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First node set up. * Progress: all Resources and Operations updated * Upsates to all resources. * Updated tooltip for Tweet > Search > Tweet fields. * Upodate to resource locator items in User > Search. * Added e.g. to placeholders and minor copy tweaks. * Fixed Operations sorting. * Added a couple of operations back. * Removed 'Authorized API Call'. * Remove authorization header when empty * Import pkce * Add OAuth2 with new grant type to Twitter * Add pkce logic auto assign authorization code if pkce not defined * Add pkce to ui and interfaces * Fix scopes for Oauth2 twitter * Deubg + pass it through header * Add debug console, add airtable cred * Remove all console.logs, make PKCE in th body only when it exists * Remove invalid character ~ * Remove more console.logs * remove body inside query * Remove useless grantype check * Hide oauth2 twitter waiting for overhaul * Remove redundant header removal * Remove more console.logs * Add V2 twitter * Add V1 * Fix description V1, V2 * Fix description for V1 * Add Oauth2 request * Add user lookup * Add search username by ID * Search tweet feat * Wip create tweet * Generic function for returning ID * Add like and retweet feat * Add delete tweet feat * Fix Location tweets * Fix type * Feat List add members * Add scopes for dm and list * Add direct message feature * Improve response data * Fix regex * Add unit test to Twitter v2 * Fix unit test * Remove console.logs * Remove more console.logs * Handle @ in username * Minor copy tweaks. * Add return all logic * Add error for api permission error * Update message api error * Add error for date error * Add notice for TwitterOAuth2 api link * Fix display names location * fix List RLC * Fix like endpoint * Fix error message check * fix(core): Fix OAuth2 callback for grantType=clientCredentials * Improve fix for callback * update pnpm * Fix iso time for end time * sync oauth2Credential * remove unused codeVerifer in Server.ts * Add location and attachments notice * Add notice to oauth1 * Improve notice for twitter * moved credentials notice to TwitterOAuth1Api.credentials.ts --------- Co-authored-by: agobrech Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ Co-authored-by: Marcus --- packages/@n8n/client-oauth2/src/CodeFlow.ts | 1 - .../@n8n/client-oauth2/src/CredentialsFlow.ts | 1 - .../TwitterOAuth1Api.credentials.ts | 7 + .../TwitterOAuth2Api.credentials.ts | 73 +++ .../nodes/Twitter/Twitter.node.json | 3 +- .../nodes-base/nodes/Twitter/Twitter.node.ts | 348 +------------ .../{ => V1}/DirectMessageDescription.ts | 0 .../Twitter/{ => V1}/GenericFunctions.ts | 0 .../Twitter/{ => V1}/TweetDescription.ts | 0 .../nodes/Twitter/{ => V1}/TweetInterface.ts | 0 .../nodes/Twitter/V1/TwitterV1.node.ts | 337 ++++++++++++ .../Twitter/V2/DirectMessageDescription.ts | 103 ++++ .../nodes/Twitter/V2/GenericFunctions.ts | 125 +++++ .../nodes/Twitter/V2/ListDescription.ts | 94 ++++ .../nodes/Twitter/V2/TweetDescription.ts | 479 ++++++++++++++++++ .../nodes/Twitter/V2/TweetInterface.ts | 25 + .../nodes/Twitter/V2/TwitterV2.node.ts | 365 +++++++++++++ .../nodes/Twitter/V2/UserDescription.ts | 78 +++ .../nodes/Twitter/test/Twitter.test.ts | 88 ++++ .../test/Workflow_Twitter_UnitTest.json | 284 +++++++++++ packages/nodes-base/package.json | 1 + .../test/nodes/FakeCredentialsMap.ts | 19 + 22 files changed, 2100 insertions(+), 331 deletions(-) create mode 100644 packages/nodes-base/credentials/TwitterOAuth2Api.credentials.ts rename packages/nodes-base/nodes/Twitter/{ => V1}/DirectMessageDescription.ts (100%) rename packages/nodes-base/nodes/Twitter/{ => V1}/GenericFunctions.ts (100%) rename packages/nodes-base/nodes/Twitter/{ => V1}/TweetDescription.ts (100%) rename packages/nodes-base/nodes/Twitter/{ => V1}/TweetInterface.ts (100%) create mode 100644 packages/nodes-base/nodes/Twitter/V1/TwitterV1.node.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/DirectMessageDescription.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/ListDescription.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/TweetDescription.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/TweetInterface.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/TwitterV2.node.ts create mode 100644 packages/nodes-base/nodes/Twitter/V2/UserDescription.ts create mode 100644 packages/nodes-base/nodes/Twitter/test/Twitter.test.ts create mode 100644 packages/nodes-base/nodes/Twitter/test/Workflow_Twitter_UnitTest.json diff --git a/packages/@n8n/client-oauth2/src/CodeFlow.ts b/packages/@n8n/client-oauth2/src/CodeFlow.ts index fceb71cdd5..7d3b842329 100644 --- a/packages/@n8n/client-oauth2/src/CodeFlow.ts +++ b/packages/@n8n/client-oauth2/src/CodeFlow.ts @@ -57,7 +57,6 @@ export class CodeFlow { opts?: Partial, ): Promise { const options = { ...this.client.options, ...opts }; - expects(options, 'clientId', 'accessTokenUri'); const url = uri instanceof URL ? uri : new URL(uri, DEFAULT_URL_BASE); diff --git a/packages/@n8n/client-oauth2/src/CredentialsFlow.ts b/packages/@n8n/client-oauth2/src/CredentialsFlow.ts index 1b6eb70e8e..d83450a412 100644 --- a/packages/@n8n/client-oauth2/src/CredentialsFlow.ts +++ b/packages/@n8n/client-oauth2/src/CredentialsFlow.ts @@ -21,7 +21,6 @@ export class CredentialsFlow { */ async getToken(opts?: Partial): Promise { const options = { ...this.client.options, ...opts }; - expects(options, 'clientId', 'clientSecret', 'accessTokenUri'); const body: CredentialsFlowBody = { diff --git a/packages/nodes-base/credentials/TwitterOAuth1Api.credentials.ts b/packages/nodes-base/credentials/TwitterOAuth1Api.credentials.ts index 5a576c89c7..63c50e001b 100644 --- a/packages/nodes-base/credentials/TwitterOAuth1Api.credentials.ts +++ b/packages/nodes-base/credentials/TwitterOAuth1Api.credentials.ts @@ -34,5 +34,12 @@ export class TwitterOAuth1Api implements ICredentialType { type: 'hidden', default: 'HMAC-SHA1', }, + { + displayName: + 'Some operations requires a Basic or a Pro API for more informations see Twitter Api Doc', + name: 'apiPermissioms', + type: 'notice', + default: '', + }, ]; } diff --git a/packages/nodes-base/credentials/TwitterOAuth2Api.credentials.ts b/packages/nodes-base/credentials/TwitterOAuth2Api.credentials.ts new file mode 100644 index 0000000000..e948daad39 --- /dev/null +++ b/packages/nodes-base/credentials/TwitterOAuth2Api.credentials.ts @@ -0,0 +1,73 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +const scopes = [ + 'tweet.read', + 'users.read', + 'tweet.write', + 'tweet.moderate.write', + 'users.read', + 'follows.read', + 'follows.write', + 'offline.access', + 'like.read', + 'like.write', + 'dm.write', + 'dm.read', + 'list.read', + 'list.write', +]; +export class TwitterOAuth2Api implements ICredentialType { + name = 'twitterOAuth2Api'; + + extends = ['oAuth2Api']; + + displayName = 'Twitter OAuth2 API'; + + documentationUrl = 'twitter'; + + properties: INodeProperties[] = [ + { + displayName: + 'Some operations requires a Basic or a Pro API for more informations see Twitter Api Doc', + name: 'apiPermissioms', + type: 'notice', + default: '', + }, + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'pkce', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://twitter.com/i/oauth2/authorize', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://api.twitter.com/2/oauth2/token', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: `${scopes.join(' ')}`, + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'header', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Twitter/Twitter.node.json b/packages/nodes-base/nodes/Twitter/Twitter.node.json index 7282e9b2ce..9d8fb0b276 100644 --- a/packages/nodes-base/nodes/Twitter/Twitter.node.json +++ b/packages/nodes-base/nodes/Twitter/Twitter.node.json @@ -31,5 +31,6 @@ "url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/" } ] - } + }, + "alias": ["Tweet"] } diff --git a/packages/nodes-base/nodes/Twitter/Twitter.node.ts b/packages/nodes-base/nodes/Twitter/Twitter.node.ts index 4f49f7a414..3e35c6a0f5 100644 --- a/packages/nodes-base/nodes/Twitter/Twitter.node.ts +++ b/packages/nodes-base/nodes/Twitter/Twitter.node.ts @@ -1,335 +1,27 @@ -import type { - IDataObject, - IExecuteFunctions, - ILoadOptionsFunctions, - INodeExecutionData, - INodePropertyOptions, - INodeType, - INodeTypeDescription, - JsonObject, -} from 'n8n-workflow'; +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; -import { directMessageFields, directMessageOperations } from './DirectMessageDescription'; +import { TwitterV1 } from './V1/TwitterV1.node'; -import { tweetFields, tweetOperations } from './TweetDescription'; +import { TwitterV2 } from './V2/TwitterV2.node'; -import { - twitterApiRequest, - twitterApiRequestAllItems, - uploadAttachments, -} from './GenericFunctions'; +export class Twitter extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Twitter', + name: 'twitter', + icon: 'file:twitter.svg', + group: ['output'], + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Twitter API', + defaultVersion: 2, + }; -import type { ITweet, ITweetCreate } from './TweetInterface'; + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new TwitterV1(baseDescription), + 2: new TwitterV2(baseDescription), + }; -import ISO6391 from 'iso-639-1'; - -export class Twitter implements INodeType { - description: INodeTypeDescription = { - displayName: 'Twitter', - name: 'twitter', - icon: 'file:twitter.svg', - group: ['input', 'output'], - version: 1, - description: 'Consume Twitter API', - subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}', - defaults: { - name: 'Twitter', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'twitterOAuth1Api', - required: true, - }, - ], - properties: [ - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Direct Message', - value: 'directMessage', - }, - { - name: 'Tweet', - value: 'tweet', - }, - ], - default: 'tweet', - }, - // DIRECT MESSAGE - ...directMessageOperations, - ...directMessageFields, - // TWEET - ...tweetOperations, - ...tweetFields, - ], - }; - - methods = { - loadOptions: { - // Get all the available languages to display them to user so that they can - // select them easily - async getLanguages(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const languages = ISO6391.getAllNames(); - for (const language of languages) { - const languageName = language; - const languageId = ISO6391.getCode(language); - returnData.push({ - name: languageName, - value: languageId, - }); - } - return returnData; - }, - }, - }; - - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - const returnData: INodeExecutionData[] = []; - const length = items.length; - let responseData; - const resource = this.getNodeParameter('resource', 0); - const operation = this.getNodeParameter('operation', 0); - for (let i = 0; i < length; i++) { - try { - if (resource === 'directMessage') { - //https://developer.twitter.com/en/docs/twitter-api/v1/direct-messages/sending-and-receiving/api-reference/new-event - if (operation === 'create') { - const userId = this.getNodeParameter('userId', i) as string; - const text = this.getNodeParameter('text', i) as string; - const additionalFields = this.getNodeParameter('additionalFields', i); - const body: ITweetCreate = { - type: 'message_create', - message_create: { - target: { - recipient_id: userId, - }, - message_data: { - text, - attachment: {}, - }, - }, - }; - - if (additionalFields.attachment) { - const attachment = additionalFields.attachment as string; - - const attachmentProperties: string[] = attachment.split(',').map((propertyName) => { - return propertyName.trim(); - }); - - const medias = await uploadAttachments.call(this, attachmentProperties, i); - body.message_create.message_data.attachment = { - type: 'media', - //@ts-ignore - media: { id: medias[0].media_id_string }, - }; - } else { - delete body.message_create.message_data.attachment; - } - - responseData = await twitterApiRequest.call( - this, - 'POST', - '/direct_messages/events/new.json', - { event: body }, - ); - - responseData = responseData.event; - } - } - if (resource === 'tweet') { - // https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update - if (operation === 'create') { - const text = this.getNodeParameter('text', i) as string; - const additionalFields = this.getNodeParameter('additionalFields', i); - const body: ITweet = { - status: text, - }; - - if (additionalFields.inReplyToStatusId) { - body.in_reply_to_status_id = additionalFields.inReplyToStatusId as string; - body.auto_populate_reply_metadata = true; - } - - if (additionalFields.attachments) { - const attachments = additionalFields.attachments as string; - - const attachmentProperties: string[] = attachments.split(',').map((propertyName) => { - return propertyName.trim(); - }); - - const medias = await uploadAttachments.call(this, attachmentProperties, i); - - body.media_ids = (medias as IDataObject[]) - .map((media: IDataObject) => media.media_id_string) - .join(','); - } - - if (additionalFields.possiblySensitive) { - body.possibly_sensitive = additionalFields.possiblySensitive as boolean; - } - - if (additionalFields.displayCoordinates) { - body.display_coordinates = additionalFields.displayCoordinates as boolean; - } - - if (additionalFields.locationFieldsUi) { - const locationUi = additionalFields.locationFieldsUi as IDataObject; - if (locationUi.locationFieldsValues) { - const values = locationUi.locationFieldsValues as IDataObject; - body.lat = parseFloat(values.latitude as string); - body.long = parseFloat(values.longitude as string); - } - } - - responseData = await twitterApiRequest.call( - this, - 'POST', - '/statuses/update.json', - {}, - body as unknown as IDataObject, - ); - } - // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-destroy-id - if (operation === 'delete') { - const tweetId = this.getNodeParameter('tweetId', i) as string; - - responseData = await twitterApiRequest.call( - this, - 'POST', - `/statuses/destroy/${tweetId}.json`, - {}, - {}, - ); - } - // https://developer.twitter.com/en/docs/tweets/search/api-reference/get-search-tweets - if (operation === 'search') { - const q = this.getNodeParameter('searchText', i) as string; - const returnAll = this.getNodeParameter('returnAll', i); - const additionalFields = this.getNodeParameter('additionalFields', i); - const qs: IDataObject = { - q, - }; - - if (additionalFields.includeEntities) { - qs.include_entities = additionalFields.includeEntities as boolean; - } - - if (additionalFields.resultType) { - qs.response_type = additionalFields.resultType as string; - } - - if (additionalFields.until) { - qs.until = additionalFields.until as string; - } - - if (additionalFields.lang) { - qs.lang = additionalFields.lang as string; - } - - if (additionalFields.locationFieldsUi) { - const locationUi = additionalFields.locationFieldsUi as IDataObject; - if (locationUi.locationFieldsValues) { - const values = locationUi.locationFieldsValues as IDataObject; - qs.geocode = `${values.latitude as string},${values.longitude as string},${ - values.distance - }${values.radius}`; - } - } - - qs.tweet_mode = additionalFields.tweetMode || 'compat'; - - if (returnAll) { - responseData = await twitterApiRequestAllItems.call( - this, - 'statuses', - 'GET', - '/search/tweets.json', - {}, - qs, - ); - } else { - qs.count = this.getNodeParameter('limit', 0); - responseData = await twitterApiRequest.call( - this, - 'GET', - '/search/tweets.json', - {}, - qs, - ); - responseData = responseData.statuses; - } - } - //https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-favorites-create - if (operation === 'like') { - const tweetId = this.getNodeParameter('tweetId', i) as string; - const additionalFields = this.getNodeParameter('additionalFields', i); - - const qs: IDataObject = { - id: tweetId, - }; - - if (additionalFields.includeEntities) { - qs.include_entities = additionalFields.includeEntities as boolean; - } - - responseData = await twitterApiRequest.call( - this, - 'POST', - '/favorites/create.json', - {}, - qs, - ); - } - //https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-retweet-id - if (operation === 'retweet') { - const tweetId = this.getNodeParameter('tweetId', i) as string; - const additionalFields = this.getNodeParameter('additionalFields', i); - - const qs: IDataObject = { - id: tweetId, - }; - - if (additionalFields.trimUser) { - qs.trim_user = additionalFields.trimUser as boolean; - } - - responseData = await twitterApiRequest.call( - this, - 'POST', - `/statuses/retweet/${tweetId}.json`, - {}, - qs, - ); - } - } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } catch (error) { - if (this.continueOnFail()) { - const executionErrorData = { - json: { - error: (error as JsonObject).message, - }, - }; - returnData.push(executionErrorData); - continue; - } - throw error; - } - } - - return this.prepareOutputData(returnData); + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Twitter/DirectMessageDescription.ts b/packages/nodes-base/nodes/Twitter/V1/DirectMessageDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Twitter/DirectMessageDescription.ts rename to packages/nodes-base/nodes/Twitter/V1/DirectMessageDescription.ts diff --git a/packages/nodes-base/nodes/Twitter/GenericFunctions.ts b/packages/nodes-base/nodes/Twitter/V1/GenericFunctions.ts similarity index 100% rename from packages/nodes-base/nodes/Twitter/GenericFunctions.ts rename to packages/nodes-base/nodes/Twitter/V1/GenericFunctions.ts diff --git a/packages/nodes-base/nodes/Twitter/TweetDescription.ts b/packages/nodes-base/nodes/Twitter/V1/TweetDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Twitter/TweetDescription.ts rename to packages/nodes-base/nodes/Twitter/V1/TweetDescription.ts diff --git a/packages/nodes-base/nodes/Twitter/TweetInterface.ts b/packages/nodes-base/nodes/Twitter/V1/TweetInterface.ts similarity index 100% rename from packages/nodes-base/nodes/Twitter/TweetInterface.ts rename to packages/nodes-base/nodes/Twitter/V1/TweetInterface.ts diff --git a/packages/nodes-base/nodes/Twitter/V1/TwitterV1.node.ts b/packages/nodes-base/nodes/Twitter/V1/TwitterV1.node.ts new file mode 100644 index 0000000000..ecb034d23a --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V1/TwitterV1.node.ts @@ -0,0 +1,337 @@ +import type { + IDataObject, + IExecuteFunctions, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, + JsonObject, +} from 'n8n-workflow'; + +import { directMessageFields, directMessageOperations } from './DirectMessageDescription'; + +import { tweetFields, tweetOperations } from './TweetDescription'; + +import { + twitterApiRequest, + twitterApiRequestAllItems, + uploadAttachments, +} from './GenericFunctions'; + +import type { ITweet, ITweetCreate } from './TweetInterface'; + +import ISO6391 from 'iso-639-1'; + +export class TwitterV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDecription: INodeTypeBaseDescription) { + this.description = { + ...baseDecription, + version: 1, + description: 'Consume Twitter API', + subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}', + defaults: { + name: 'Twitter', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'twitterOAuth1Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Direct Message', + value: 'directMessage', + }, + { + name: 'Tweet', + value: 'tweet', + }, + ], + default: 'tweet', + }, + // DIRECT MESSAGE + ...directMessageOperations, + ...directMessageFields, + // TWEET + ...tweetOperations, + ...tweetFields, + ], + }; + } + + methods = { + loadOptions: { + // Get all the available languages to display them to user so that they can + // select them easily + async getLanguages(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const languages = ISO6391.getAllNames(); + for (const language of languages) { + const languageName = language; + const languageId = ISO6391.getCode(language); + returnData.push({ + name: languageName, + value: languageId, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + const length = items.length; + let responseData; + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + for (let i = 0; i < length; i++) { + try { + if (resource === 'directMessage') { + //https://developer.twitter.com/en/docs/twitter-api/v1/direct-messages/sending-and-receiving/api-reference/new-event + if (operation === 'create') { + const userId = this.getNodeParameter('userId', i) as string; + const text = this.getNodeParameter('text', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i); + const body: ITweetCreate = { + type: 'message_create', + message_create: { + target: { + recipient_id: userId, + }, + message_data: { + text, + attachment: {}, + }, + }, + }; + + if (additionalFields.attachment) { + const attachment = additionalFields.attachment as string; + + const attachmentProperties: string[] = attachment.split(',').map((propertyName) => { + return propertyName.trim(); + }); + + const medias = await uploadAttachments.call(this, attachmentProperties, i); + body.message_create.message_data.attachment = { + type: 'media', + //@ts-ignore + media: { id: medias[0].media_id_string }, + }; + } else { + delete body.message_create.message_data.attachment; + } + + responseData = await twitterApiRequest.call( + this, + 'POST', + '/direct_messages/events/new.json', + { event: body }, + ); + + responseData = responseData.event; + } + } + if (resource === 'tweet') { + // https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update + if (operation === 'create') { + const text = this.getNodeParameter('text', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i); + const body: ITweet = { + status: text, + }; + + if (additionalFields.inReplyToStatusId) { + body.in_reply_to_status_id = additionalFields.inReplyToStatusId as string; + body.auto_populate_reply_metadata = true; + } + + if (additionalFields.attachments) { + const attachments = additionalFields.attachments as string; + + const attachmentProperties: string[] = attachments.split(',').map((propertyName) => { + return propertyName.trim(); + }); + + const medias = await uploadAttachments.call(this, attachmentProperties, i); + + body.media_ids = (medias as IDataObject[]) + .map((media: IDataObject) => media.media_id_string) + .join(','); + } + + if (additionalFields.possiblySensitive) { + body.possibly_sensitive = additionalFields.possiblySensitive as boolean; + } + + if (additionalFields.displayCoordinates) { + body.display_coordinates = additionalFields.displayCoordinates as boolean; + } + + if (additionalFields.locationFieldsUi) { + const locationUi = additionalFields.locationFieldsUi as IDataObject; + if (locationUi.locationFieldsValues) { + const values = locationUi.locationFieldsValues as IDataObject; + body.lat = parseFloat(values.latitude as string); + body.long = parseFloat(values.longitude as string); + } + } + + responseData = await twitterApiRequest.call( + this, + 'POST', + '/statuses/update.json', + {}, + body as unknown as IDataObject, + ); + } + // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-destroy-id + if (operation === 'delete') { + const tweetId = this.getNodeParameter('tweetId', i) as string; + + responseData = await twitterApiRequest.call( + this, + 'POST', + `/statuses/destroy/${tweetId}.json`, + {}, + {}, + ); + } + // https://developer.twitter.com/en/docs/tweets/search/api-reference/get-search-tweets + if (operation === 'search') { + const q = this.getNodeParameter('searchText', i) as string; + const returnAll = this.getNodeParameter('returnAll', i); + const additionalFields = this.getNodeParameter('additionalFields', i); + const qs: IDataObject = { + q, + }; + + if (additionalFields.includeEntities) { + qs.include_entities = additionalFields.includeEntities as boolean; + } + + if (additionalFields.resultType) { + qs.response_type = additionalFields.resultType as string; + } + + if (additionalFields.until) { + qs.until = additionalFields.until as string; + } + + if (additionalFields.lang) { + qs.lang = additionalFields.lang as string; + } + + if (additionalFields.locationFieldsUi) { + const locationUi = additionalFields.locationFieldsUi as IDataObject; + if (locationUi.locationFieldsValues) { + const values = locationUi.locationFieldsValues as IDataObject; + qs.geocode = `${values.latitude as string},${values.longitude as string},${ + values.distance + }${values.radius}`; + } + } + + qs.tweet_mode = additionalFields.tweetMode || 'compat'; + + if (returnAll) { + responseData = await twitterApiRequestAllItems.call( + this, + 'statuses', + 'GET', + '/search/tweets.json', + {}, + qs, + ); + } else { + qs.count = this.getNodeParameter('limit', 0); + responseData = await twitterApiRequest.call( + this, + 'GET', + '/search/tweets.json', + {}, + qs, + ); + responseData = responseData.statuses; + } + } + //https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-favorites-create + if (operation === 'like') { + const tweetId = this.getNodeParameter('tweetId', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i); + + const qs: IDataObject = { + id: tweetId, + }; + + if (additionalFields.includeEntities) { + qs.include_entities = additionalFields.includeEntities as boolean; + } + + responseData = await twitterApiRequest.call( + this, + 'POST', + '/favorites/create.json', + {}, + qs, + ); + } + //https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-retweet-id + if (operation === 'retweet') { + const tweetId = this.getNodeParameter('tweetId', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i); + + const qs: IDataObject = { + id: tweetId, + }; + + if (additionalFields.trimUser) { + qs.trim_user = additionalFields.trimUser as boolean; + } + + responseData = await twitterApiRequest.call( + this, + 'POST', + `/statuses/retweet/${tweetId}.json`, + {}, + qs, + ); + } + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = { + json: { + error: (error as JsonObject).message, + }, + }; + returnData.push(executionErrorData); + continue; + } + throw error; + } + } + + return this.prepareOutputData(returnData); + } +} diff --git a/packages/nodes-base/nodes/Twitter/V2/DirectMessageDescription.ts b/packages/nodes-base/nodes/Twitter/V2/DirectMessageDescription.ts new file mode 100644 index 0000000000..2e98afede1 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/DirectMessageDescription.ts @@ -0,0 +1,103 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const directMessageOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['directMessage'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Send a direct message to a user', + action: 'Create Direct Message', + }, + ], + default: 'create', + }, +]; + +export const directMessageFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* directMessage:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User', + name: 'user', + type: 'resourceLocator', + default: { mode: 'username', value: '' }, + required: true, + description: 'The user you want to send the message to', + displayOptions: { + show: { + operation: ['create'], + resource: ['directMessage'], + }, + }, + modes: [ + { + displayName: 'By Username', + name: 'username', + type: 'string', + validation: [], + placeholder: 'e.g. n8n', + url: '', + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1068479892537384960', + url: '', + }, + ], + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + required: true, + default: '', + typeOptions: { + rows: 2, + }, + displayOptions: { + show: { + operation: ['create'], + resource: ['directMessage'], + }, + }, + description: + 'The text of the direct message. URL encoding is required. Max length of 10,000 characters.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: ['create'], + resource: ['directMessage'], + }, + }, + options: [ + { + displayName: 'Attachment ID', + name: 'attachments', + type: 'string', + default: '', + placeholder: '1664279886239010824', + description: 'The attachment ID to associate with the message', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Twitter/V2/GenericFunctions.ts b/packages/nodes-base/nodes/Twitter/V2/GenericFunctions.ts new file mode 100644 index 0000000000..2064e05a82 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/GenericFunctions.ts @@ -0,0 +1,125 @@ +import type { OptionsWithUrl } from 'request'; + +import type { + IDataObject, + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, + INodeParameterResourceLocator, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; + +export async function twitterApiRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IHookFunctions, + method: string, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, + fullOutput?: boolean, + uri?: string, + option: IDataObject = {}, +) { + let options: OptionsWithUrl = { + method, + body, + qs, + url: uri || `https://api.twitter.com/2${resource}`, + json: true, + }; + try { + if (Object.keys(option).length !== 0) { + options = Object.assign({}, options, option); + } + if (Object.keys(body).length === 0) { + delete options.body; + } + if (Object.keys(qs).length === 0) { + delete options.qs; + } + if (fullOutput) { + return await this.helpers.requestOAuth2.call(this, 'twitterOAuth2Api', options); + } else { + const { data } = await this.helpers.requestOAuth2.call(this, 'twitterOAuth2Api', options); + return data; + } + } catch (error) { + if (error.error?.required_enrollment === 'Appropriate Level of API Access') { + throw new NodeOperationError( + this.getNode(), + 'The operation requires Twitter Api to be either Basic or Pro.', + ); + } else if (error.errors && error.error?.errors[0].message.includes('must be ')) { + throw new NodeOperationError(this.getNode(), error.error.errors[0].message as string); + } + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +export async function twitterApiRequestAllItems( + this: IExecuteFunctions | ILoadOptionsFunctions, + propertyName: string, + method: string, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {}, +) { + const returnData: IDataObject[] = []; + + let responseData; + + query.max_results = 10; + + do { + responseData = await twitterApiRequest.call(this, method, endpoint, body, query, true); + query.next_token = responseData.meta.next_token as string; + returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]); + } while (responseData.meta.next_token); + + return returnData; +} + +export function returnId(tweetId: INodeParameterResourceLocator) { + if (tweetId.mode === 'id') { + return tweetId.value as string; + } else if (tweetId.mode === 'url') { + const value = tweetId.value as string; + const tweetIdMatch = value.includes('lists') + ? value.match(/^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/list(s)?\/(\d+)$/) + : value.match(/^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(es)?\/(\d+)$/); + + return tweetIdMatch?.[3] as string; + } else { + throw new Error(`The mode ${tweetId.mode} is not valid!`); + } +} + +export async function returnIdFromUsername( + this: IExecuteFunctions, + usernameRlc: INodeParameterResourceLocator, +) { + usernameRlc.value = (usernameRlc.value as string).includes('@') + ? (usernameRlc.value as string).replace('@', '') + : usernameRlc.value; + if ( + usernameRlc.mode === 'username' || + (usernameRlc.mode === 'name' && this.getNode().parameters.list !== undefined) + ) { + const user = (await twitterApiRequest.call( + this, + 'GET', + `/users/by/username/${usernameRlc.value}`, + {}, + )) as { id: string }; + return user.id; + } else if (this.getNode().parameters.list === undefined) { + const list = (await twitterApiRequest.call( + this, + 'GET', + `/list/by/name/${usernameRlc.value}`, + {}, + )) as { id: string }; + return list.id; + } else throw new Error(`The username mode ${usernameRlc.mode} is not valid!`); +} diff --git a/packages/nodes-base/nodes/Twitter/V2/ListDescription.ts b/packages/nodes-base/nodes/Twitter/V2/ListDescription.ts new file mode 100644 index 0000000000..430e1bcaa9 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/ListDescription.ts @@ -0,0 +1,94 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const listOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['list'], + }, + }, + options: [ + { + name: 'Add Member', + value: 'add', + description: 'Add a member to a list', + action: 'Add Member to List', + }, + ], + default: 'add', + }, +]; + +export const listFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* list:add */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'List', + name: 'list', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The list you want to add the user to', + displayOptions: { + show: { + operation: ['add'], + resource: ['list'], + }, + }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 99923132', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/i/lists/99923132', + url: '', + }, + ], + }, + { + displayName: 'User', + name: 'user', + type: 'resourceLocator', + default: { mode: 'username', value: '' }, + required: true, + description: 'The user you want to add to the list', + displayOptions: { + show: { + operation: ['add'], + resource: ['list'], + }, + }, + modes: [ + { + displayName: 'By Username', + name: 'username', + type: 'string', + validation: [], + placeholder: 'e.g. n8n', + url: '', + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1068479892537384960', + url: '', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Twitter/V2/TweetDescription.ts b/packages/nodes-base/nodes/Twitter/V2/TweetDescription.ts new file mode 100644 index 0000000000..b7fb9006ef --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/TweetDescription.ts @@ -0,0 +1,479 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const tweetOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['tweet'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create, quote, or reply to a tweet', + action: 'Create Tweet', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a tweet', + action: 'Delete Tweet', + }, + { + name: 'Like', + value: 'like', + description: 'Like a tweet', + action: 'Like Tweet', + }, + { + name: 'Retweet', + value: 'retweet', + description: 'Retweet a tweet', + action: 'Retweet Tweet', + }, + { + name: 'Search', + value: 'search', + description: 'Search for tweets from the last seven days', + action: 'Search Tweets', + }, + ], + default: 'create', + }, +]; + +export const tweetFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* tweet:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Text', + name: 'text', + type: 'string', + typeOptions: { + rows: 2, + }, + default: '', + required: true, + displayOptions: { + show: { + operation: ['create'], + resource: ['tweet'], + }, + }, + description: + 'The text of the status update. URLs must be encoded. Links wrapped with the t.co shortener will affect character count', + }, + { + displayName: 'Options', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: ['create'], + resource: ['tweet'], + }, + }, + options: [ + { + displayName: 'Location ID', + name: 'location', + type: 'string', + placeholder: '4e696bef7e24d378', + default: '', + description: 'Location information for the tweet', + }, + { + displayName: 'Media ID', + name: 'attachments', + type: 'string', + default: '', + placeholder: '1664279886239010824', + description: 'The attachment ID to associate with the message', + }, + { + displayName: 'Quote a Tweet', + name: 'inQuoteToStatusId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + description: 'The tweet being quoted', + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + { + displayName: 'Reply to Tweet', + name: 'inReplyToStatusId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + // required: true, + description: 'The tweet being replied to', + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + ], + }, + { + displayName: 'Locations are not supported due to Twitter V2 API limitations', + name: 'noticeLocation', + type: 'notice', + displayOptions: { + show: { + '/additionalFields.location': [''], + }, + }, + default: '', + }, + { + displayName: 'Attachements are not supported due to Twitter V2 API limitations', + name: 'noticeAttachments', + type: 'notice', + displayOptions: { + show: { + '/additionalFields.attachments': [''], + }, + }, + default: '', + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Tweet', + name: 'tweetDeleteId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The tweet to delete', + displayOptions: { + show: { + resource: ['tweet'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:like */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Tweet', + name: 'tweetId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The tweet to like', + displayOptions: { + show: { + operation: ['like'], + resource: ['tweet'], + }, + }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:search */ + /* -------------------------------------------------------------------------- */ + { + // displayName: 'Search Text', + displayName: 'Search Term', + name: 'searchText', + type: 'string', + required: true, + default: '', + placeholder: 'e.g. automation', + displayOptions: { + show: { + operation: ['search'], + resource: ['tweet'], + }, + }, + description: + 'A UTF-8, URL-encoded search query of 500 characters maximum, including operators. Queries may additionally be limited by complexity. Check the searching examples here.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: ['tweet'], + operation: ['search'], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'Max number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: ['tweet'], + operation: ['search'], + returnAll: [false], + }, + }, + }, + { + displayName: 'Options', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: ['search'], + resource: ['tweet'], + }, + }, + options: [ + { + displayName: 'Sort Order', + name: 'sortOrder', + type: 'options', + options: [ + { + name: 'Recent', + value: 'recency', + }, + { + name: 'Relevant', + value: 'relevancy', + }, + ], + // required: true, + description: 'The order in which to return results', + default: 'recency', + }, + { + displayName: 'After', + name: 'startTime', + type: 'dateTime', + default: '', + description: + "Tweets before this date will not be returned. This date must be within the last 7 days if you don't have Academic Research access.", + }, + { + displayName: 'Before', + name: 'endTime', + type: 'dateTime', + default: '', + description: + "Tweets after this date will not be returned. This date must be within the last 7 days if you don't have Academic Research access.", + }, + { + displayName: 'Tweet Fields', + name: 'tweetFieldsObject', + type: 'multiOptions', + // eslint-disable-next-line n8n-nodes-base/node-param-multi-options-type-unsorted-items + options: [ + { + name: 'Attachments', + value: 'attachments', + }, + { + name: 'Author ID', + value: 'author_id', + }, + { + name: 'Context Annotations', + value: 'context_annotations', + }, + { + name: 'Conversation ID', + value: 'conversation_id', + }, + { + name: 'Created At', + value: 'created_at', + }, + { + name: 'Edit Controls', + value: 'edit_controls', + }, + { + name: 'Entities', + value: 'entities', + }, + { + name: 'Geo', + value: 'geo', + }, + { + name: 'ID', + value: 'id', + }, + { + name: 'In Reply To User ID', + value: 'in_reply_to_user_id', + }, + { + name: 'Lang', + value: 'lang', + }, + { + name: 'Non Public Metrics', + value: 'non_public_metrics', + }, + { + name: 'Public Metrics', + value: 'public_metrics', + }, + { + name: 'Organic Metrics', + value: 'organic_metrics', + }, + { + name: 'Promoted Metrics', + value: 'promoted_metrics', + }, + { + name: 'Possibly Sensitive', + value: 'possibly_sensitive', + }, + { + name: 'Referenced Tweets', + value: 'referenced_tweets', + }, + { + name: 'Reply Settings', + value: 'reply_settings', + }, + { + name: 'Source', + value: 'source', + }, + { + name: 'Text', + value: 'text', + }, + { + name: 'Withheld', + value: 'withheld', + }, + ], + default: [], + description: + 'The fields to add to each returned tweet object. Default fields are: ID, text, edit_history_tweet_ids.', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* tweet:retweet */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Tweet', + name: 'tweetId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + description: 'The tweet to retweet', + displayOptions: { + show: { + operation: ['retweet'], + resource: ['tweet'], + }, + }, + modes: [ + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1187836157394112513', + url: '', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + validation: [], + placeholder: 'e.g. https://twitter.com/n8n_io/status/1187836157394112513', + url: '', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Twitter/V2/TweetInterface.ts b/packages/nodes-base/nodes/Twitter/V2/TweetInterface.ts new file mode 100644 index 0000000000..05fb67e260 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/TweetInterface.ts @@ -0,0 +1,25 @@ +import type { IDataObject } from 'n8n-workflow'; + +export interface ITweet { + auto_populate_reply_metadata?: boolean; + display_coordinates?: boolean; + lat?: number; + long?: number; + media_ids?: string; + possibly_sensitive?: boolean; + status: string; + in_reply_to_status_id?: string; +} + +export interface ITweetCreate { + type: 'message_create'; + message_create: { + target: { + recipient_id: string; + }; + message_data: { + text: string; + attachment?: IDataObject; + }; + }; +} diff --git a/packages/nodes-base/nodes/Twitter/V2/TwitterV2.node.ts b/packages/nodes-base/nodes/Twitter/V2/TwitterV2.node.ts new file mode 100644 index 0000000000..10fd2e42a2 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/TwitterV2.node.ts @@ -0,0 +1,365 @@ +import type { + IDataObject, + IExecuteFunctions, + ILoadOptionsFunctions, + INodeExecutionData, + INodeParameterResourceLocator, + INodePropertyOptions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, + JsonObject, +} from 'n8n-workflow'; + +import { directMessageOperations, directMessageFields } from './DirectMessageDescription'; +import { listOperations, listFields } from './ListDescription'; +import { tweetFields, tweetOperations } from './TweetDescription'; +import { userOperations, userFields } from './UserDescription'; + +import ISO6391 from 'iso-639-1'; +import { + returnId, + returnIdFromUsername, + twitterApiRequest, + twitterApiRequestAllItems, +} from './GenericFunctions'; +import { DateTime } from 'luxon'; + +export class TwitterV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: 2, + description: + 'Post, like, and search tweets, send messages, search users, and add users to lists', + subtitle: '={{$parameter["operation"] + ":" + $parameter["resource"]}}', + defaults: { + name: 'Twitter', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'twitterOAuth2Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Direct Message', + value: 'directMessage', + description: 'Send a direct message to a user', + }, + { + name: 'List', + value: 'list', + description: 'Add a user to a list', + }, + { + name: 'Tweet', + value: 'tweet', + description: 'Create, like, search, or delete a tweet', + }, + { + name: 'User', + value: 'user', + description: 'Search users by username', + }, + ], + default: 'tweet', + }, + // DIRECT MESSAGE + ...directMessageOperations, + ...directMessageFields, + // LIST + ...listOperations, + ...listFields, + // TWEET + ...tweetOperations, + ...tweetFields, + // USER + ...userOperations, + ...userFields, + ], + }; + } + + methods = { + loadOptions: { + // Get all the available languages to display them to user so that they can + // select them easily + async getLanguages(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const languages = ISO6391.getAllNames(); + for (const language of languages) { + const languageName = language; + const languageId = ISO6391.getCode(language); + returnData.push({ + name: languageName, + value: languageId, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + const length = items.length; + let responseData; + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + for (let i = 0; i < length; i++) { + try { + if (resource === 'user') { + if (operation === 'searchUser') { + const me = this.getNodeParameter('me', i, false) as boolean; + if (me) { + responseData = await twitterApiRequest.call(this, 'GET', '/users/me', {}); + } else { + const userRlc = this.getNodeParameter( + 'user', + i, + undefined, + {}, + ) as INodeParameterResourceLocator; + if (userRlc.mode === 'username') { + userRlc.value = (userRlc.value as string).includes('@') + ? (userRlc.value as string).replace('@', '') + : userRlc.value; + responseData = await twitterApiRequest.call( + this, + 'GET', + `/users/by/username/${userRlc.value}`, + {}, + ); + } else if (userRlc.mode === 'id') { + responseData = await twitterApiRequest.call( + this, + 'GET', + `/users/${userRlc.value}`, + {}, + ); + } + } + } + } + if (resource === 'tweet') { + if (operation === 'search') { + const searchText = this.getNodeParameter('searchText', i, '', {}); + const returnAll = this.getNodeParameter('returnAll', i); + const { sortOrder, startTime, endTime, tweetFieldsObject } = this.getNodeParameter( + 'additionalFields', + i, + {}, + ) as { + sortOrder: string; + startTime: string; + endTime: string; + tweetFieldsObject: string[]; + }; + const qs: IDataObject = { + query: searchText, + }; + if (endTime) { + const endTimeISO = DateTime.fromISO(endTime).toISO(); + qs.end_time = endTimeISO; + } + if (sortOrder) { + qs.sort_order = sortOrder; + } + if (startTime) { + const startTimeISO8601 = DateTime.fromISO(startTime).toISO(); + qs.start_time = startTimeISO8601; + } + if (tweetFieldsObject) { + if (tweetFieldsObject.length > 0) { + qs['tweet.fields'] = tweetFieldsObject.join(','); + } + } + if (returnAll) { + responseData = await twitterApiRequestAllItems.call( + this, + 'data', + 'GET', + '/tweets/search/recent', + {}, + qs, + ); + } else { + const limit = this.getNodeParameter('limit', i); + qs.max_results = limit; + responseData = await twitterApiRequest.call( + this, + 'GET', + '/tweets/search/recent', + {}, + qs, + ); + } + } + if (operation === 'create') { + const text = this.getNodeParameter('text', i, '', {}); + const { location, attachments, inQuoteToStatusId, inReplyToStatusId } = + this.getNodeParameter('additionalFields', i, {}) as { + location: string; + attachments: string; + inQuoteToStatusId: INodeParameterResourceLocator; + inReplyToStatusId: INodeParameterResourceLocator; + }; + const body: IDataObject = { + text, + }; + if (location) { + body.geo = { place_id: location }; + } + if (attachments) { + body.media = { media_ids: [attachments] }; + } + if (inQuoteToStatusId) { + body.quote_tweet_id = returnId(inQuoteToStatusId); + } + if (inReplyToStatusId) { + const inReplyToStatusIdValue = { in_reply_to_tweet_id: returnId(inReplyToStatusId) }; + body.reply = inReplyToStatusIdValue; + } + responseData = await twitterApiRequest.call(this, 'POST', '/tweets', body); + } + if (operation === 'delete') { + const tweetRLC = this.getNodeParameter( + 'tweetDeleteId', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const tweetId = returnId(tweetRLC); + responseData = await twitterApiRequest.call(this, 'DELETE', `/tweets/${tweetId}`, {}); + } + if (operation === 'like') { + const tweetRLC = this.getNodeParameter( + 'tweetId', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const tweetId = returnId(tweetRLC); + const body: IDataObject = { + tweet_id: tweetId, + }; + const user = (await twitterApiRequest.call(this, 'GET', '/users/me', {})) as { + id: string; + }; + responseData = await twitterApiRequest.call( + this, + 'POST', + `/users/${user.id}/likes`, + body, + ); + } + if (operation === 'retweet') { + const tweetRLC = this.getNodeParameter( + 'tweetId', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const tweetId = returnId(tweetRLC); + const body: IDataObject = { + tweet_id: tweetId, + }; + const user = (await twitterApiRequest.call(this, 'GET', '/users/me', {})) as { + id: string; + }; + responseData = await twitterApiRequest.call( + this, + 'POST', + `/users/${user.id}/retweets`, + body, + ); + } + } + if (resource === 'list') { + if (operation === 'add') { + const userRlc = this.getNodeParameter( + 'user', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const userId = + userRlc.mode !== 'username' + ? returnId(userRlc) + : await returnIdFromUsername.call(this, userRlc); + const listRlc = this.getNodeParameter( + 'list', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const listId = returnId(listRlc); + responseData = await twitterApiRequest.call(this, 'POST', `/lists/${listId}/members`, { + user_id: userId, + }); + } + } + if (resource === 'directMessage') { + if (operation === 'create') { + const userRlc = this.getNodeParameter( + 'user', + i, + '', + {}, + ) as INodeParameterResourceLocator; + const user = await returnIdFromUsername.call(this, userRlc); + const text = this.getNodeParameter('text', i, '', {}); + const { attachments } = this.getNodeParameter('additionalFields', i, {}, {}) as { + attachments: number; + }; + const body: IDataObject = { + text, + }; + + if (attachments) { + body.attachments = [{ media_id: attachments }]; + } + + responseData = await twitterApiRequest.call( + this, + 'POST', + `/dm_conversations/with/${user}/messages`, + body, + ); + } + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = { + json: { + error: (error as JsonObject).message, + }, + }; + returnData.push(executionErrorData); + continue; + } + throw error; + } + } + + return this.prepareOutputData(returnData); + } +} diff --git a/packages/nodes-base/nodes/Twitter/V2/UserDescription.ts b/packages/nodes-base/nodes/Twitter/V2/UserDescription.ts new file mode 100644 index 0000000000..9f7c9d6ff8 --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/V2/UserDescription.ts @@ -0,0 +1,78 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const userOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Get', + value: 'searchUser', + description: 'Retrieve a user by username', + action: 'Get User', + }, + ], + default: 'searchUser', + }, +]; + +export const userFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* user:searchUser */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User', + name: 'user', + type: 'resourceLocator', + default: { mode: 'username', value: '' }, + required: true, + description: 'The user you want to search', + displayOptions: { + show: { + operation: ['searchUser'], + resource: ['user'], + }, + hide: { + me: [true], + }, + }, + modes: [ + { + displayName: 'By Username', + name: 'username', + type: 'string', + validation: [], + placeholder: 'e.g. n8n', + url: '', + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [], + placeholder: 'e.g. 1068479892537384960', + url: '', + }, + ], + }, + { + displayName: 'Me', + name: 'me', + type: 'boolean', + displayOptions: { + show: { + operation: ['searchUser'], + resource: ['user'], + }, + }, + default: false, + description: 'Whether you want to search the authenticated user', + }, +]; diff --git a/packages/nodes-base/nodes/Twitter/test/Twitter.test.ts b/packages/nodes-base/nodes/Twitter/test/Twitter.test.ts new file mode 100644 index 0000000000..e9e1e09bae --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/test/Twitter.test.ts @@ -0,0 +1,88 @@ +import { getWorkflowFilenames, testWorkflows } from '../../../test/nodes/Helpers'; + +import nock from 'nock'; + +const searchResult = { + data: [ + { + edit_history_tweet_ids: ['1666357334740811776'], + id: '1666357334740811776', + text: 'RT @business: Extreme heat is happening earlier than usual this year in Asia. That’s posing a grave risk to agriculture and industrial acti…', + }, + { + edit_history_tweet_ids: ['1666357331276230656'], + id: '1666357331276230656', + text: + '@ROBVME @sheepsleepdeep @mattxiv Bit like Bloomberg, but then for an incredible average beer brand, to which grown ass adults have some deeply emotional attachment to and going through a teenage break-up with, because a tiktok.\n' + + 'Got it.', + }, + { + edit_history_tweet_ids: ['1666357319381180417'], + id: '1666357319381180417', + text: "The global economy is set for a weak recovery from the shocks of Covid and Russia’s war in Ukraine, dogged by persistent inflation and central banks' restrictive policies, the OECD warns https://t.co/HPtelXu8iR https://t.co/rziWHhr8Np", + }, + { + edit_history_tweet_ids: ['1666357315946303488'], + id: '1666357315946303488', + text: + 'RT @lukedepulford: Love this so much. Variations of “Glory to Hong Kong” are THE WHOLE TOP TEN of the most downloaded song on iTunes.\n' + + '\n' + + '✊\n' + + '\n' + + 'h…', + }, + { + edit_history_tweet_ids: ['1666357265320869891'], + id: '1666357265320869891', + text: 'RT @business: The SEC said it’s seeking to freeze https://t.co/35sr7lifRX’s assets and protect customer funds, including through the repatr…', + }, + { + edit_history_tweet_ids: ['1666357244760555520'], + id: '1666357244760555520', + text: 'RT @BloombergJapan: オプション市場で日経平均先高観強まる、3万4000円に備える買い急増 https://t.co/mIcdkgokYj', + }, + { + edit_history_tweet_ids: ['1666357239710359552'], + id: '1666357239710359552', + text: "Twitter'a mı girdim bloomberg mi anlamadım,dolar euro altın..maşallahları var,tl mi onun anası sikilmiş.", + }, + { + edit_history_tweet_ids: ['1666357235340165120'], + id: '1666357235340165120', + text: 'RT @business: These charts show why Germany needs mass migration https://t.co/rvZixuwwnu', + }, + { + edit_history_tweet_ids: ['1666357210409213952'], + id: '1666357210409213952', + text: 'RT @elonmusk: @MattWalshBlog View count is actually understated, as it does not include anything from our API, for example tweets you see i…', + }, + { + edit_history_tweet_ids: ['1666357208983166976'], + id: '1666357208983166976', + text: 'RT @coinbureau: There we go. Without proving in a court of law that these tokens are "securities" the SEC may be able to restrict access to…', + }, + ], +}; + +const meResult = { + data: { id: '1285192200213626880', name: 'Integration-n8n', username: 'IntegrationN8n' }, +}; +describe('Test Twitter Request Node', () => { + beforeAll(() => { + const baseUrl = 'https://api.twitter.com/2'; + nock.disableNetConnect(); + //GET + nock(baseUrl).get('/users/me').reply(200, meResult); + + nock(baseUrl) + .get('/tweets/search/recent?query=bloomberg&max_results=10') + .reply(200, searchResult); + }); + + afterEach(() => { + nock.restore(); + }); + + const workflows = getWorkflowFilenames(__dirname); + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Twitter/test/Workflow_Twitter_UnitTest.json b/packages/nodes-base/nodes/Twitter/test/Workflow_Twitter_UnitTest.json new file mode 100644 index 0000000000..54548cf88e --- /dev/null +++ b/packages/nodes-base/nodes/Twitter/test/Workflow_Twitter_UnitTest.json @@ -0,0 +1,284 @@ +{ + "name": "node-1-twitter-node-overhaul", + "nodes": [ + { + "parameters": {}, + "id": "91cdc3d3-9cf7-4fe0-b74c-b17e0c8b404d", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-420, 80] + }, + { + "parameters": { + "resource": "user", + "me": true + }, + "id": "4ee34d07-db6c-413b-95f3-932182770044", + "name": "Twitter1", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [-140, 260], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + }, + "disabled": false + }, + { + "parameters": { + "operation": "delete", + "tweetDeleteId": { + "__rl": true, + "value": "={{ $('Twitter').item.json.id }}", + "mode": "id" + } + }, + "id": "ceca5fb1-f4b7-4dbe-9505-61793bffd87a", + "name": "Twitter3", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [240, 80], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + }, + "disabled": true + }, + { + "parameters": { + "operation": "retweet", + "tweetId": { + "__rl": true, + "value": "={{ $json.id }}", + "mode": "id" + }, + "additionalFields": {} + }, + "id": "ce49286a-04bb-4f59-bae5-ef64a1bca2b0", + "name": "Twitter2", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [40, 80], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + }, + "disabled": true + }, + { + "parameters": { + "text": "test5", + "additionalFields": {} + }, + "id": "370e20de-dca6-48ab-aa24-47fb326bad77", + "name": "Twitter", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [-140, 80], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + }, + "disabled": true + }, + { + "parameters": { + "resource": "list", + "list": { + "__rl": true, + "value": "https://twitter.com/i/lists/1663852298521419776", + "mode": "url" + }, + "user": { + "__rl": true, + "value": "n8n_io", + "mode": "username" + } + }, + "id": "ea1c13af-80b8-41d4-8591-e16b8e7be84b", + "name": "Twitter7", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [-140, 640], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + }, + "disabled": true + }, + { + "parameters": { + "operation": "search", + "searchText": "bloomberg", + "limit": 10, + "additionalFields": {} + }, + "id": "216b3061-a52b-4d64-9ed8-9cc6940f6efa", + "name": "Twitter5", + "type": "n8n-nodes-base.twitter", + "typeVersion": 2, + "position": [-140, 440], + "credentials": { + "twitterOAuth2Api": { + "id": "121", + "name": "Twitter OAuth account 2" + } + } + } + ], + "pinData": { + "Twitter1": [ + { + "json": { + "id": "1285192200213626880", + "name": "Integration-n8n", + "username": "IntegrationN8n" + } + } + ], + "Twitter5": [ + { + "json": { + "edit_history_tweet_ids": ["1666357334740811776"], + "id": "1666357334740811776", + "text": "RT @business: Extreme heat is happening earlier than usual this year in Asia. That’s posing a grave risk to agriculture and industrial acti…" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357331276230656"], + "id": "1666357331276230656", + "text": "@ROBVME @sheepsleepdeep @mattxiv Bit like Bloomberg, but then for an incredible average beer brand, to which grown ass adults have some deeply emotional attachment to and going through a teenage break-up with, because a tiktok.\nGot it." + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357319381180417"], + "id": "1666357319381180417", + "text": "The global economy is set for a weak recovery from the shocks of Covid and Russia’s war in Ukraine, dogged by persistent inflation and central banks' restrictive policies, the OECD warns https://t.co/HPtelXu8iR https://t.co/rziWHhr8Np" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357315946303488"], + "id": "1666357315946303488", + "text": "RT @lukedepulford: Love this so much. Variations of “Glory to Hong Kong” are THE WHOLE TOP TEN of the most downloaded song on iTunes.\n\n✊\n\nh…" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357265320869891"], + "id": "1666357265320869891", + "text": "RT @business: The SEC said it’s seeking to freeze https://t.co/35sr7lifRX’s assets and protect customer funds, including through the repatr…" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357244760555520"], + "id": "1666357244760555520", + "text": "RT @BloombergJapan: オプション市場で日経平均先高観強まる、3万4000円に備える買い急増 https://t.co/mIcdkgokYj" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357239710359552"], + "id": "1666357239710359552", + "text": "Twitter'a mı girdim bloomberg mi anlamadım,dolar euro altın..maşallahları var,tl mi onun anası sikilmiş." + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357235340165120"], + "id": "1666357235340165120", + "text": "RT @business: These charts show why Germany needs mass migration https://t.co/rvZixuwwnu" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357210409213952"], + "id": "1666357210409213952", + "text": "RT @elonmusk: @MattWalshBlog View count is actually understated, as it does not include anything from our API, for example tweets you see i…" + } + }, + { + "json": { + "edit_history_tweet_ids": ["1666357208983166976"], + "id": "1666357208983166976", + "text": "RT @coinbureau: There we go. Without proving in a court of law that these tokens are \"securities\" the SEC may be able to restrict access to…" + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Twitter1", + "type": "main", + "index": 0 + }, + { + "node": "Twitter", + "type": "main", + "index": 0 + }, + { + "node": "Twitter7", + "type": "main", + "index": 0 + }, + { + "node": "Twitter5", + "type": "main", + "index": 0 + } + ] + ] + }, + "Twitter1": { + "main": [[]] + }, + "Twitter2": { + "main": [ + [ + { + "node": "Twitter3", + "type": "main", + "index": 0 + } + ] + ] + }, + "Twitter": { + "main": [ + [ + { + "node": "Twitter2", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "bc985960-5bfd-4b56-b290-ab839b3c0c30", + "id": "66", + "meta": { + "instanceId": "8e9416f42a954d0a370d988ac3c0f916f44074a6e45189164b1a8559394a7516" + }, + "tags": [] +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e260be8b82..cf100c387f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -318,6 +318,7 @@ "dist/credentials/TwilioApi.credentials.js", "dist/credentials/TwistOAuth2Api.credentials.js", "dist/credentials/TwitterOAuth1Api.credentials.js", + "dist/credentials/TwitterOAuth2Api.credentials.js", "dist/credentials/TypeformApi.credentials.js", "dist/credentials/TypeformOAuth2Api.credentials.js", "dist/credentials/UnleashedSoftwareApi.credentials.js", diff --git a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts index 83c82b3dbb..3b13075233 100644 --- a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts +++ b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts @@ -26,4 +26,23 @@ export const FAKE_CREDENTIALS_DATA = { accessKeyId: 'key', secretAccessKey: 'secret', }, + twitterOAuth2Api: { + grantType: 'pkce', + authUrl: 'https://twitter.com/i/oauth2/authorize', + accessTokenUrl: 'https://api.twitter.com/2/oauth2/token', + clientId: 'CLIENTID', + clientSecret: 'CLIENTSECRET', + scope: + 'tweet.read users.read tweet.write tweet.moderate.write users.read follows.read follows.write offline.access like.read like.write dm.write dm.read list.read list.write', + authQueryParameters: '', + authentication: 'header', + oauthTokenData: { + token_type: 'bearer', + expires_in: 7200, + access_token: 'ACCESSTOKEN', + scope: + 'tweet.moderate.write follows.read offline.access list.write dm.read list.read tweet.write like.write like.read users.read dm.write tweet.read follows.write', + refresh_token: 'REFRESHTOKEN', + }, + }, } as const; From 68fdc2078928be478a286774f2889feba1c3f5fe Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Wed, 28 Jun 2023 14:59:07 +0300 Subject: [PATCH 24/46] feat: Add various source control improvements (#6533) * feat: update source control notice wording * feat: update source control paywall state * fix: remove source control git repository ssh input hint * feat: hide tags, variables, and credentials from push modal * feat: add status colors and current workflow marking and sorting * feat: add select all workflows to push modal * fix: push everything besides current workflow with push workflow action * feat: add source control pull modal * feat: add updatedAt integration * fix: add time to last updated * fix: fix sorting, taking deleted into account * fix: update 409 pull workflow test * fix: add status priority sorting * fix: fix linting issue --- .../src/components/N8nBadge/Badge.vue | 26 ++- packages/editor-ui/src/Interface.ts | 1 + .../components/MainHeader/WorkflowDetails.vue | 42 +++- .../components/MainSidebarSourceControl.vue | 29 +-- packages/editor-ui/src/components/Modals.vue | 10 + .../components/SourceControlPullModal.ee.vue | 89 ++++++++ .../components/SourceControlPushModal.ee.vue | 209 ++++++++++++++---- .../MainSidebarSourceControl.test.ts | 22 +- packages/editor-ui/src/constants.ts | 2 + .../src/plugins/i18n/locales/en.json | 15 +- packages/editor-ui/src/stores/ui.store.ts | 4 + .../src/views/SettingsSourceControl.vue | 6 + 12 files changed, 385 insertions(+), 70 deletions(-) create mode 100644 packages/editor-ui/src/components/SourceControlPullModal.ee.vue diff --git a/packages/design-system/src/components/N8nBadge/Badge.vue b/packages/design-system/src/components/N8nBadge/Badge.vue index 605c78243c..7c6aed8b2f 100644 --- a/packages/design-system/src/components/N8nBadge/Badge.vue +++ b/packages/design-system/src/components/N8nBadge/Badge.vue @@ -16,7 +16,10 @@ export default defineComponent({ theme: { type: String, default: 'default', - validator: (value: string) => ['default', 'primary', 'secondary', 'tertiary'].includes(value), + validator: (value: string) => + ['default', 'success', 'warning', 'danger', 'primary', 'secondary', 'tertiary'].includes( + value, + ), }, size: { type: String, @@ -49,6 +52,27 @@ export default defineComponent({ border-color: var(--color-text-light); } +.success { + composes: badge; + border-radius: var(--border-radius-base); + color: var(--color-success); + border-color: var(--color-success); +} + +.warning { + composes: badge; + border-radius: var(--border-radius-base); + color: var(--color-warning); + border-color: var(--color-warning); +} + +.danger { + composes: badge; + border-radius: var(--border-radius-base); + color: var(--color-danger); + border-color: var(--color-danger); +} + .primary { composes: badge; padding: var(--spacing-5xs) var(--spacing-3xs); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 859a2ca104..35aa7a24d0 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1480,6 +1480,7 @@ export interface SourceControlAggregatedFile { name: string; status: string; type: string; + updatedAt?: string; } export declare namespace Cloud { diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index 5d8903d604..ae5bdba064 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -133,6 +133,7 @@ import { MAX_WORKFLOW_NAME_LENGTH, MODAL_CONFIRM, PLACEHOLDER_EMPTY_WORKFLOW_ID, + SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS, WORKFLOW_MENU_ACTIONS, WORKFLOW_SETTINGS_MODAL_KEY, @@ -151,7 +152,7 @@ import BreakpointsObserver from '@/components/BreakpointsObserver.vue'; import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface'; import { saveAs } from 'file-saver'; -import { useTitleChange, useToast, useMessage } from '@/composables'; +import { useTitleChange, useToast, useMessage, useLoadingService } from '@/composables'; import type { MessageBoxInputData } from 'element-ui/types/message-box'; import { useUIStore, @@ -161,6 +162,7 @@ import { useTagsStore, useUsersStore, useUsageStore, + useSourceControlStore, } from '@/stores'; import type { IPermissions } from '@/permissions'; import { getWorkflowPermissions } from '@/permissions'; @@ -197,7 +199,10 @@ export default defineComponent({ }, }, setup() { + const loadingService = useLoadingService(); + return { + loadingService, ...useTitleChange(), ...useToast(), ...useMessage(), @@ -211,6 +216,7 @@ export default defineComponent({ tagsEditBus: createEventBus(), MAX_WORKFLOW_NAME_LENGTH, tagsSaving: false, + eventBus: createEventBus(), EnterpriseEditionFeature, }; }, @@ -224,6 +230,7 @@ export default defineComponent({ useWorkflowsStore, useUsersStore, useCloudPlanStore, + useSourceControlStore, ), currentUser(): IUser | null { return this.usersStore.currentUser; @@ -305,6 +312,15 @@ export default defineComponent({ ); } + actions.push({ + id: WORKFLOW_MENU_ACTIONS.PUSH, + label: this.$locale.baseText('menuActions.push'), + disabled: + !this.sourceControlStore.isEnterpriseSourceControlEnabled || + !this.onWorkflowPage || + this.onExecutionsTab, + }); + actions.push({ id: WORKFLOW_MENU_ACTIONS.SETTINGS, label: this.$locale.baseText('generic.settings'), @@ -514,6 +530,30 @@ export default defineComponent({ (this.$refs.importFile as HTMLInputElement).click(); break; } + case WORKFLOW_MENU_ACTIONS.PUSH: { + this.loadingService.startLoading(); + try { + await this.onSaveButtonClick(); + + const status = await this.sourceControlStore.getAggregatedStatus(); + const workflowStatus = status.filter( + (s) => + (s.id === this.currentWorkflowId && s.type === 'workflow') || s.type !== 'workflow', + ); + + this.uiStore.openModalWithData({ + name: SOURCE_CONTROL_PUSH_MODAL_KEY, + data: { eventBus: this.eventBus, status: workflowStatus }, + }); + } catch (error) { + this.showError(error, this.$locale.baseText('error')); + } finally { + this.loadingService.stopLoading(); + this.loadingService.setLoadingText(this.$locale.baseText('genericHelpers.loading')); + } + + break; + } case WORKFLOW_MENU_ACTIONS.SETTINGS: { this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY); break; diff --git a/packages/editor-ui/src/components/MainSidebarSourceControl.vue b/packages/editor-ui/src/components/MainSidebarSourceControl.vue index 052baa09dd..8a8c9016df 100644 --- a/packages/editor-ui/src/components/MainSidebarSourceControl.vue +++ b/packages/editor-ui/src/components/MainSidebarSourceControl.vue @@ -4,12 +4,16 @@ import { useRouter } from 'vue-router/composables'; import { createEventBus } from 'n8n-design-system/utils'; import { useI18n, useLoadingService, useMessage, useToast } from '@/composables'; import { useUIStore, useSourceControlStore } from '@/stores'; -import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants'; +import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants'; const props = defineProps<{ isCollapsed: boolean; }>(); +const responseStatuses = { + CONFLICT: 409, +}; + const router = useRouter(); const loadingService = useLoadingService(); const uiStore = useUIStore(); @@ -47,28 +51,17 @@ async function pushWorkfolder() { async function pullWorkfolder() { loadingService.startLoading(); loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull')); + try { await sourceControlStore.pullWorkfolder(false); } catch (error) { const errorResponse = error.response; - if (errorResponse?.status === 409) { - const confirm = await message.confirm( - i18n.baseText('settings.sourceControl.modals.pull.description'), - i18n.baseText('settings.sourceControl.modals.pull.title'), - { - confirmButtonText: i18n.baseText('settings.sourceControl.modals.pull.buttons.save'), - cancelButtonText: i18n.baseText('settings.sourceControl.modals.pull.buttons.cancel'), - }, - ); - - try { - if (confirm === 'confirm') { - await sourceControlStore.pullWorkfolder(true); - } - } catch (error) { - toast.showError(error, 'Error'); - } + if (errorResponse?.status === responseStatuses.CONFLICT) { + uiStore.openModalWithData({ + name: SOURCE_CONTROL_PULL_MODAL_KEY, + data: { eventBus, status: errorResponse.data.data }, + }); } else { toast.showError(error, 'Error'); } diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index d0fccd5a52..3746992e6b 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -117,6 +117,12 @@ + + + +
@@ -146,6 +152,7 @@ import { LOG_STREAM_MODAL_KEY, ASK_AI_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, + SOURCE_CONTROL_PULL_MODAL_KEY, } from '@/constants'; import AboutModal from './AboutModal.vue'; @@ -172,6 +179,7 @@ import ImportCurlModal from './ImportCurlModal.vue'; import WorkflowShareModal from './WorkflowShareModal.ee.vue'; import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue'; import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue'; +import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue'; export default defineComponent({ name: 'Modals', @@ -200,6 +208,7 @@ export default defineComponent({ ImportCurlModal, EventDestinationSettingsModal, SourceControlPushModal, + SourceControlPullModal, }, data: () => ({ COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, @@ -225,6 +234,7 @@ export default defineComponent({ IMPORT_CURL_MODAL_KEY, LOG_STREAM_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, + SOURCE_CONTROL_PULL_MODAL_KEY, }), }); diff --git a/packages/editor-ui/src/components/SourceControlPullModal.ee.vue b/packages/editor-ui/src/components/SourceControlPullModal.ee.vue new file mode 100644 index 0000000000..bc58765f8c --- /dev/null +++ b/packages/editor-ui/src/components/SourceControlPullModal.ee.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/packages/editor-ui/src/components/SourceControlPushModal.ee.vue b/packages/editor-ui/src/components/SourceControlPushModal.ee.vue index e27eee84ab..bc5c5fa3fa 100644 --- a/packages/editor-ui/src/components/SourceControlPushModal.ee.vue +++ b/packages/editor-ui/src/components/SourceControlPushModal.ee.vue @@ -9,6 +9,7 @@ import { useI18n, useLoadingService, useToast } from '@/composables'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useUIStore } from '@/stores'; import { useRoute } from 'vue-router/composables'; +import dateformat from 'dateformat'; const props = defineProps({ data: { @@ -17,6 +18,8 @@ const props = defineProps({ }, }); +const defaultStagedFileTypes = ['tags', 'variables', 'credential']; + const loadingService = useLoadingService(); const uiStore = useUIStore(); const toast = useToast(); @@ -31,10 +34,71 @@ const commitMessage = ref(''); const loading = ref(true); const context = ref<'workflow' | 'workflows' | 'credentials' | string>(''); +const statusToBadgeThemeMap = { + created: 'success', + deleted: 'danger', + modified: 'warning', + renamed: 'warning', +}; + const isSubmitDisabled = computed(() => { return !commitMessage.value || Object.values(staged.value).every((value) => !value); }); +const workflowId = computed(() => { + if (context.value === 'workflow') { + return route.params.name as string; + } + + return ''; +}); + +const sortedFiles = computed(() => { + const statusPriority = { + deleted: 1, + modified: 2, + renamed: 3, + created: 4, + }; + + return [...files.value].sort((a, b) => { + if (context.value === 'workflow') { + if (a.id === workflowId.value) { + return -1; + } else if (b.id === workflowId.value) { + return 1; + } + } + + if (statusPriority[a.status] < statusPriority[b.status]) { + return -1; + } else if (statusPriority[a.status] > statusPriority[b.status]) { + return 1; + } + + return a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0; + }); +}); + +const selectAll = computed(() => { + return files.value.every((file) => staged.value[file.file]); +}); + +const workflowFiles = computed(() => { + return files.value.filter((file) => file.type === 'workflow'); +}); + +const stagedWorkflowFiles = computed(() => { + return workflowFiles.value.filter((workflow) => staged.value[workflow.file]); +}); + +const selectAllIndeterminate = computed(() => { + return ( + stagedWorkflowFiles.value.length > 0 && + stagedWorkflowFiles.value.length < workflowFiles.value.length + ); +}); + onMounted(async () => { context.value = getContext(); try { @@ -46,6 +110,22 @@ onMounted(async () => { } }); +function onToggleSelectAll() { + if (selectAll.value) { + files.value.forEach((file) => { + if (!defaultStagedFileTypes.includes(file.type)) { + staged.value[file.file] = false; + } + }); + } else { + files.value.forEach((file) => { + if (!defaultStagedFileTypes.includes(file.type)) { + staged.value[file.file] = true; + } + }); + } +} + function getContext() { if (route.fullPath.startsWith('/workflows')) { return 'workflows'; @@ -62,20 +142,24 @@ function getContext() { } function getStagedFilesByContext(files: SourceControlAggregatedFile[]): Record { - const stagedFiles: SourceControlAggregatedFile[] = []; - if (context.value === 'workflows') { - stagedFiles.push(...files.filter((file) => file.file.startsWith('workflows'))); - } else if (context.value === 'credentials') { - stagedFiles.push(...files.filter((file) => file.file.startsWith('credentials'))); - } else if (context.value === 'workflow') { - const workflowId = route.params.name as string; - stagedFiles.push(...files.filter((file) => file.type === 'workflow' && file.id === workflowId)); - } - - return stagedFiles.reduce>((acc, file) => { - acc[file.file] = true; + const stagedFiles = files.reduce((acc, file) => { + acc[file.file] = false; return acc; }, {}); + + files.forEach((file) => { + if (defaultStagedFileTypes.includes(file.type)) { + stagedFiles[file.file] = true; + } + + if (context.value === 'workflow' && file.type === 'workflow' && file.id === workflowId.value) { + stagedFiles[file.file] = true; + } else if (context.value === 'workflows' && file.type === 'workflow') { + stagedFiles[file.file] = true; + } + }); + + return stagedFiles; } function setStagedStatus(file: SourceControlAggregatedFile, status: boolean) { @@ -89,6 +173,20 @@ function close() { uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY); } +function renderUpdatedAt(file: SourceControlAggregatedFile) { + const currentYear = new Date().getFullYear(); + + return i18n.baseText('settings.sourceControl.lastUpdated', { + interpolate: { + date: dateformat( + file.updatedAt, + `d mmm${file.updatedAt.startsWith(currentYear) ? '' : ', yyyy'}`, + ), + time: dateformat(file.updatedAt, 'HH:MM'), + }, + }); +} + async function commitAndPush() { const fileNames = files.value.filter((file) => staged.value[file.file]).map((file) => file.file); @@ -135,12 +233,24 @@ async function commitAndPush() { -
- - {{ i18n.baseText('settings.sourceControl.modals.push.filesToCommit') }} - +
+
+ + + {{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }} + + + ({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }}) + + +
- - - Workflow - Credential - Id: {{ file.id }} - - +
+ + Deleted Workflow: + Deleted Credential: + {{ file.id }} + + {{ file.name }} - - - - {{ file.status }} - + +
+ + {{ renderUpdatedAt(file) }} + +
+
+ + {{ i18n.baseText('settings.sourceControl.modals.push.overrideVersionInGit') }} + +
+
+
+ + Current workflow + + + {{ file.status }} + +
@@ -228,22 +353,22 @@ async function commitAndPush() { &:last-child { margin-bottom: 0; } +} - .listItemBody { - display: flex; - flex-direction: row; - align-items: center; +.listItemBody { + display: flex; + flex-direction: row; + align-items: center; +} - .listItemCheckbox { - display: inline-flex !important; - margin-bottom: 0 !important; - margin-right: var(--spacing-2xs); - } +.listItemCheckbox { + display: inline-flex !important; + margin-bottom: 0 !important; + margin-right: var(--spacing-2xs) !important; +} - .listItemStatus { - margin-left: var(--spacing-2xs); - } - } +.listItemStatus { + margin-left: auto; } .footer { diff --git a/packages/editor-ui/src/components/__tests__/MainSidebarSourceControl.test.ts b/packages/editor-ui/src/components/__tests__/MainSidebarSourceControl.test.ts index b80bd9821f..950b75d733 100644 --- a/packages/editor-ui/src/components/__tests__/MainSidebarSourceControl.test.ts +++ b/packages/editor-ui/src/components/__tests__/MainSidebarSourceControl.test.ts @@ -4,15 +4,16 @@ import userEvent from '@testing-library/user-event'; import { PiniaVuePlugin } from 'pinia'; import { createTestingPinia } from '@pinia/testing'; import { merge } from 'lodash-es'; -import { STORES } from '@/constants'; +import { SOURCE_CONTROL_PULL_MODAL_KEY, STORES } from '@/constants'; import { i18nInstance } from '@/plugins/i18n'; import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue'; -import { useUsersStore, useSourceControlStore } from '@/stores'; +import { useUsersStore, useSourceControlStore, useUIStore } from '@/stores'; let pinia: ReturnType; let sourceControlStore: ReturnType; let usersStore: ReturnType; +let uiStore: ReturnType; const renderComponent = (renderOptions: Parameters[1] = {}) => { return render( @@ -42,6 +43,7 @@ describe('MainSidebarSourceControl', () => { }); sourceControlStore = useSourceControlStore(); + uiStore = useUIStore(); usersStore = useUsersStore(); }); @@ -89,13 +91,25 @@ describe('MainSidebarSourceControl', () => { }); it('should show confirm if pull response http status code is 409', async () => { + const status = {}; vi.spyOn(sourceControlStore, 'pullWorkfolder').mockRejectedValueOnce({ - response: { status: 409 }, + response: { status: 409, data: { data: status } }, }); + const openModalSpy = vi.spyOn(uiStore, 'openModalWithData'); + const { getAllByRole, getByRole } = renderComponent({ props: { isCollapsed: false } }); await userEvent.click(getAllByRole('button')[0]); - await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument()); + await waitFor(() => + expect(openModalSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: SOURCE_CONTROL_PULL_MODAL_KEY, + data: expect.objectContaining({ + status, + }), + }), + ), + ); }); }); }); diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index d21c2b423a..9086e185f3 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -49,6 +49,7 @@ export const IMPORT_CURL_MODAL_KEY = 'importCurl'; export const LOG_STREAM_MODAL_KEY = 'settingsLogStream'; export const SOURCE_CONTROL_PUSH_MODAL_KEY = 'sourceControlPush'; +export const SOURCE_CONTROL_PULL_MODAL_KEY = 'sourceControlPull'; export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = { UNINSTALL: 'uninstall', @@ -429,6 +430,7 @@ export const enum WORKFLOW_MENU_ACTIONS { DOWNLOAD = 'download', IMPORT_FROM_URL = 'import-from-url', IMPORT_FROM_FILE = 'import-from-file', + PUSH = 'push', SETTINGS = 'settings', DELETE = 'delete', } diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 3b2c1558ca..8c8ffe4c7f 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -630,6 +630,7 @@ "mainSidebar.executions": "All executions", "menuActions.duplicate": "Duplicate", "menuActions.download": "Download", + "menuActions.push": "Push to Git", "menuActions.importFromUrl": "Import from URL...", "menuActions.importFromFile": "Import from File...", "menuActions.delete": "Delete", @@ -1328,10 +1329,11 @@ "settings.usageAndPlan.desktop.description": "Cloud plans allow you to collaborate with teammates. Plus you don’t need to leave this app open all the time for your workflows to run.", "settings.sourceControl.title": "Source Control", "settings.sourceControl.actionBox.title": "Available on Enterprise plan", - "settings.sourceControl.actionBox.description": "Use Source Control to connect your instance to an external Git repository to backup and track changes made to your workflows, variables, and credentials. With Source Control you can also sync instances across multiple environments (development, production...).", + "settings.sourceControl.actionBox.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository.", + "settings.sourceControl.actionBox.description.link": "More info", "settings.sourceControl.actionBox.buttonText": "See plans", - "settings.sourceControl.description": "Source Control allows you to connect your n8n instance to a Git branch of a repository. You can connect your branches to multiples n8n instances to create a multi environments setup. {link}", - "settings.sourceControl.description.link": "Learn how to set up Source Control and Environments in n8n.", + "settings.sourceControl.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository. {link}", + "settings.sourceControl.description.link": "More info", "settings.sourceControl.gitConfig": "Git configuration", "settings.sourceControl.repoUrl": "Git repository URL (SSH)", "settings.sourceControl.repoUrlPlaceholder": "e.g. git@github.com:my-team/my-repository", @@ -1371,14 +1373,18 @@ "settings.sourceControl.modals.push.description.learnMore": "Learn more", "settings.sourceControl.modals.push.description.learnMore.url": "https://docs.n8n.io/source-control/using/", "settings.sourceControl.modals.push.filesToCommit": "Files to commit", + "settings.sourceControl.modals.push.workflowsToCommit": "Workflows to commit", "settings.sourceControl.modals.push.everythingIsUpToDate": "Everything is up to date", + "settings.sourceControl.modals.push.overrideVersionInGit": "This will override the version in Git", "settings.sourceControl.modals.push.commitMessage": "Commit message", "settings.sourceControl.modals.push.commitMessage.placeholder": "e.g. My commit", "settings.sourceControl.modals.push.buttons.cancel": "Cancel", "settings.sourceControl.modals.push.buttons.save": "Commit and Push", "settings.sourceControl.modals.push.success.title": "Pushed successfully", "settings.sourceControl.modals.push.success.description": "The files you selected were committed and pushed to the remote repository", - "settings.sourceControl.modals.pull.title": "Override local changes", + "settings.sourceControl.pull.success.title": "Pulled successfully", + "settings.sourceControl.pull.success.description": "Make sure you fill out the details of any new credentials or variables", + "settings.sourceControl.modals.pull.title": "Override local changes?", "settings.sourceControl.modals.pull.description": "Some remote changes are going to override some of your local changes. Are you sure you want to continue?", "settings.sourceControl.modals.pull.buttons.cancel": "@:_reusableBaseText.cancel", "settings.sourceControl.modals.pull.buttons.save": "Pull and override", @@ -1398,6 +1404,7 @@ "settings.sourceControl.toast.disconnected.error": "Error disconnecting from Git", "settings.sourceControl.loading.pull": "Pulling from remote", "settings.sourceControl.loading.push": "Pushing to remote", + "settings.sourceControl.lastUpdated": "Last updated {date} at {time}", "settings.sourceControl.saved.title": "Settings successfully saved", "settings.sourceControl.refreshBranches.tooltip": "Reload branches list", "settings.sourceControl.refreshBranches.success": "Branches successfully refreshed", diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index fa96e4ae22..af2a00c9d9 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -31,6 +31,7 @@ import { WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, + SOURCE_CONTROL_PULL_MODAL_KEY, } from '@/constants'; import type { CurlToJSONResponse, @@ -136,6 +137,9 @@ export const useUIStore = defineStore(STORES.UI, { [SOURCE_CONTROL_PUSH_MODAL_KEY]: { open: false, }, + [SOURCE_CONTROL_PULL_MODAL_KEY]: { + open: false, + }, }, modalStack: [], sidebarMenuCollapsed: true, diff --git a/packages/editor-ui/src/views/SettingsSourceControl.vue b/packages/editor-ui/src/views/SettingsSourceControl.vue index d5b32ee330..3967dff8df 100644 --- a/packages/editor-ui/src/views/SettingsSourceControl.vue +++ b/packages/editor-ui/src/views/SettingsSourceControl.vue @@ -379,6 +379,12 @@ const refreshBranches = async () => { +
From db81342b30c3015f9f661ad47feccb1f60347f82 Mon Sep 17 00:00:00 2001 From: Tom <19203795+that-one-tom@users.noreply.github.com> Date: Wed, 28 Jun 2023 14:03:58 +0200 Subject: [PATCH 25/46] docs: Add irreversible change warning for n8n@0.234.0 (no-changelog) (#6558) Add irreversible change warning for n8n@0.234.0 Add breaking change note for n8n@0.234.0 based on https://github.com/n8n-io/n8n/pull/6345 --- packages/cli/BREAKING-CHANGES.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 2269ed9456..c8f114ba7e 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,19 @@ This list shows all the versions which include breaking changes and how to upgrade. +## 0.234.0 + +### What changed? + +This release introduces two irreversible changes: + +* The n8n database will use strings instead of numeric values to identify workflows and credentials +* Execution data is split into a separate database table + +### When is action necessary? + +It will not be possible to read a n8n@0.234.0 database with older versions of n8n, so we recommend that you take a full backup before migrating. + ## 0.232.0 ### What changed? From 9bd49e844a44c40eff068cd9524fd79b8f1f1514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 28 Jun 2023 17:25:43 +0200 Subject: [PATCH 26/46] fix(core): Ensure valid `logger` is passed to every migration (no-changelog) (#6563) --- packages/cli/src/databases/utils/migrationHelpers.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/databases/utils/migrationHelpers.ts b/packages/cli/src/databases/utils/migrationHelpers.ts index 0221e7345d..a1f8bf223f 100644 --- a/packages/cli/src/databases/utils/migrationHelpers.ts +++ b/packages/cli/src/databases/utils/migrationHelpers.ts @@ -5,7 +5,7 @@ import type { QueryRunner } from 'typeorm/query-runner/QueryRunner'; import config from '@/config'; import { getLogger } from '@/Logger'; import { inTest } from '@/constants'; -import type { Migration } from '@db/types'; +import type { Migration, MigrationContext } from '@db/types'; const logger = getLogger(); @@ -68,7 +68,13 @@ export const wrapMigration = (migration: Migration) => { const dbName = config.getEnv(`database.${dbType === 'mariadb' ? 'mysqldb' : dbType}.database`); const tablePrefix = config.getEnv('database.tablePrefix'); const migrationName = migration.name; - const context = { tablePrefix, dbType, dbName, migrationName }; + const context: Omit = { + tablePrefix, + dbType, + dbName, + migrationName, + logger, + }; const { up, down } = migration.prototype; Object.assign(migration.prototype, { From 1abd172f73e171e37c4cc3ccfaa395c6a46bdf48 Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Thu, 29 Jun 2023 14:48:55 +0200 Subject: [PATCH 27/46] fix(core): Fix credentials test (#6569) fix credentials test --- packages/cli/test/integration/credentials.ee.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials.ee.test.ts index 344e01887d..32154d362e 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials.ee.test.ts @@ -100,8 +100,12 @@ describe('GET /credentials', () => { expect(response.statusCode).toBe(200); expect(response.body.data).toHaveLength(2); // owner retrieved owner cred and member cred - - const [ownerCredential, memberCredential] = response.body.data as CredentialWithSharings[]; + const ownerCredential = response.body.data.find( + (e: CredentialWithSharings) => e.ownedBy?.id === owner.id, + ); + const memberCredential = response.body.data.find( + (e: CredentialWithSharings) => e.ownedBy?.id === member1.id, + ); validateMainCredentialData(ownerCredential); expect(ownerCredential.data).toBeUndefined(); From b17b4582a059104665888a2369c3e2256db4c1ed Mon Sep 17 00:00:00 2001 From: Marcus <56945030+maspio@users.noreply.github.com> Date: Thu, 29 Jun 2023 15:27:02 +0200 Subject: [PATCH 28/46] feat(HTTP Request Node): New http request generic custom auth credential (#5798) --- .../credentials/HttpCustomAuth.credentials.ts | 28 ++++++++++++++ .../nodes-base/nodes/GraphQL/GraphQL.node.ts | 38 ++++++++++++++++++- .../HttpRequest/V3/HttpRequestV3.node.ts | 24 ++++++++++++ packages/nodes-base/package.json | 1 + 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 packages/nodes-base/credentials/HttpCustomAuth.credentials.ts diff --git a/packages/nodes-base/credentials/HttpCustomAuth.credentials.ts b/packages/nodes-base/credentials/HttpCustomAuth.credentials.ts new file mode 100644 index 0000000000..405d7b7cdf --- /dev/null +++ b/packages/nodes-base/credentials/HttpCustomAuth.credentials.ts @@ -0,0 +1,28 @@ +/* eslint-disable n8n-nodes-base/cred-class-field-name-unsuffixed */ +/* eslint-disable n8n-nodes-base/cred-class-name-unsuffixed */ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class HttpCustomAuth implements ICredentialType { + name = 'httpCustomAuth'; + + displayName = 'Custom Auth'; + + documentationUrl = 'httpRequest'; + + genericAuth = true; + + icon = 'node:n8n-nodes-base.httpRequest'; + + properties: INodeProperties[] = [ + { + displayName: 'JSON', + name: 'json', + type: 'json', + required: true, + description: 'Use json to specify authentication values for headers, body and qs.', + placeholder: + '{ "headers": { "key" : "value" }, "body": { "key": "value" }, "qs": { "key": "value" } }', + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/GraphQL/GraphQL.node.ts b/packages/nodes-base/nodes/GraphQL/GraphQL.node.ts index 13129d9d3a..4e5598d11b 100644 --- a/packages/nodes-base/nodes/GraphQL/GraphQL.node.ts +++ b/packages/nodes-base/nodes/GraphQL/GraphQL.node.ts @@ -6,8 +6,9 @@ import type { INodeType, INodeTypeDescription, JsonObject, + IRequestOptionsSimplified, } from 'n8n-workflow'; -import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +import { NodeApiError, NodeOperationError, jsonParse } from 'n8n-workflow'; import type { OptionsWithUri } from 'request'; import type { RequestPromiseOptions } from 'request-promise-native'; @@ -36,6 +37,15 @@ export class GraphQL implements INodeType { }, }, }, + { + name: 'httpCustomAuth', + required: true, + displayOptions: { + show: { + authentication: ['customAuth'], + }, + }, + }, { name: 'httpDigestAuth', required: true, @@ -92,6 +102,10 @@ export class GraphQL implements INodeType { name: 'Basic Auth', value: 'basicAuth', }, + { + name: 'Custom Auth', + value: 'customAuth', + }, { name: 'Digest Auth', value: 'digestAuth', @@ -284,6 +298,7 @@ export class GraphQL implements INodeType { const items = this.getInputData(); let httpBasicAuth; let httpDigestAuth; + let httpCustomAuth; let httpHeaderAuth; let httpQueryAuth; let oAuth1Api; @@ -294,6 +309,11 @@ export class GraphQL implements INodeType { } catch (error) { // Do nothing } + try { + httpCustomAuth = await this.getCredentials('httpCustomAuth'); + } catch (error) { + // Do nothing + } try { httpDigestAuth = await this.getCredentials('httpDigestAuth'); } catch (error) { @@ -361,6 +381,21 @@ export class GraphQL implements INodeType { pass: httpBasicAuth.password as string, }; } + if (httpCustomAuth !== undefined) { + const customAuth = jsonParse( + (httpCustomAuth.json as string) || '{}', + { errorMessage: 'Invalid Custom Auth JSON' }, + ); + if (customAuth.headers) { + requestOptions.headers = { ...requestOptions.headers, ...customAuth.headers }; + } + if (customAuth.body) { + requestOptions.body = { ...requestOptions.body, ...customAuth.body }; + } + if (customAuth.qs) { + requestOptions.qs = { ...requestOptions.qs, ...customAuth.qs }; + } + } if (httpHeaderAuth !== undefined) { requestOptions.headers![httpHeaderAuth.name as string] = httpHeaderAuth.value; } @@ -387,6 +422,7 @@ export class GraphQL implements INodeType { } else { if (requestFormat === 'json') { requestOptions.body = { + ...requestOptions.body, query: gqlQuery, variables: this.getNodeParameter('variables', itemIndex, {}) as object, operationName: this.getNodeParameter('operationName', itemIndex) as string, diff --git a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts index 7828836563..915edb166d 100644 --- a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts @@ -8,6 +8,7 @@ import type { INodeType, INodeTypeBaseDescription, INodeTypeDescription, + IRequestOptionsSimplified, JsonObject, } from 'n8n-workflow'; @@ -969,6 +970,7 @@ export class HttpRequestV3 implements INodeType { let httpDigestAuth; let httpHeaderAuth; let httpQueryAuth; + let httpCustomAuth; let oAuth1Api; let oAuth2Api; let nodeCredentialType; @@ -992,6 +994,10 @@ export class HttpRequestV3 implements INodeType { try { httpQueryAuth = await this.getCredentials('httpQueryAuth'); } catch {} + } else if (genericAuthType === 'httpCustomAuth') { + try { + httpCustomAuth = await this.getCredentials('httpCustomAuth'); + } catch {} } else if (genericAuthType === 'oAuth1Api') { try { oAuth1Api = await this.getCredentials('oAuth1Api'); @@ -1345,6 +1351,24 @@ export class HttpRequestV3 implements INodeType { }; authDataKeys.auth = ['pass']; } + if (httpCustomAuth !== undefined) { + const customAuth = jsonParse( + (httpCustomAuth.json as string) || '{}', + { errorMessage: 'Invalid Custom Auth JSON' }, + ); + if (customAuth.headers) { + requestOptions.headers = { ...requestOptions.headers, ...customAuth.headers }; + authDataKeys.headers = Object.keys(customAuth.headers); + } + if (customAuth.body) { + requestOptions.body = { ...requestOptions.body, ...customAuth.body }; + authDataKeys.body = Object.keys(customAuth.body); + } + if (customAuth.qs) { + requestOptions.qs = { ...requestOptions.qs, ...customAuth.qs }; + authDataKeys.qs = Object.keys(customAuth.qs); + } + } if (requestOptions.headers!.accept === undefined) { if (responseFormat === 'json') { diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index cf100c387f..c9cab8c02c 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -152,6 +152,7 @@ "dist/credentials/HttpBasicAuth.credentials.js", "dist/credentials/HttpDigestAuth.credentials.js", "dist/credentials/HttpHeaderAuth.credentials.js", + "dist/credentials/HttpCustomAuth.credentials.js", "dist/credentials/HttpQueryAuth.credentials.js", "dist/credentials/HubspotApi.credentials.js", "dist/credentials/HubspotAppToken.credentials.js", From cdd215f642b47413c05f229e641074d0d4048f68 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 29 Jun 2023 15:30:39 +0200 Subject: [PATCH 29/46] fix(XML Node): Fix issue with not returning valid data (#6565) --- packages/nodes-base/nodes/Xml/Xml.node.ts | 4 +- .../test/node/workflow.xml-valid-data.json | 134 ++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 packages/nodes-base/nodes/Xml/test/node/workflow.xml-valid-data.json diff --git a/packages/nodes-base/nodes/Xml/Xml.node.ts b/packages/nodes-base/nodes/Xml/Xml.node.ts index 213ce2df73..929df2fc69 100644 --- a/packages/nodes-base/nodes/Xml/Xml.node.ts +++ b/packages/nodes-base/nodes/Xml/Xml.node.ts @@ -5,7 +5,7 @@ import type { INodeType, INodeTypeDescription, } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; +import { NodeOperationError, deepCopy } from 'n8n-workflow'; export class Xml implements INodeType { description: INodeTypeDescription = { @@ -249,7 +249,7 @@ export class Xml implements INodeType { } const json = await parser.parseStringPromise(item.json[dataPropertyName] as string); - returnData.push({ json }); + returnData.push({ json: deepCopy(json) }); } else if (mode === 'jsonToxml') { const builder = new Builder(options); diff --git a/packages/nodes-base/nodes/Xml/test/node/workflow.xml-valid-data.json b/packages/nodes-base/nodes/Xml/test/node/workflow.xml-valid-data.json new file mode 100644 index 0000000000..0b46a4ca96 --- /dev/null +++ b/packages/nodes-base/nodes/Xml/test/node/workflow.xml-valid-data.json @@ -0,0 +1,134 @@ +{ + "name": "My workflow", + "nodes": [ + { + "parameters": {}, + "id": "7f04c09e-5a60-4a0c-a336-ef38e4732452", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [860, 380] + }, + { + "parameters": { + "jsCode": "return [\n {\n json: {\n a: {\n b: {\n c: 1\n }\n }\n }\n }\n];\n" + }, + "id": "be80ce82-d312-460d-ac79-05c0626845ad", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [1080, 380] + }, + { + "parameters": { + "mode": "jsonToxml", + "options": {} + }, + "id": "c352655b-e0dc-4f7c-a63a-ff1bc5f1909f", + "name": "XML", + "type": "n8n-nodes-base.xml", + "typeVersion": 1, + "position": [1300, 380] + }, + { + "parameters": { + "options": {} + }, + "id": "21547e44-133e-4c3b-a601-0cffba1daf9b", + "name": "XML1", + "type": "n8n-nodes-base.xml", + "typeVersion": 1, + "position": [1500, 380] + }, + { + "parameters": { + "values": { + "string": [ + { + "name": "new", + "value": "={{ $json.a }}" + } + ] + }, + "options": {} + }, + "id": "632dee22-10d1-424d-b1d2-673b95b32943", + "name": "Set", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [1720, 380] + } + ], + "pinData": { + "Set": [ + { + "json": { + "a": { + "b": { + "c": "1" + } + }, + "new": { + "b": { + "c": "1" + } + } + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code": { + "main": [ + [ + { + "node": "XML", + "type": "main", + "index": 0 + } + ] + ] + }, + "XML": { + "main": [ + [ + { + "node": "XML1", + "type": "main", + "index": 0 + } + ] + ] + }, + "XML1": { + "main": [ + [ + { + "node": "Set", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "14c1d4b8-3546-4692-8e8a-44c244c79bcc", + "id": "0G78Fh2FTnM46kuR", + "meta": { + "instanceId": "021d3c82ba2d3bc090cbf4fc81c9312668bcc34297e022bb3438c5c88a43a5ff" + }, + "tags": [] +} From e63b3982d200ade34461b9159eb1e988f494c025 Mon Sep 17 00:00:00 2001 From: Marcus <56945030+maspio@users.noreply.github.com> Date: Thu, 29 Jun 2023 17:11:00 +0200 Subject: [PATCH 30/46] fix(Brevo Node): Rename SendInBlue node to Brevo node (#6521) * rename sendinblue to brevo * refactor sendinblue node files/folders to brevo * update codex node, documentation urls and api baseUrls * adding comments to original node names --- ...credentials.ts => BrevoApi.credentials.ts} | 9 +++---- .../AttributeDescription.ts | 4 ++-- .../Brevo.node.json} | 9 +++---- .../Brevo.node.ts} | 13 +++++----- .../BrevoTrigger.node.json} | 9 +++---- .../BrevoTrigger.node.ts} | 24 ++++++++----------- .../ContactDescription.ts | 0 .../{SendInBlue => Brevo}/EmailDescription.ts | 22 ++++++++--------- .../{SendInBlue => Brevo}/GenericFunctions.ts | 6 ++--- .../{SendInBlue => Brevo}/SenderDescrition.ts | 0 packages/nodes-base/nodes/Brevo/brevo.svg | 4 ++++ .../nodes/SendInBlue/sendinblue.svg | 20 ---------------- packages/nodes-base/package.json | 6 ++--- 13 files changed, 55 insertions(+), 71 deletions(-) rename packages/nodes-base/credentials/{SendInBlueApi.credentials.ts => BrevoApi.credentials.ts} (73%) rename packages/nodes-base/nodes/{SendInBlue => Brevo}/AttributeDescription.ts (98%) rename packages/nodes-base/nodes/{SendInBlue/SendInBlue.node.json => Brevo/Brevo.node.json} (67%) rename packages/nodes-base/nodes/{SendInBlue/SendInBlue.node.ts => Brevo/Brevo.node.ts} (85%) rename packages/nodes-base/nodes/{SendInBlue/SendInBlueTrigger.node.json => Brevo/BrevoTrigger.node.json} (65%) rename packages/nodes-base/nodes/{SendInBlue/SendInBlueTrigger.node.ts => Brevo/BrevoTrigger.node.ts} (92%) rename packages/nodes-base/nodes/{SendInBlue => Brevo}/ContactDescription.ts (100%) rename packages/nodes-base/nodes/{SendInBlue => Brevo}/EmailDescription.ts (90%) rename packages/nodes-base/nodes/{SendInBlue => Brevo}/GenericFunctions.ts (98%) rename packages/nodes-base/nodes/{SendInBlue => Brevo}/SenderDescrition.ts (100%) create mode 100644 packages/nodes-base/nodes/Brevo/brevo.svg delete mode 100644 packages/nodes-base/nodes/SendInBlue/sendinblue.svg diff --git a/packages/nodes-base/credentials/SendInBlueApi.credentials.ts b/packages/nodes-base/credentials/BrevoApi.credentials.ts similarity index 73% rename from packages/nodes-base/credentials/SendInBlueApi.credentials.ts rename to packages/nodes-base/credentials/BrevoApi.credentials.ts index f4fed2a413..2690f3207d 100644 --- a/packages/nodes-base/credentials/SendInBlueApi.credentials.ts +++ b/packages/nodes-base/credentials/BrevoApi.credentials.ts @@ -5,12 +5,13 @@ import type { INodeProperties, } from 'n8n-workflow'; -export class SendInBlueApi implements ICredentialType { +export class BrevoApi implements ICredentialType { + // keep sendinblue name for backward compatibility name = 'sendInBlueApi'; - displayName = 'SendInBlue'; + displayName = 'Brevo'; - documentationUrl = 'sendinblue'; + documentationUrl = 'brevo'; properties: INodeProperties[] = [ { @@ -33,7 +34,7 @@ export class SendInBlueApi implements ICredentialType { test: ICredentialTestRequest = { request: { - baseURL: 'https://api.sendinblue.com/v3', + baseURL: 'https://api.brevo.com/v3', url: '/account', }, }; diff --git a/packages/nodes-base/nodes/SendInBlue/AttributeDescription.ts b/packages/nodes-base/nodes/Brevo/AttributeDescription.ts similarity index 98% rename from packages/nodes-base/nodes/SendInBlue/AttributeDescription.ts rename to packages/nodes-base/nodes/Brevo/AttributeDescription.ts index 5944ba8fe2..f51efa0579 100644 --- a/packages/nodes-base/nodes/SendInBlue/AttributeDescription.ts +++ b/packages/nodes-base/nodes/Brevo/AttributeDescription.ts @@ -4,7 +4,7 @@ import type { INodeProperties, JsonObject, } from 'n8n-workflow'; -import { SendInBlueNode } from './GenericFunctions'; +import { BrevoNode } from './GenericFunctions'; export const attributeOperations: INodeProperties[] = [ { @@ -43,7 +43,7 @@ export const attributeOperations: INodeProperties[] = [ requestOptions: IHttpRequestOptions, ): Promise { const selectedCategory = this.getNodeParameter('attributeCategory') as string; - const override = SendInBlueNode.INTERCEPTORS.get(selectedCategory); + const override = BrevoNode.INTERCEPTORS.get(selectedCategory); if (override) { override.call(this, requestOptions.body! as JsonObject); } diff --git a/packages/nodes-base/nodes/SendInBlue/SendInBlue.node.json b/packages/nodes-base/nodes/Brevo/Brevo.node.json similarity index 67% rename from packages/nodes-base/nodes/SendInBlue/SendInBlue.node.json rename to packages/nodes-base/nodes/Brevo/Brevo.node.json index abf2d926f1..931dd09b63 100644 --- a/packages/nodes-base/nodes/SendInBlue/SendInBlue.node.json +++ b/packages/nodes-base/nodes/Brevo/Brevo.node.json @@ -1,18 +1,19 @@ { - "node": "n8n-nodes-base.sendInBlue", + "node": "n8n-nodes-base.brevo", "nodeVersion": "1.0", "codexVersion": "1.0", "categories": ["Marketing & Content", "Communication"], "resources": { "credentialDocumentation": [ { - "url": "https://docs.n8n.io/credentials/sendInBlue" + "url": "https://docs.n8n.io/credentials/brevo" } ], "primaryDocumentation": [ { - "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.sendinblue/" + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.brevo/" } ] - } + }, + "alias": ["sendinblue"] } diff --git a/packages/nodes-base/nodes/SendInBlue/SendInBlue.node.ts b/packages/nodes-base/nodes/Brevo/Brevo.node.ts similarity index 85% rename from packages/nodes-base/nodes/SendInBlue/SendInBlue.node.ts rename to packages/nodes-base/nodes/Brevo/Brevo.node.ts index 19c9ba5a63..f4519c0651 100644 --- a/packages/nodes-base/nodes/SendInBlue/SendInBlue.node.ts +++ b/packages/nodes-base/nodes/Brevo/Brevo.node.ts @@ -5,17 +5,18 @@ import { contactFields, contactOperations } from './ContactDescription'; import { emailFields, emailOperations } from './EmailDescription'; import { senderFields, senderOperations } from './SenderDescrition'; -export class SendInBlue implements INodeType { +export class Brevo implements INodeType { description: INodeTypeDescription = { - displayName: 'SendInBlue', + displayName: 'Brevo', + // keep sendinblue name for backward compatibility name: 'sendInBlue', - icon: 'file:sendinblue.svg', + icon: 'file:brevo.svg', group: ['transform'], version: 1, subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Consume Sendinblue API', + description: 'Consume Brevo API', defaults: { - name: 'SendInBlue', + name: 'Brevo', }, inputs: ['main'], outputs: ['main'], @@ -26,7 +27,7 @@ export class SendInBlue implements INodeType { }, ], requestDefaults: { - baseURL: 'https://api.sendinblue.com', + baseURL: 'https://api.brevo.com', }, properties: [ { diff --git a/packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.json b/packages/nodes-base/nodes/Brevo/BrevoTrigger.node.json similarity index 65% rename from packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.json rename to packages/nodes-base/nodes/Brevo/BrevoTrigger.node.json index f553e8d39f..81df05bd70 100644 --- a/packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.json +++ b/packages/nodes-base/nodes/Brevo/BrevoTrigger.node.json @@ -1,19 +1,20 @@ { - "node": "n8n-nodes-base.sendInBlueTrigger", + "node": "n8n-nodes-base.brevoTrigger", "nodeVersion": "1.0", "codexVersion": "1.0", "categories": ["Marketing & Content", "Communication"], "resources": { "credentialDocumentation": [ { - "url": "https://docs.n8n.io/credentials/sendInBlue" + "url": "https://docs.n8n.io/credentials/brevo" } ], "primaryDocumentation": [ { - "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.sendinbluetrigger/" + "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.brevotrigger/" } ], "generic": [] - } + }, + "alias": ["sendinblue"] } diff --git a/packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.ts b/packages/nodes-base/nodes/Brevo/BrevoTrigger.node.ts similarity index 92% rename from packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.ts rename to packages/nodes-base/nodes/Brevo/BrevoTrigger.node.ts index dc9bb30370..b0988a9e84 100644 --- a/packages/nodes-base/nodes/SendInBlue/SendInBlueTrigger.node.ts +++ b/packages/nodes-base/nodes/Brevo/BrevoTrigger.node.ts @@ -6,9 +6,9 @@ import type { IWebhookFunctions, IWebhookResponseData, } from 'n8n-workflow'; -import { SendInBlueWebhookApi } from './GenericFunctions'; +import { BrevoWebhookApi } from './GenericFunctions'; -export class SendInBlueTrigger implements INodeType { +export class BrevoTrigger implements INodeType { description: INodeTypeDescription = { credentials: [ { @@ -16,14 +16,15 @@ export class SendInBlueTrigger implements INodeType { required: true, }, ], - displayName: 'SendInBlue Trigger', + displayName: 'Brevo Trigger', defaults: { - name: 'SendInBlue Trigger', + name: 'Brevo Trigger', }, - description: 'Starts the workflow when SendInBlue events occur', + description: 'Starts the workflow when Brevo events occur', group: ['trigger'], - icon: 'file:sendinblue.svg', + icon: 'file:brevo.svg', inputs: [], + // keep sendinblue name for backward compatibility name: 'sendInBlueTrigger', outputs: ['main'], version: 1, @@ -213,7 +214,7 @@ export class SendInBlueTrigger implements INodeType { const events = this.getNodeParameter('events') as string[]; try { - const { webhooks } = await SendInBlueWebhookApi.fetchWebhooks(this, type); + const { webhooks } = await BrevoWebhookApi.fetchWebhooks(this, type); for (const webhook of webhooks) { if ( @@ -240,12 +241,7 @@ export class SendInBlueTrigger implements INodeType { const events = this.getNodeParameter('events') as string[]; - const responseData = await SendInBlueWebhookApi.createWebHook( - this, - type, - events, - webhookUrl, - ); + const responseData = await BrevoWebhookApi.createWebHook(this, type, events, webhookUrl); if (responseData === undefined || responseData.id === undefined) { // Required data is missing so was not successful @@ -261,7 +257,7 @@ export class SendInBlueTrigger implements INodeType { if (webhookData.webhookId !== undefined) { try { - await SendInBlueWebhookApi.deleteWebhook(this, webhookData.webhookId as string); + await BrevoWebhookApi.deleteWebhook(this, webhookData.webhookId as string); } catch (error) { return false; } diff --git a/packages/nodes-base/nodes/SendInBlue/ContactDescription.ts b/packages/nodes-base/nodes/Brevo/ContactDescription.ts similarity index 100% rename from packages/nodes-base/nodes/SendInBlue/ContactDescription.ts rename to packages/nodes-base/nodes/Brevo/ContactDescription.ts diff --git a/packages/nodes-base/nodes/SendInBlue/EmailDescription.ts b/packages/nodes-base/nodes/Brevo/EmailDescription.ts similarity index 90% rename from packages/nodes-base/nodes/SendInBlue/EmailDescription.ts rename to packages/nodes-base/nodes/Brevo/EmailDescription.ts index 8a1cc400ea..63fda3ab10 100644 --- a/packages/nodes-base/nodes/SendInBlue/EmailDescription.ts +++ b/packages/nodes-base/nodes/Brevo/EmailDescription.ts @@ -1,5 +1,5 @@ import type { INodeProperties } from 'n8n-workflow'; -import { SendInBlueNode } from './GenericFunctions'; +import { BrevoNode } from './GenericFunctions'; export const emailOperations: INodeProperties[] = [ { @@ -120,7 +120,7 @@ const sendHtmlEmailFields: INodeProperties[] = [ required: true, routing: { send: { - preSend: [SendInBlueNode.Validators.validateAndCompileSenderEmail], + preSend: [BrevoNode.Validators.validateAndCompileSenderEmail], }, }, }, @@ -138,7 +138,7 @@ const sendHtmlEmailFields: INodeProperties[] = [ required: true, routing: { send: { - preSend: [SendInBlueNode.Validators.validateAndCompileReceipientEmails], + preSend: [BrevoNode.Validators.validateAndCompileReceipientEmails], }, }, }, @@ -180,7 +180,7 @@ const sendHtmlEmailFields: INodeProperties[] = [ ], routing: { send: { - preSend: [SendInBlueNode.Validators.validateAndCompileAttachmentsData], + preSend: [BrevoNode.Validators.validateAndCompileAttachmentsData], }, }, }, @@ -206,7 +206,7 @@ const sendHtmlEmailFields: INodeProperties[] = [ ], routing: { send: { - preSend: [SendInBlueNode.Validators.validateAndCompileBCCEmails], + preSend: [BrevoNode.Validators.validateAndCompileBCCEmails], }, }, }, @@ -232,7 +232,7 @@ const sendHtmlEmailFields: INodeProperties[] = [ ], routing: { send: { - preSend: [SendInBlueNode.Validators.validateAndCompileCCEmails], + preSend: [BrevoNode.Validators.validateAndCompileCCEmails], }, }, }, @@ -259,7 +259,7 @@ const sendHtmlEmailFields: INodeProperties[] = [ ], routing: { send: { - preSend: [SendInBlueNode.Validators.validateAndCompileTags], + preSend: [BrevoNode.Validators.validateAndCompileTags], }, }, }, @@ -339,7 +339,7 @@ const sendHtmlTemplateEmailFields: INodeProperties[] = [ required: true, routing: { send: { - preSend: [SendInBlueNode.Validators.validateAndCompileReceipientEmails], + preSend: [BrevoNode.Validators.validateAndCompileReceipientEmails], }, }, }, @@ -381,7 +381,7 @@ const sendHtmlTemplateEmailFields: INodeProperties[] = [ ], routing: { send: { - preSend: [SendInBlueNode.Validators.validateAndCompileAttachmentsData], + preSend: [BrevoNode.Validators.validateAndCompileAttachmentsData], }, }, }, @@ -408,7 +408,7 @@ const sendHtmlTemplateEmailFields: INodeProperties[] = [ ], routing: { send: { - preSend: [SendInBlueNode.Validators.validateAndCompileTags], + preSend: [BrevoNode.Validators.validateAndCompileTags], }, }, }, @@ -437,7 +437,7 @@ const sendHtmlTemplateEmailFields: INodeProperties[] = [ ], routing: { send: { - preSend: [SendInBlueNode.Validators.validateAndCompileTemplateParameters], + preSend: [BrevoNode.Validators.validateAndCompileTemplateParameters], }, }, }, diff --git a/packages/nodes-base/nodes/SendInBlue/GenericFunctions.ts b/packages/nodes-base/nodes/Brevo/GenericFunctions.ts similarity index 98% rename from packages/nodes-base/nodes/SendInBlue/GenericFunctions.ts rename to packages/nodes-base/nodes/Brevo/GenericFunctions.ts index 7fa7a6e4bd..bed24ef97e 100644 --- a/packages/nodes-base/nodes/SendInBlue/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Brevo/GenericFunctions.ts @@ -8,7 +8,7 @@ import type { import { jsonParse, NodeOperationError } from 'n8n-workflow'; import type { OptionsWithUri } from 'request'; import MailComposer from 'nodemailer/lib/mail-composer'; -export namespace SendInBlueNode { +export namespace BrevoNode { type ValidEmailFields = { to: string } | { sender: string } | { cc: string } | { bcc: string }; type Address = { address: string; name?: string }; type Email = { email: string; name?: string }; @@ -277,7 +277,7 @@ export namespace SendInBlueNode { } } -export namespace SendInBlueWebhookApi { +export namespace BrevoWebhookApi { interface WebhookDetails { url: string; id: number; @@ -297,7 +297,7 @@ export namespace SendInBlueWebhookApi { } const credentialsName = 'sendInBlueApi'; - const baseURL = 'https://api.sendinblue.com/v3'; + const baseURL = 'https://api.brevo.com/v3'; export const supportedAuthMap = new Map Promise>([ [ 'apiKey', diff --git a/packages/nodes-base/nodes/SendInBlue/SenderDescrition.ts b/packages/nodes-base/nodes/Brevo/SenderDescrition.ts similarity index 100% rename from packages/nodes-base/nodes/SendInBlue/SenderDescrition.ts rename to packages/nodes-base/nodes/Brevo/SenderDescrition.ts diff --git a/packages/nodes-base/nodes/Brevo/brevo.svg b/packages/nodes-base/nodes/Brevo/brevo.svg new file mode 100644 index 0000000000..4b097f196f --- /dev/null +++ b/packages/nodes-base/nodes/Brevo/brevo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/nodes-base/nodes/SendInBlue/sendinblue.svg b/packages/nodes-base/nodes/SendInBlue/sendinblue.svg deleted file mode 100644 index 7525b9036e..0000000000 --- a/packages/nodes-base/nodes/SendInBlue/sendinblue.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index c9cab8c02c..05a52f8deb 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -271,7 +271,7 @@ "dist/credentials/SecurityScorecardApi.credentials.js", "dist/credentials/SegmentApi.credentials.js", "dist/credentials/SendGridApi.credentials.js", - "dist/credentials/SendInBlueApi.credentials.js", + "dist/credentials/BrevoApi.credentials.js", "dist/credentials/SendyApi.credentials.js", "dist/credentials/SentryIoApi.credentials.js", "dist/credentials/SentryIoOAuth2Api.credentials.js", @@ -599,8 +599,8 @@ "dist/nodes/Netlify/NetlifyTrigger.node.js", "dist/nodes/NextCloud/NextCloud.node.js", "dist/nodes/NocoDB/NocoDB.node.js", - "dist/nodes/SendInBlue/SendInBlue.node.js", - "dist/nodes/SendInBlue/SendInBlueTrigger.node.js", + "dist/nodes/Brevo/Brevo.node.js", + "dist/nodes/Brevo/BrevoTrigger.node.js", "dist/nodes/StickyNote/StickyNote.node.js", "dist/nodes/NoOp/NoOp.node.js", "dist/nodes/Onfleet/Onfleet.node.js", From 3b9e65e9e047b7a5553f51ec08e21e5ecf5b31f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Fri, 30 Jun 2023 08:51:39 +0200 Subject: [PATCH 31/46] fix(core): Route `/rest/workflows/new` correctly (no-changelog) (#6572) --- .../src/workflows/workflows.controller.ee.ts | 1 + .../integration/workflows.controller.ee.test.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/cli/src/workflows/workflows.controller.ee.ts b/packages/cli/src/workflows/workflows.controller.ee.ts index 14b4d56d96..c50860c359 100644 --- a/packages/cli/src/workflows/workflows.controller.ee.ts +++ b/packages/cli/src/workflows/workflows.controller.ee.ts @@ -87,6 +87,7 @@ EEWorkflowController.put( EEWorkflowController.get( '/:id(\\w+)', + (req, res, next) => (req.params.id === 'new' ? next('router') : next()), // skip ee router and use free one for naming ResponseHelper.send(async (req: WorkflowRequest.Get) => { const { id: workflowId } = req.params; diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index f2949c3ed5..f84a6449ae 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -196,6 +196,23 @@ describe('GET /workflows', () => { }); }); +describe('GET /workflows/new', () => { + [true, false].forEach((sharingEnabled) => { + test(`should return an auto-incremented name, even when sharing is ${ + sharingEnabled ? 'enabled' : 'disabled' + }`, async () => { + sharingSpy.mockReturnValueOnce(sharingEnabled); + + await createWorkflow({ name: 'My workflow' }, owner); + await createWorkflow({ name: 'My workflow 7' }, owner); + + const response = await authOwnerAgent.get('/workflows/new'); + expect(response.statusCode).toBe(200); + expect(response.body.data.name).toEqual('My workflow 8'); + }); + }); +}); + describe('GET /workflows/:id', () => { test('GET should fail with invalid id due to route rule', async () => { const response = await authOwnerAgent.get('/workflows/potatoes'); From 7a8b85ab11fb0dbfa110fb48bad7928791e2486b Mon Sep 17 00:00:00 2001 From: agobrech <45268029+agobrech@users.noreply.github.com> Date: Fri, 30 Jun 2023 11:07:37 +0200 Subject: [PATCH 32/46] ci: Add Github Action to enforce template to issues (#5295) Add workflow template for enforcing issue template --- .github/workflows/check-issue-template.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/check-issue-template.yml diff --git a/.github/workflows/check-issue-template.yml b/.github/workflows/check-issue-template.yml new file mode 100644 index 0000000000..755307a1dd --- /dev/null +++ b/.github/workflows/check-issue-template.yml @@ -0,0 +1,18 @@ +name: Check Issue Template + +on: + issues: + types: [opened, edited] + +jobs: + check-issue: + name: Check Issue Template + runs-on: ubuntu-latest + steps: + - name: Run Check Issue Template + uses: n8n-io/GH-actions-playground@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + + From 238a78f0582dbf439a9799de0edcb2e9bef29978 Mon Sep 17 00:00:00 2001 From: perseus-algol <129220578+perseus-algol@users.noreply.github.com> Date: Mon, 3 Jul 2023 10:31:06 +0300 Subject: [PATCH 33/46] feat: Add crowd.dev node and trigger node (#6082) --- .../credentials/CrowdDevApi.credentials.ts | 72 +++++ .../nodes/CrowdDev/CrowdDev.node.json | 18 ++ .../nodes/CrowdDev/CrowdDev.node.ts | 32 ++ .../nodes/CrowdDev/CrowdDevTrigger.node.json | 18 ++ .../nodes/CrowdDev/CrowdDevTrigger.node.ts | 185 ++++++++++++ .../nodes/CrowdDev/GenericFunctions.ts | 221 ++++++++++++++ .../nodes-base/nodes/CrowdDev/crowdDev.svg | 15 + .../CrowdDev/descriptions/activityFields.ts | 186 ++++++++++++ .../CrowdDev/descriptions/automationFields.ts | 129 ++++++++ .../nodes/CrowdDev/descriptions/index.ts | 24 ++ .../CrowdDev/descriptions/memberFields.ts | 275 ++++++++++++++++++ .../nodes/CrowdDev/descriptions/noteFields.ts | 92 ++++++ .../descriptions/organizationFields.ts | 150 ++++++++++ .../nodes/CrowdDev/descriptions/resources.ts | 36 +++ .../nodes/CrowdDev/descriptions/shared.ts | 27 ++ .../nodes/CrowdDev/descriptions/taskFields.ts | 163 +++++++++++ .../nodes/CrowdDev/descriptions/utils.ts | 57 ++++ packages/nodes-base/package.json | 3 + 18 files changed, 1703 insertions(+) create mode 100644 packages/nodes-base/credentials/CrowdDevApi.credentials.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/CrowdDev.node.json create mode 100644 packages/nodes-base/nodes/CrowdDev/CrowdDev.node.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/CrowdDevTrigger.node.json create mode 100644 packages/nodes-base/nodes/CrowdDev/CrowdDevTrigger.node.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/crowdDev.svg create mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/activityFields.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/automationFields.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/index.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/memberFields.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/noteFields.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/organizationFields.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/resources.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/shared.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/taskFields.ts create mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/utils.ts diff --git a/packages/nodes-base/credentials/CrowdDevApi.credentials.ts b/packages/nodes-base/credentials/CrowdDevApi.credentials.ts new file mode 100644 index 0000000000..a6ee085a02 --- /dev/null +++ b/packages/nodes-base/credentials/CrowdDevApi.credentials.ts @@ -0,0 +1,72 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class CrowdDevApi implements ICredentialType { + name = 'crowdDevApi'; + + displayName = 'crowd.dev API'; + + documentationUrl = 'crowdDev'; + + properties: INodeProperties[] = [ + { + displayName: 'URL', + name: 'url', + type: 'string', + default: 'https://app.crowd.dev', + }, + { + displayName: 'Tenant ID', + name: 'tenantId', + type: 'string', + default: '', + }, + { + displayName: 'Token', + name: 'token', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'Ignore SSL Issues', + name: 'allowUnauthorizedCerts', + type: 'boolean', + description: 'Whether to connect even if SSL certificate validation is not possible', + default: false, + }, + ]; + + // This allows the credential to be used by other parts of n8n + // stating how this credential is injected as part of the request + // An example is the Http Request node that can make generic calls + // reusing this credential + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '={{"Bearer " + $credentials.token}}', + }, + }, + }; + + // The block below tells how this credential can be tested + test: ICredentialTestRequest = { + request: { + method: 'POST', + baseURL: '={{$credentials.url.replace(/\\/$/, "") + "/api/tenant/" + $credentials.tenantId}}', + url: '/member/query', + skipSslCertificateValidation: '={{ $credentials.allowUnauthorizedCerts }}', + body: { + limit: 1, + offset: 0, + }, + }, + }; +} diff --git a/packages/nodes-base/nodes/CrowdDev/CrowdDev.node.json b/packages/nodes-base/nodes/CrowdDev/CrowdDev.node.json new file mode 100644 index 0000000000..8c03ebe23e --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/CrowdDev.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.crowdDev", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Productivity"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/crowdDev" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.crowdDev/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/CrowdDev/CrowdDev.node.ts b/packages/nodes-base/nodes/CrowdDev/CrowdDev.node.ts new file mode 100644 index 0000000000..76cd06d60a --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/CrowdDev.node.ts @@ -0,0 +1,32 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { allProperties } from './descriptions'; + +export class CrowdDev implements INodeType { + description: INodeTypeDescription = { + displayName: 'crowd.dev', + name: 'crowdDev', + icon: 'file:crowdDev.svg', + group: ['transform'], + version: 1, + subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', + description: + 'crowd.dev is an open-source suite of community and data tools built to unlock community-led growth for your organization.', + defaults: { + name: 'crowd.dev', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'crowdDevApi', + required: true, + }, + ], + requestDefaults: { + baseURL: '={{$credentials.url}}/api/tenant/{{$credentials.tenantId}}', + json: true, + skipSslCertificateValidation: '={{ $credentials.allowUnauthorizedCerts }}', + }, + properties: allProperties, + }; +} diff --git a/packages/nodes-base/nodes/CrowdDev/CrowdDevTrigger.node.json b/packages/nodes-base/nodes/CrowdDev/CrowdDevTrigger.node.json new file mode 100644 index 0000000000..2e37220b0c --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/CrowdDevTrigger.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.crowdDevTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Productivity"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/crowdDev" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.crowddevtrigger/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/CrowdDev/CrowdDevTrigger.node.ts b/packages/nodes-base/nodes/CrowdDev/CrowdDevTrigger.node.ts new file mode 100644 index 0000000000..cf30224b4e --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/CrowdDevTrigger.node.ts @@ -0,0 +1,185 @@ +import type { + IHookFunctions, + IWebhookFunctions, + INodeType, + INodeTypeDescription, + IWebhookResponseData, + IHttpRequestOptions, +} from 'n8n-workflow'; + +interface ICrowdDevCreds { + url: string; + tenantId: string; + token: string; + allowUnauthorizedCerts: boolean; +} + +const credsName = 'crowdDevApi'; + +const getCreds = async (hookFns: IHookFunctions) => + hookFns.getCredentials(credsName) as unknown as ICrowdDevCreds; + +const createRequest = ( + creds: ICrowdDevCreds, + opts: Partial, +): IHttpRequestOptions => { + const defaults: IHttpRequestOptions = { + baseURL: `${creds.url}/api/tenant/${creds.tenantId}`, + url: '', + json: true, + skipSslCertificateValidation: creds.allowUnauthorizedCerts, + }; + return Object.assign(defaults, opts); +}; + +export class CrowdDevTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'crowd.dev Trigger', + name: 'crowdDevTrigger', + icon: 'file:crowdDev.svg', + group: ['trigger'], + version: 1, + description: 'Starts the workflow when crowd.dev events occur.', + defaults: { + name: 'crowd.dev Trigger', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'crowdDevApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Trigger', + name: 'trigger', + description: 'What will trigger an automation', + type: 'options', + required: true, + default: 'new_activity', + options: [ + { + name: 'New Activity', + value: 'new_activity', + }, + { + name: 'New Member', + value: 'new_member', + }, + ], + }, + ], + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const creds = await getCreds(this); + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default') as string; + + if (webhookData.webhookId !== undefined) { + try { + const options = createRequest(creds, { + url: `/automation/${webhookData.webhookId}`, + method: 'GET', + }); + const data = await this.helpers.httpRequestWithAuthentication.call( + this, + credsName, + options, + ); + if (data.settings.url === webhookUrl) { + return true; + } + } catch (error) { + return false; + } + } + + // If it did not error then the webhook exists + return false; + }, + + async create(this: IHookFunctions): Promise { + const creds = await getCreds(this); + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default') as string; + const params = { + trigger: this.getNodeParameter('trigger') as string, + }; + + const options = createRequest(creds, { + url: '/automation', + method: 'POST', + body: { + data: { + settings: { + url: webhookUrl, + }, + type: 'webhook', + trigger: params.trigger, + }, + }, + }); + + const responseData = await this.helpers.httpRequestWithAuthentication.call( + this, + 'crowdDevApi', + options, + ); + if (responseData === undefined || responseData.id === undefined) { + // Required data is missing so was not successful + return false; + } + + webhookData.webhookId = responseData.id as string; + + return true; + }, + + async delete(this: IHookFunctions): Promise { + const creds = await getCreds(this); + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId !== undefined) { + try { + const options = createRequest(creds, { + url: `/automation/${webhookData.webhookId}`, + method: 'DELETE', + }); + await this.helpers.httpRequestWithAuthentication.call(this, credsName, options); + } catch (error) { + return false; + } + + // Remove from the static workflow data so that it is clear + // that no webhooks are registered anymore + delete webhookData.webhookId; + delete webhookData.webhookEvents; + delete webhookData.hookSecret; + } + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const bodyData = this.getBodyData(); + + return { + workflowData: [this.helpers.returnJsonArray(bodyData)], + }; + } +} diff --git a/packages/nodes-base/nodes/CrowdDev/GenericFunctions.ts b/packages/nodes-base/nodes/CrowdDev/GenericFunctions.ts new file mode 100644 index 0000000000..5bb06fd38e --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/GenericFunctions.ts @@ -0,0 +1,221 @@ +import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; + +const addOptName = 'additionalOptions'; + +const getAllParams = (execFns: IExecuteSingleFunctions): Record => { + const params = execFns.getNode().parameters; + const keys = Object.keys(params); + const paramsWithValues = keys + .filter((i) => i !== addOptName) + .map((name) => [name, execFns.getNodeParameter(name)]); + + const paramsWithValuesObj = Object.fromEntries(paramsWithValues); + + if (keys.includes(addOptName)) { + const additionalOptions = execFns.getNodeParameter(addOptName); + return Object.assign(paramsWithValuesObj, additionalOptions); + } + + return paramsWithValuesObj; +}; + +const formatParams = ( + obj: Record, + filters?: { [paramName: string]: (value: any) => boolean }, + mappers?: { [paramName: string]: (value: any) => any }, +) => { + return Object.fromEntries( + Object.entries(obj) + .filter(([name, value]) => !filters || (name in filters ? filters[name](value) : false)) + .map(([name, value]) => + !mappers || !(name in mappers) ? [name, value] : [name, mappers[name](value)], + ), + ); +}; + +const objectFromProps = (src: any, props: string[]) => { + const result = props.filter((p) => src.hasOwnProperty(p)).map((p) => [p, src[p]]); + return Object.fromEntries(result); +}; + +const idFn = (i: any) => i; + +const keyValueToObj = (arr: any[]) => { + const obj: any = {}; + arr.forEach((item) => { + obj[item.key] = item.value; + }); + return obj; +}; + +const transformSingleProp = (prop: string) => (values: any) => + (values.itemChoice || []).map((i: any) => i[prop]); + +export async function activityPresend( + this: IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const params = getAllParams(this); + const isCreateWithMember = params.operation === 'createWithMember'; + const isCreateForMember = params.operation === 'createForMember'; + + if (isCreateWithMember) { + // Move following props into "member" subproperty + const memberProps = ['displayName', 'emails', 'joinedAt', 'username']; + params.member = objectFromProps(params, memberProps); + memberProps.forEach((p) => delete params[p]); + } + opts.body = formatParams( + params, + { + member: (v) => (isCreateWithMember || isCreateForMember) && v, + type: idFn, + timestamp: idFn, + platform: idFn, + title: idFn, + body: idFn, + channel: idFn, + sourceId: idFn, + sourceParentId: idFn, + }, + { + member: (v) => + typeof v === 'object' + ? formatParams( + v as Record, + { + username: (un) => un.itemChoice, + displayName: idFn, + emails: idFn, + joinedAt: idFn, + }, + { + username: (un) => keyValueToObj(un.itemChoice as any[]), + emails: transformSingleProp('email'), + }, + ) + : v, + }, + ); + return opts; +} + +export async function automationPresend( + this: IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const params = getAllParams(this); + opts.body = { + data: { + settings: { + url: params.url, + }, + type: 'webhook', + trigger: params.trigger, + }, + }; + return opts; +} + +export async function memberPresend( + this: IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const params = getAllParams(this); + opts.body = formatParams( + params, + { + platform: idFn, + username: idFn, + displayName: idFn, + emails: (i) => i.itemChoice, + joinedAt: idFn, + organizations: (i) => i.itemChoice, + tags: (i) => i.itemChoice, + tasks: (i) => i.itemChoice, + notes: (i) => i.itemChoice, + activities: (i) => i.itemChoice, + }, + { + emails: transformSingleProp('email'), + organizations: (i) => + i.itemChoice.map((org: any) => + formatParams( + org as Record, + { + name: idFn, + url: idFn, + description: idFn, + logo: idFn, + employees: idFn, + members: (j) => j.itemChoice, + }, + { + members: transformSingleProp('member'), + }, + ), + ), + tags: transformSingleProp('tag'), + tasks: transformSingleProp('task'), + notes: transformSingleProp('note'), + activities: transformSingleProp('activity'), + }, + ); + return opts; +} + +export async function notePresend( + this: IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const params = getAllParams(this); + opts.body = { + body: params.body, + }; + return opts; +} + +export async function organizationPresend( + this: IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const params = getAllParams(this); + opts.body = formatParams( + params, + { + name: idFn, + url: idFn, + description: idFn, + logo: idFn, + employees: idFn, + members: (j) => j.itemChoice, + }, + { + members: transformSingleProp('member'), + }, + ); + return opts; +} + +export async function taskPresend( + this: IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const params = getAllParams(this); + opts.body = formatParams( + params, + { + name: idFn, + body: idFn, + status: idFn, + members: (i) => i.itemChoice, + activities: (i) => i.itemChoice, + assigneess: idFn, + }, + { + members: transformSingleProp('member'), + activities: transformSingleProp('activity'), + }, + ); + return opts; +} diff --git a/packages/nodes-base/nodes/CrowdDev/crowdDev.svg b/packages/nodes-base/nodes/CrowdDev/crowdDev.svg new file mode 100644 index 0000000000..0ba87c1306 --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/crowdDev.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/nodes-base/nodes/CrowdDev/descriptions/activityFields.ts b/packages/nodes-base/nodes/CrowdDev/descriptions/activityFields.ts new file mode 100644 index 0000000000..f7fe7c2050 --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/descriptions/activityFields.ts @@ -0,0 +1,186 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { activityPresend } from '../GenericFunctions'; +import { emailsField } from './shared'; +import { getAdditionalOptions, mapWith, showFor } from './utils'; + +const displayOpts = showFor(['activity']); + +const displayFor = { + resource: displayOpts(), + createWithMember: displayOpts(['createWithMember']), + createForMember: displayOpts(['createForMember']), +}; + +const activityOperations: INodeProperties = { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: displayFor.resource.displayOptions, + noDataExpression: true, + default: 'createWithMember', + options: [ + { + name: 'Create or Update with a Member', + value: 'createWithMember', + description: 'Create or update an activity with a member', + action: 'Create or update an activity with a member', + routing: { + send: { preSend: [activityPresend] }, + request: { + method: 'POST', + url: '/activity/with-member', + }, + }, + }, + { + name: 'Create', + value: 'createForMember', + description: 'Create an activity for a member', + action: 'Create an activity for a member', + routing: { + send: { preSend: [activityPresend] }, + request: { + method: 'POST', + url: '/activity', + }, + }, + }, + ], +}; + +const createWithMemberFields: INodeProperties[] = [ + { + displayName: 'Username', + name: 'username', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + required: true, + default: {}, + options: [ + { + displayName: 'Item Choice', + name: 'itemChoice', + values: [ + { + displayName: 'Platform', + description: 'Platform name (e.g twitter, github, etc)', + name: 'key', + type: 'string', + required: true, + default: '', + }, + { + displayName: 'Username', + description: 'Username at the specified Platform', + name: 'value', + type: 'string', + required: true, + default: '', + }, + ], + }, + ], + }, + { + displayName: 'displayName', + name: 'displayName', + description: 'UI friendly name of the member', + type: 'string', + default: '', + }, + emailsField, + { + displayName: 'Joined At', + name: 'joinedAt', + description: 'Date of joining the community', + type: 'dateTime', + default: '', + }, +]; + +const memberIdField: INodeProperties = { + displayName: 'Member', + name: 'member', + description: 'The ID of the member that performed the activity', + type: 'string', + required: true, + default: '', +}; + +const createCommonFields: INodeProperties[] = [ + { + displayName: 'Type', + name: 'type', + description: 'Type of activity', + type: 'string', + required: true, + default: '', + }, + { + displayName: 'Timestamp', + name: 'timestamp', + description: 'Date and time when the activity took place', + type: 'dateTime', + required: true, + default: '', + }, + { + displayName: 'Platform', + name: 'platform', + description: 'Platform on which the activity took place', + type: 'string', + required: true, + default: '', + }, + { + displayName: 'Source ID', + name: 'sourceId', + description: 'The ID of the activity in the platform (e.g. the ID of the message in Discord)', + type: 'string', + required: true, + default: '', + }, +]; + +const additionalOptions: INodeProperties[] = [ + { + displayName: 'Title', + name: 'title', + description: 'Title of the activity', + type: 'string', + default: '', + }, + { + displayName: 'Body', + name: 'body', + description: 'Body of the activity', + type: 'string', + default: '', + }, + { + displayName: 'Channel', + name: 'channel', + description: 'Channel of the activity', + type: 'string', + default: '', + }, + { + displayName: 'Source Parent ID', + name: 'sourceParentId', + description: + 'The ID of the parent activity in the platform (e.g. the ID of the parent message in Discord)', + type: 'string', + default: '', + }, +]; + +const activityFields: INodeProperties[] = [ + ...createWithMemberFields.map(mapWith(displayFor.createWithMember)), + Object.assign({}, memberIdField, displayFor.createForMember), + ...createCommonFields.map(mapWith(displayFor.resource)), + Object.assign({}, getAdditionalOptions(additionalOptions), displayFor.resource), +]; + +export { activityOperations, activityFields }; diff --git a/packages/nodes-base/nodes/CrowdDev/descriptions/automationFields.ts b/packages/nodes-base/nodes/CrowdDev/descriptions/automationFields.ts new file mode 100644 index 0000000000..3607321a64 --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/descriptions/automationFields.ts @@ -0,0 +1,129 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { automationPresend } from '../GenericFunctions'; +import { mapWith, showFor } from './utils'; + +const displayOpts = showFor(['automation']); + +const displayFor = { + resource: displayOpts(), + createOrUpdate: displayOpts(['create', 'update']), + id: displayOpts(['destroy', 'find', 'update']), +}; + +const automationOperations: INodeProperties = { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: displayFor.resource.displayOptions, + noDataExpression: true, + default: 'list', + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new automation for the tenant', + action: 'Create a new automation for the tenant', + routing: { + send: { preSend: [automationPresend] }, + request: { + method: 'POST', + url: '/automation', + }, + }, + }, + { + name: 'Destroy', + value: 'destroy', + description: 'Destroy an existing automation for the tenant', + action: 'Destroy an existing automation for the tenant', + routing: { + request: { + method: 'DELETE', + url: '=/automation/{{$parameter["id"]}}', + }, + }, + }, + { + name: 'Find', + value: 'find', + description: 'Get an existing automation data for the tenant', + action: 'Get an existing automation data for the tenant', + routing: { + request: { + method: 'GET', + url: '=/automation/{{$parameter["id"]}}', + }, + }, + }, + { + name: 'List', + value: 'list', + description: 'Get all existing automation data for tenant', + action: 'Get all existing automation data for tenant', + routing: { + request: { + method: 'GET', + url: '/automation', + }, + }, + }, + { + name: 'Update', + value: 'update', + description: 'Updates an existing automation for the tenant', + action: 'Updates an existing automation for the tenant', + routing: { + send: { preSend: [automationPresend] }, + request: { + method: 'PUT', + url: '=/automation/{{$parameter["id"]}}', + }, + }, + }, + ], +}; + +const idField: INodeProperties = { + displayName: 'ID', + name: 'id', + description: 'The ID of the automation', + type: 'string', + required: true, + default: '', +}; + +const commonFields: INodeProperties[] = [ + { + displayName: 'Trigger', + name: 'trigger', + description: 'What will trigger an automation', + type: 'options', + required: true, + default: 'new_activity', + options: [ + { + name: 'New Activity', + value: 'new_activity', + }, + { + name: 'New Member', + value: 'new_member', + }, + ], + }, + { + displayName: 'URL', + name: 'url', + description: 'URL to POST webhook data to', + type: 'string', + required: true, + default: '', + }, +]; + +const automationFields: INodeProperties[] = [ + Object.assign({}, idField, displayFor.id), + ...commonFields.map(mapWith(displayFor.createOrUpdate)), +]; + +export { automationOperations, automationFields }; diff --git a/packages/nodes-base/nodes/CrowdDev/descriptions/index.ts b/packages/nodes-base/nodes/CrowdDev/descriptions/index.ts new file mode 100644 index 0000000000..2d92d79380 --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/descriptions/index.ts @@ -0,0 +1,24 @@ +import { resources } from './resources'; +import { activityOperations, activityFields } from './activityFields'; +import { memberFields, memberOperations } from './memberFields'; +import { noteFields, noteOperations } from './noteFields'; +import { organizationFields, organizationOperations } from './organizationFields'; +import { taskFields, taskOperations } from './taskFields'; +import type { INodeProperties } from 'n8n-workflow'; +import { automationFields, automationOperations } from './automationFields'; + +export const allProperties: INodeProperties[] = [ + resources, + activityOperations, + memberOperations, + noteOperations, + organizationOperations, + taskOperations, + automationOperations, + ...activityFields, + ...memberFields, + ...noteFields, + ...organizationFields, + ...taskFields, + ...automationFields, +]; diff --git a/packages/nodes-base/nodes/CrowdDev/descriptions/memberFields.ts b/packages/nodes-base/nodes/CrowdDev/descriptions/memberFields.ts new file mode 100644 index 0000000000..c42acd25c7 --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/descriptions/memberFields.ts @@ -0,0 +1,275 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { getAdditionalOptions, getId, mapWith, showFor } from './utils'; +import * as shared from './shared'; +import { memberPresend } from '../GenericFunctions'; + +const displayOpts = showFor(['member']); + +const displayFor = { + resource: displayOpts(), + createOrUpdate: displayOpts(['createOrUpdate', 'update']), + id: displayOpts(['delete', 'find', 'update']), +}; + +const memberOperations: INodeProperties = { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: displayFor.resource.displayOptions, + noDataExpression: true, + default: 'find', + options: [ + { + name: 'Create or Update', + value: 'createOrUpdate', + description: 'Create or update a member', + action: 'Create or update a member', + routing: { + send: { preSend: [memberPresend] }, + request: { + method: 'POST', + url: '/member', + }, + }, + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a member', + action: 'Delete a member', + routing: { + request: { + method: 'DELETE', + url: '=/member', + }, + }, + }, + { + name: 'Find', + value: 'find', + description: 'Find a member', + action: 'Find a member', + routing: { + request: { + method: 'GET', + url: '=/member/{{$parameter["id"]}}', + }, + }, + }, + { + name: 'Update', + value: 'update', + description: 'Update a member', + action: 'Update a member', + routing: { + send: { preSend: [memberPresend] }, + request: { + method: 'PUT', + url: '=/member/{{$parameter["id"]}}', + }, + }, + }, + ], +}; + +const commonFields: INodeProperties[] = [ + { + displayName: 'Platform', + name: 'platform', + description: 'Platform for which to check member existence', + type: 'string', + required: true, + default: '', + }, + { + displayName: 'Username', + name: 'username', + description: 'Username of the member in platform', + type: 'string', + required: true, + default: '', + }, +]; + +const additionalOptions: INodeProperties[] = [ + { + displayName: 'Display Name', + name: 'displayName', + description: 'UI friendly name of the member', + type: 'string', + default: '', + }, + shared.emailsField, + { + displayName: 'Joined At', + name: 'joinedAt', + description: 'Date of joining the community', + type: 'dateTime', + default: '', + }, + { + displayName: 'Organizations', + name: 'organizations', + description: + 'Organizations associated with the member. Each element in the array is the name of the organization, or an organization object. If the organization does not exist, it will be created.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Item Choice', + name: 'itemChoice', + values: [ + { + displayName: 'Name', + name: 'name', + description: 'The name of the organization', + type: 'string', + required: true, + default: '', + }, + { + displayName: 'Url', + name: 'url', + description: 'The URL of the organization', + type: 'string', + default: '', + }, + { + displayName: 'Description', + name: 'description', + description: 'A short description of the organization', + type: 'string', + typeOptions: { + rows: 3, + }, + default: '', + }, + { + displayName: 'Logo', + name: 'logo', + description: 'A URL for logo of the organization', + type: 'string', + default: '', + }, + { + displayName: 'Employees', + name: 'employees', + description: 'The number of employees of the organization', + type: 'number', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Tags', + name: 'tags', + description: 'Tags associated with the member. Each element in the array is the ID of the tag.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Item Choice', + name: 'itemChoice', + values: [ + { + displayName: 'Tag', + name: 'tag', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Tasks', + name: 'tasks', + description: + 'Tasks associated with the member. Each element in the array is the ID of the task.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Item Choice', + name: 'itemChoice', + values: [ + { + displayName: 'Task', + name: 'task', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Notes', + name: 'notes', + description: + 'Notes associated with the member. Each element in the array is the ID of the note.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Item Choice', + name: 'itemChoice', + values: [ + { + displayName: 'Note', + name: 'note', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Activities', + name: 'activities', + description: + 'Activities associated with the member. Each element in the array is the ID of the activity.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Item Choice', + name: 'itemChoice', + values: [ + { + displayName: 'Activity', + name: 'activity', + type: 'string', + default: '', + }, + ], + }, + ], + }, +]; + +const memberFields: INodeProperties[] = [ + Object.assign(getId(), { description: 'The ID of the member' }, displayFor.id), + ...commonFields.map(mapWith(displayFor.createOrUpdate)), + Object.assign({}, getAdditionalOptions(additionalOptions), displayFor.createOrUpdate), +]; + +export { memberOperations, memberFields }; diff --git a/packages/nodes-base/nodes/CrowdDev/descriptions/noteFields.ts b/packages/nodes-base/nodes/CrowdDev/descriptions/noteFields.ts new file mode 100644 index 0000000000..7184d88f0b --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/descriptions/noteFields.ts @@ -0,0 +1,92 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { notePresend } from '../GenericFunctions'; +import { getId, mapWith, showFor } from './utils'; + +const displayOpts = showFor(['note']); + +const displayFor = { + resource: displayOpts(), + createOrUpdate: displayOpts(['create', 'update']), + id: displayOpts(['delete', 'find', 'update']), +}; + +const noteOperations: INodeProperties = { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: displayFor.resource.displayOptions, + noDataExpression: true, + default: 'find', + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a note', + action: 'Create a note', + routing: { + send: { preSend: [notePresend] }, + request: { + method: 'POST', + url: '/note', + }, + }, + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a note', + action: 'Delete a note', + routing: { + request: { + method: 'DELETE', + url: '=/note', + }, + }, + }, + { + name: 'Find', + value: 'find', + description: 'Find a note', + action: 'Find a note', + routing: { + request: { + method: 'GET', + url: '=/note/{{$parameter["id"]}}', + }, + }, + }, + { + name: 'Update', + value: 'update', + description: 'Update a note', + action: 'Update a note', + routing: { + send: { preSend: [notePresend] }, + request: { + method: 'PUT', + url: '=/note/{{$parameter["id"]}}', + }, + }, + }, + ], +}; + +const commonFields: INodeProperties[] = [ + { + displayName: 'Body', + name: 'body', + description: 'The body of the note', + type: 'string', + typeOptions: { + rows: 4, + }, + default: '', + }, +]; + +const noteFields: INodeProperties[] = [ + Object.assign(getId(), { description: 'The ID of the note' }, displayFor.id), + ...commonFields.map(mapWith(displayFor.createOrUpdate)), +]; + +export { noteOperations, noteFields }; diff --git a/packages/nodes-base/nodes/CrowdDev/descriptions/organizationFields.ts b/packages/nodes-base/nodes/CrowdDev/descriptions/organizationFields.ts new file mode 100644 index 0000000000..283c85193e --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/descriptions/organizationFields.ts @@ -0,0 +1,150 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { organizationPresend } from '../GenericFunctions'; +import { getAdditionalOptions, getId, mapWith, showFor } from './utils'; + +const displayOpts = showFor(['organization']); + +const displayFor = { + resource: displayOpts(), + createOrUpdate: displayOpts(['create', 'update']), + id: displayOpts(['delete', 'find', 'update']), +}; + +const organizationOperations: INodeProperties = { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: displayFor.resource.displayOptions, + noDataExpression: true, + default: 'find', + options: [ + { + name: 'Create', + value: 'create', + description: 'Create an organization', + action: 'Create an organization', + routing: { + send: { preSend: [organizationPresend] }, + request: { + method: 'POST', + url: '/organization', + }, + }, + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an organization', + action: 'Delete an organization', + routing: { + request: { + method: 'DELETE', + url: '=/organization', + }, + }, + }, + { + name: 'Find', + value: 'find', + description: 'Find an organization', + action: 'Find an organization', + routing: { + request: { + method: 'GET', + url: '=/organization/{{$parameter["id"]}}', + }, + }, + }, + { + name: 'Update', + value: 'update', + description: 'Update an organization', + action: 'Update an organization', + routing: { + send: { preSend: [organizationPresend] }, + request: { + method: 'PUT', + url: '=/organization/{{$parameter["id"]}}', + }, + }, + }, + ], +}; + +const commonFields: INodeProperties[] = [ + { + displayName: 'Name', + name: 'name', + description: 'The name of the organization', + type: 'string', + required: true, + default: '', + }, +]; + +const additionalOptions: INodeProperties[] = [ + { + displayName: 'Url', + name: 'url', + description: 'The URL of the organization', + type: 'string', + default: '', + }, + { + displayName: 'Description', + name: 'description', + description: 'A short description of the organization', + type: 'string', + typeOptions: { + rows: 3, + }, + default: '', + }, + { + displayName: 'Logo', + name: 'logo', + description: 'A URL for logo of the organization', + type: 'string', + default: '', + }, + { + displayName: 'Employees', + name: 'employees', + description: 'The number of employees of the organization', + type: 'number', + default: '', + }, + { + displayName: 'Members', + name: 'members', + description: + 'Members associated with the organization. Each element in the array is the ID of the member.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Item Choice', + name: 'itemChoice', + values: [ + { + displayName: 'Member', + name: 'member', + type: 'string', + default: '', + }, + ], + }, + ], + }, +]; + +const organizationFields: INodeProperties[] = [ + Object.assign(getId(), { description: 'The ID of the organization' }, displayFor.id), + ...commonFields.map(mapWith(displayFor.createOrUpdate)), + Object.assign({}, getAdditionalOptions(additionalOptions), displayFor.createOrUpdate), +]; + +export { organizationOperations, organizationFields }; diff --git a/packages/nodes-base/nodes/CrowdDev/descriptions/resources.ts b/packages/nodes-base/nodes/CrowdDev/descriptions/resources.ts new file mode 100644 index 0000000000..430b13cb56 --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/descriptions/resources.ts @@ -0,0 +1,36 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const resources: INodeProperties = { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + default: 'activity', + placeholder: 'Resourcee', + options: [ + { + name: 'Activity', + value: 'activity', + }, + { + name: 'Automation', + value: 'automation', + }, + { + name: 'Member', + value: 'member', + }, + { + name: 'Note', + value: 'note', + }, + { + name: 'Organization', + value: 'organization', + }, + { + name: 'Task', + value: 'task', + }, + ], +}; diff --git a/packages/nodes-base/nodes/CrowdDev/descriptions/shared.ts b/packages/nodes-base/nodes/CrowdDev/descriptions/shared.ts new file mode 100644 index 0000000000..3e0711000b --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/descriptions/shared.ts @@ -0,0 +1,27 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const emailsField: INodeProperties = { + displayName: 'Emails', + name: 'emails', + description: 'Email addresses of the member', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Item Choice', + name: 'itemChoice', + values: [ + { + displayName: 'Email', + name: 'email', + type: 'string', + placeholder: 'name@email.com', + default: '', + }, + ], + }, + ], +}; diff --git a/packages/nodes-base/nodes/CrowdDev/descriptions/taskFields.ts b/packages/nodes-base/nodes/CrowdDev/descriptions/taskFields.ts new file mode 100644 index 0000000000..8a39b0031a --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/descriptions/taskFields.ts @@ -0,0 +1,163 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { taskPresend } from '../GenericFunctions'; +import { getAdditionalOptions, getId, showFor } from './utils'; + +const displayOpts = showFor(['task']); + +const displayFor = { + resource: displayOpts(), + createOrUpdate: displayOpts(['create', 'update']), + id: displayOpts(['delete', 'find', 'update']), +}; + +const taskOperations: INodeProperties = { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: displayFor.resource.displayOptions, + noDataExpression: true, + default: 'find', + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a task', + action: 'Create a task', + routing: { + send: { preSend: [taskPresend] }, + request: { + method: 'POST', + url: '/task', + }, + }, + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a task', + action: 'Delete a task', + routing: { + request: { + method: 'DELETE', + url: '=/task', + }, + }, + }, + { + name: 'Find', + value: 'find', + description: 'Find a task', + action: 'Find a task', + routing: { + request: { + method: 'GET', + url: '=/task/{{$parameter["id"]}}', + }, + }, + }, + { + name: 'Update', + value: 'update', + description: 'Update a task', + action: 'Update a task', + routing: { + send: { preSend: [taskPresend] }, + request: { + method: 'PUT', + url: '=/task/{{$parameter["id"]}}', + }, + }, + }, + ], +}; + +const additionalOptions: INodeProperties[] = [ + { + displayName: 'Name', + name: 'name', + description: 'The name of the task', + type: 'string', + default: '', + }, + { + displayName: 'Body', + name: 'body', + description: 'The body of the task', + type: 'string', + typeOptions: { + rows: 4, + }, + default: '', + }, + { + displayName: 'Status', + name: 'status', + description: 'The status of the task', + type: 'string', + default: '', + }, + { + displayName: 'Members', + name: 'members', + description: + 'Members associated with the task. Each element in the array is the ID of the member.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Item Choice', + name: 'itemChoice', + values: [ + { + displayName: 'Member', + name: 'member', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Activities', + name: 'activities', + description: + 'Activities associated with the task. Each element in the array is the ID of the activity.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Item Choice', + name: 'itemChoice', + values: [ + { + displayName: 'Activity', + name: 'activity', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Assigneess', + name: 'assigneess', + description: 'Users assigned with the task. Each element in the array is the ID of the user.', + type: 'string', + default: '', + }, +]; + +const taskFields: INodeProperties[] = [ + Object.assign(getId(), { description: 'The ID of the task' }, displayFor.id), + Object.assign({}, getAdditionalOptions(additionalOptions), displayFor.createOrUpdate), +]; + +export { taskOperations, taskFields }; diff --git a/packages/nodes-base/nodes/CrowdDev/descriptions/utils.ts b/packages/nodes-base/nodes/CrowdDev/descriptions/utils.ts new file mode 100644 index 0000000000..35488eea44 --- /dev/null +++ b/packages/nodes-base/nodes/CrowdDev/descriptions/utils.ts @@ -0,0 +1,57 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const showFor = + (resources: string[]) => + (operations?: string[]): Partial => { + return operations !== undefined + ? { + displayOptions: { + show: { + resource: resources, + operation: operations, + }, + }, + } + : { + displayOptions: { + show: { + resource: resources, + }, + }, + }; + }; + +export const mapWith = + (...objects: Array>) => + (item: Partial) => + Object.assign({}, item, ...objects); + +export const getId = (): INodeProperties => ({ + displayName: 'ID', + name: 'id', + type: 'string', + required: true, + default: '', + routing: { + send: { + type: 'query', + property: 'ids[]', + }, + }, +}); + +export const getAdditionalOptions = (fields: INodeProperties[]): INodeProperties => { + return { + displayName: 'Additional Options', + name: 'additionalOptions', + type: 'collection', + displayOptions: { + show: { + operation: ['getAll'], + }, + }, + default: {}, + placeholder: 'Add Option', + options: fields, + }; +}; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 05a52f8deb..06e95615a5 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -77,6 +77,7 @@ "dist/credentials/CopperApi.credentials.js", "dist/credentials/CortexApi.credentials.js", "dist/credentials/CrateDb.credentials.js", + "dist/credentials/CrowdDevApi.credentials.js", "dist/credentials/CustomerIoApi.credentials.js", "dist/credentials/DeepLApi.credentials.js", "dist/credentials/DemioApi.credentials.js", @@ -423,6 +424,8 @@ "dist/nodes/Cortex/Cortex.node.js", "dist/nodes/CrateDb/CrateDb.node.js", "dist/nodes/Cron/Cron.node.js", + "dist/nodes/CrowdDev/CrowdDev.node.js", + "dist/nodes/CrowdDev/CrowdDevTrigger.node.js", "dist/nodes/Crypto/Crypto.node.js", "dist/nodes/CustomerIo/CustomerIo.node.js", "dist/nodes/CustomerIo/CustomerIoTrigger.node.js", From 22a950aa2246f00cdaa9612f17c03d13f706f058 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Mon, 3 Jul 2023 15:01:01 +0300 Subject: [PATCH 34/46] refactor(Item Lists Node): Refactoring (#6575) --- .../nodes/ItemLists/ItemLists.node.ts | 5 +- .../nodes/ItemLists/V3/ItemListsV3.node.ts | 24 + .../V3/actions/common.descriptions.ts | 10 + .../itemList/concatenateItems.operation.ts | 349 ++++++++++ .../ItemLists/V3/actions/itemList/index.ts | 70 ++ .../V3/actions/itemList/limit.operation.ts | 62 ++ .../itemList/removeDuplicates.operation.ts | 246 +++++++ .../V3/actions/itemList/sort.operation.ts | 303 +++++++++ .../itemList/splitOutItems.operation.ts | 218 +++++++ .../actions/itemList/summarize.operation.ts | 617 ++++++++++++++++++ .../nodes/ItemLists/V3/actions/node.type.ts | 13 + .../nodes/ItemLists/V3/actions/router.ts | 35 + .../V3/actions/versionDescription.ts | 35 + .../nodes/ItemLists/V3/helpers/utils.ts | 59 ++ .../test/node/workflow.update_3.json | 606 +++++++++++++++++ 15 files changed, 2650 insertions(+), 2 deletions(-) create mode 100644 packages/nodes-base/nodes/ItemLists/V3/ItemListsV3.node.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/common.descriptions.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/itemList/concatenateItems.operation.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/itemList/index.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/itemList/limit.operation.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/itemList/removeDuplicates.operation.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/itemList/sort.operation.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/itemList/splitOutItems.operation.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/itemList/summarize.operation.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/node.type.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/router.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/actions/versionDescription.ts create mode 100644 packages/nodes-base/nodes/ItemLists/V3/helpers/utils.ts create mode 100644 packages/nodes-base/nodes/ItemLists/test/node/workflow.update_3.json diff --git a/packages/nodes-base/nodes/ItemLists/ItemLists.node.ts b/packages/nodes-base/nodes/ItemLists/ItemLists.node.ts index dade91b64f..d454c14e92 100644 --- a/packages/nodes-base/nodes/ItemLists/ItemLists.node.ts +++ b/packages/nodes-base/nodes/ItemLists/ItemLists.node.ts @@ -2,8 +2,8 @@ import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow' import { VersionedNodeType } from 'n8n-workflow'; import { ItemListsV1 } from './V1/ItemListsV1.node'; - import { ItemListsV2 } from './V2/ItemListsV2.node'; +import { ItemListsV3 } from './V3/ItemListsV3.node'; export class ItemLists extends VersionedNodeType { constructor() { @@ -14,7 +14,7 @@ export class ItemLists extends VersionedNodeType { group: ['input'], subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Helper for working with lists of items and transforming arrays', - defaultVersion: 2.2, + defaultVersion: 3, }; const nodeVersions: IVersionedNodeType['nodeVersions'] = { @@ -22,6 +22,7 @@ export class ItemLists extends VersionedNodeType { 2: new ItemListsV2(baseDescription), 2.1: new ItemListsV2(baseDescription), 2.2: new ItemListsV2(baseDescription), + 3: new ItemListsV3(baseDescription), }; super(nodeVersions, baseDescription); diff --git a/packages/nodes-base/nodes/ItemLists/V3/ItemListsV3.node.ts b/packages/nodes-base/nodes/ItemLists/V3/ItemListsV3.node.ts new file mode 100644 index 0000000000..84c8826619 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/ItemListsV3.node.ts @@ -0,0 +1,24 @@ +import type { + IExecuteFunctions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { versionDescription } from './actions/versionDescription'; +import { router } from './actions/router'; + +export class ItemListsV3 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + async execute(this: IExecuteFunctions) { + return router.call(this); + } +} diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/common.descriptions.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/common.descriptions.ts new file mode 100644 index 0000000000..d25b10e68b --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/common.descriptions.ts @@ -0,0 +1,10 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const disableDotNotationBoolean: INodeProperties = { + displayName: 'Disable Dot Notation', + name: 'disableDotNotation', + type: 'boolean', + default: false, + description: + 'Whether to disallow referencing child fields using `parent.child` in the field name', +}; diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/concatenateItems.operation.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/concatenateItems.operation.ts new file mode 100644 index 0000000000..99a4279471 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/concatenateItems.operation.ts @@ -0,0 +1,349 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { updateDisplayOptions } from '@utils/utilities'; + +import get from 'lodash/get'; +import isEmpty from 'lodash/isEmpty'; +import set from 'lodash/set'; + +import { prepareFieldsArray } from '../../helpers/utils'; +import { disableDotNotationBoolean } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + displayName: 'Aggregate', + name: 'aggregate', + type: 'options', + default: 'aggregateIndividualFields', + options: [ + { + name: 'Individual Fields', + value: 'aggregateIndividualFields', + }, + { + name: 'All Item Data (Into a Single List)', + value: 'aggregateAllItemData', + }, + ], + }, + { + displayName: 'Fields To Aggregate', + name: 'fieldsToAggregate', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Field To Aggregate', + default: { fieldToAggregate: [{ fieldToAggregate: '', renameField: false }] }, + displayOptions: { + show: { + aggregate: ['aggregateIndividualFields'], + }, + }, + options: [ + { + displayName: '', + name: 'fieldToAggregate', + values: [ + { + displayName: 'Input Field Name', + name: 'fieldToAggregate', + type: 'string', + default: '', + description: 'The name of a field in the input items to aggregate together', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', + requiresDataPath: 'single', + }, + { + displayName: 'Rename Field', + name: 'renameField', + type: 'boolean', + default: false, + description: 'Whether to give the field a different name in the output', + }, + { + displayName: 'Output Field Name', + name: 'outputFieldName', + displayOptions: { + show: { + renameField: [true], + }, + }, + type: 'string', + default: '', + description: + 'The name of the field to put the aggregated data in. Leave blank to use the input field name.', + requiresDataPath: 'single', + }, + ], + }, + ], + }, + { + displayName: 'Put Output in Field', + name: 'destinationFieldName', + type: 'string', + displayOptions: { + show: { + aggregate: ['aggregateAllItemData'], + }, + }, + default: 'data', + description: 'The name of the output field to put the data in', + }, + { + displayName: 'Include', + name: 'include', + type: 'options', + default: 'allFields', + options: [ + { + name: 'All Fields', + value: 'allFields', + }, + { + name: 'Specified Fields', + value: 'specifiedFields', + }, + { + name: 'All Fields Except', + value: 'allFieldsExcept', + }, + ], + displayOptions: { + show: { + aggregate: ['aggregateAllItemData'], + }, + }, + }, + { + displayName: 'Fields To Exclude', + name: 'fieldsToExclude', + type: 'string', + placeholder: 'e.g. email, name', + default: '', + requiresDataPath: 'multiple', + displayOptions: { + show: { + aggregate: ['aggregateAllItemData'], + include: ['allFieldsExcept'], + }, + }, + }, + { + displayName: 'Fields To Include', + name: 'fieldsToInclude', + type: 'string', + placeholder: 'e.g. email, name', + default: '', + requiresDataPath: 'multiple', + displayOptions: { + show: { + aggregate: ['aggregateAllItemData'], + include: ['specifiedFields'], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + hide: { + aggregate: ['aggregateAllItemData'], + }, + }, + options: [ + disableDotNotationBoolean, + { + displayName: 'Merge Lists', + name: 'mergeLists', + type: 'boolean', + default: false, + description: + 'Whether to merge the output into a single flat list (rather than a list of lists), if the field to aggregate is a list', + }, + { + displayName: 'Keep Missing And Null Values', + name: 'keepMissing', + type: 'boolean', + default: false, + description: + 'Whether to add a null entry to the aggregated list when there is a missing or null value', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['itemList'], + operation: ['concatenateItems'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + const returnData: INodeExecutionData[] = []; + + const aggregate = this.getNodeParameter('aggregate', 0, '') as string; + + if (aggregate === 'aggregateIndividualFields') { + const disableDotNotation = this.getNodeParameter( + 'options.disableDotNotation', + 0, + false, + ) as boolean; + const mergeLists = this.getNodeParameter('options.mergeLists', 0, false) as boolean; + const fieldsToAggregate = this.getNodeParameter( + 'fieldsToAggregate.fieldToAggregate', + 0, + [], + ) as [{ fieldToAggregate: string; renameField: boolean; outputFieldName: string }]; + const keepMissing = this.getNodeParameter('options.keepMissing', 0, false) as boolean; + + if (!fieldsToAggregate.length) { + throw new NodeOperationError(this.getNode(), 'No fields specified', { + description: 'Please add a field to aggregate', + }); + } + + const newItem: INodeExecutionData = { + json: {}, + pairedItem: Array.from({ length: items.length }, (_, i) => i).map((index) => { + return { + item: index, + }; + }), + }; + + const values: { [key: string]: any } = {}; + const outputFields: string[] = []; + + for (const { fieldToAggregate, outputFieldName, renameField } of fieldsToAggregate) { + const field = renameField ? outputFieldName : fieldToAggregate; + + if (outputFields.includes(field)) { + throw new NodeOperationError( + this.getNode(), + `The '${field}' output field is used more than once`, + { description: 'Please make sure each output field name is unique' }, + ); + } else { + outputFields.push(field); + } + + const getFieldToAggregate = () => + !disableDotNotation && fieldToAggregate.includes('.') + ? fieldToAggregate.split('.').pop() + : fieldToAggregate; + + const _outputFieldName = outputFieldName + ? outputFieldName + : (getFieldToAggregate() as string); + + if (fieldToAggregate !== '') { + values[_outputFieldName] = []; + for (let i = 0; i < items.length; i++) { + if (!disableDotNotation) { + let value = get(items[i].json, fieldToAggregate); + + if (!keepMissing) { + if (Array.isArray(value)) { + value = value.filter((entry) => entry !== null); + } else if (value === null || value === undefined) { + continue; + } + } + + if (Array.isArray(value) && mergeLists) { + values[_outputFieldName].push(...value); + } else { + values[_outputFieldName].push(value); + } + } else { + let value = items[i].json[fieldToAggregate]; + + if (!keepMissing) { + if (Array.isArray(value)) { + value = value.filter((entry) => entry !== null); + } else if (value === null || value === undefined) { + continue; + } + } + + if (Array.isArray(value) && mergeLists) { + values[_outputFieldName].push(...value); + } else { + values[_outputFieldName].push(value); + } + } + } + } + } + + for (const key of Object.keys(values)) { + if (!disableDotNotation) { + set(newItem.json, key, values[key]); + } else { + newItem.json[key] = values[key]; + } + } + + returnData.push(newItem); + } else { + let newItems: IDataObject[] = items.map((item) => item.json); + const destinationFieldName = this.getNodeParameter('destinationFieldName', 0) as string; + + const fieldsToExclude = prepareFieldsArray( + this.getNodeParameter('fieldsToExclude', 0, '') as string, + 'Fields To Exclude', + ); + + const fieldsToInclude = prepareFieldsArray( + this.getNodeParameter('fieldsToInclude', 0, '') as string, + 'Fields To Include', + ); + + if (fieldsToExclude.length || fieldsToInclude.length) { + newItems = newItems.reduce((acc, item) => { + const newItem: IDataObject = {}; + let outputFields = Object.keys(item); + + if (fieldsToExclude.length) { + outputFields = outputFields.filter((key) => !fieldsToExclude.includes(key)); + } + if (fieldsToInclude.length) { + outputFields = outputFields.filter((key) => + fieldsToInclude.length ? fieldsToInclude.includes(key) : true, + ); + } + + outputFields.forEach((key) => { + newItem[key] = item[key]; + }); + + if (isEmpty(newItem)) { + return acc; + } + return acc.concat([newItem]); + }, [] as IDataObject[]); + } + + const output: INodeExecutionData = { json: { [destinationFieldName]: newItems } }; + + returnData.push(output); + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/index.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/index.ts new file mode 100644 index 0000000000..6cb20a66e3 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/index.ts @@ -0,0 +1,70 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as concatenateItems from './concatenateItems.operation'; +import * as limit from './limit.operation'; +import * as removeDuplicates from './removeDuplicates.operation'; +import * as sort from './sort.operation'; +import * as splitOutItems from './splitOutItems.operation'; +import * as summarize from './summarize.operation'; + +export { concatenateItems, limit, removeDuplicates, sort, splitOutItems, summarize }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['itemList'], + }, + }, + options: [ + { + name: 'Concatenate Items', + value: 'concatenateItems', + description: 'Combine fields into a list in a single new item', + action: 'Concatenate Items', + }, + { + name: 'Limit', + value: 'limit', + description: 'Remove items if there are too many', + action: 'Limit', + }, + { + name: 'Remove Duplicates', + value: 'removeDuplicates', + description: 'Remove extra items that are similar', + action: 'Remove Duplicates', + }, + { + name: 'Sort', + value: 'sort', + description: 'Change the item order', + action: 'Sort', + }, + { + name: 'Split Out Items', + value: 'splitOutItems', + description: + "Turn a list or values of object's properties inside item(s) into separate items", + action: 'Split Out Items', + }, + { + name: 'Summarize', + value: 'summarize', + description: 'Aggregate items together (pivot table)', + action: 'Summarize', + }, + ], + default: 'splitOutItems', + }, + ...concatenateItems.description, + ...limit.description, + ...removeDuplicates.description, + ...sort.description, + ...splitOutItems.description, + ...summarize.description, +]; diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/limit.operation.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/limit.operation.ts new file mode 100644 index 0000000000..8fbbbf3c6e --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/limit.operation.ts @@ -0,0 +1,62 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from '@utils/utilities'; + +const properties: INodeProperties[] = [ + { + displayName: 'Max Items', + name: 'maxItems', + type: 'number', + typeOptions: { + minValue: 1, + }, + default: 1, + description: 'If there are more items than this number, some are removed', + }, + { + displayName: 'Keep', + name: 'keep', + type: 'options', + options: [ + { + name: 'First Items', + value: 'firstItems', + }, + { + name: 'Last Items', + value: 'lastItems', + }, + ], + default: 'firstItems', + description: 'When removing items, whether to keep the ones at the start or the ending', + }, +]; + +const displayOptions = { + show: { + resource: ['itemList'], + operation: ['limit'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + let returnData = items; + const maxItems = this.getNodeParameter('maxItems', 0) as number; + const keep = this.getNodeParameter('keep', 0) as string; + + if (maxItems > items.length) { + return returnData; + } + + if (keep === 'firstItems') { + returnData = items.slice(0, maxItems); + } else { + returnData = items.slice(items.length - maxItems, items.length); + } + return returnData; +} diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/removeDuplicates.operation.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/removeDuplicates.operation.ts new file mode 100644 index 0000000000..c13e0f6b75 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/removeDuplicates.operation.ts @@ -0,0 +1,246 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { updateDisplayOptions } from '@utils/utilities'; + +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import lt from 'lodash/lt'; +import pick from 'lodash/pick'; + +import { compareItems, flattenKeys, prepareFieldsArray } from '../../helpers/utils'; +import { disableDotNotationBoolean } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + displayName: 'Compare', + name: 'compare', + type: 'options', + options: [ + { + name: 'All Fields', + value: 'allFields', + }, + { + name: 'All Fields Except', + value: 'allFieldsExcept', + }, + { + name: 'Selected Fields', + value: 'selectedFields', + }, + ], + default: 'allFields', + description: 'The fields of the input items to compare to see if they are the same', + }, + { + displayName: 'Fields To Exclude', + name: 'fieldsToExclude', + type: 'string', + placeholder: 'e.g. email, name', + requiresDataPath: 'multiple', + description: 'Fields in the input to exclude from the comparison', + default: '', + displayOptions: { + show: { + compare: ['allFieldsExcept'], + }, + }, + }, + { + displayName: 'Fields To Compare', + name: 'fieldsToCompare', + type: 'string', + placeholder: 'e.g. email, name', + requiresDataPath: 'multiple', + description: 'Fields in the input to add to the comparison', + default: '', + displayOptions: { + show: { + compare: ['selectedFields'], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + compare: ['allFieldsExcept', 'selectedFields'], + }, + }, + options: [ + disableDotNotationBoolean, + { + displayName: 'Remove Other Fields', + name: 'removeOtherFields', + type: 'boolean', + default: false, + description: + 'Whether to remove any fields that are not being compared. If disabled, will keep the values from the first of the duplicates.', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['itemList'], + operation: ['removeDuplicates'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + const compare = this.getNodeParameter('compare', 0) as string; + const disableDotNotation = this.getNodeParameter( + 'options.disableDotNotation', + 0, + false, + ) as boolean; + const removeOtherFields = this.getNodeParameter('options.removeOtherFields', 0, false) as boolean; + + let keys = disableDotNotation + ? Object.keys(items[0].json) + : Object.keys(flattenKeys(items[0].json)); + + for (const item of items) { + for (const key of disableDotNotation + ? Object.keys(item.json) + : Object.keys(flattenKeys(item.json))) { + if (!keys.includes(key)) { + keys.push(key); + } + } + } + + if (compare === 'allFieldsExcept') { + const fieldsToExclude = prepareFieldsArray( + this.getNodeParameter('fieldsToExclude', 0, '') as string, + 'Fields To Exclude', + ); + + if (!fieldsToExclude.length) { + throw new NodeOperationError( + this.getNode(), + 'No fields specified. Please add a field to exclude from comparison', + ); + } + if (!disableDotNotation) { + keys = Object.keys(flattenKeys(items[0].json)); + } + keys = keys.filter((key) => !fieldsToExclude.includes(key)); + } + if (compare === 'selectedFields') { + const fieldsToCompare = prepareFieldsArray( + this.getNodeParameter('fieldsToCompare', 0, '') as string, + 'Fields To Compare', + ); + if (!fieldsToCompare.length) { + throw new NodeOperationError( + this.getNode(), + 'No fields specified. Please add a field to compare on', + ); + } + if (!disableDotNotation) { + keys = Object.keys(flattenKeys(items[0].json)); + } + keys = fieldsToCompare.map((key) => key.trim()); + } + + // This solution is O(nlogn) + // add original index to the items + const newItems = items.map( + (item, index) => + ({ + json: { ...item.json, __INDEX: index }, + pairedItem: { item: index }, + } as INodeExecutionData), + ); + //sort items using the compare keys + newItems.sort((a, b) => { + let result = 0; + + for (const key of keys) { + let equal; + if (!disableDotNotation) { + equal = isEqual(get(a.json, key), get(b.json, key)); + } else { + equal = isEqual(a.json[key], b.json[key]); + } + if (!equal) { + let lessThan; + if (!disableDotNotation) { + lessThan = lt(get(a.json, key), get(b.json, key)); + } else { + lessThan = lt(a.json[key], b.json[key]); + } + result = lessThan ? -1 : 1; + break; + } + } + return result; + }); + + for (const key of keys) { + let type: any = undefined; + for (const item of newItems) { + if (key === '') { + throw new NodeOperationError(this.getNode(), 'Name of field to compare is blank'); + } + const value = !disableDotNotation ? get(item.json, key) : item.json[key]; + if (value === undefined && disableDotNotation && key.includes('.')) { + throw new NodeOperationError( + this.getNode(), + `'${key}' field is missing from some input items`, + { + description: + "If you're trying to use a nested field, make sure you turn off 'disable dot notation' in the node options", + }, + ); + } else if (value === undefined) { + throw new NodeOperationError( + this.getNode(), + `'${key}' field is missing from some input items`, + ); + } + if (type !== undefined && value !== undefined && type !== typeof value) { + throw new NodeOperationError(this.getNode(), `'${key}' isn't always the same type`, { + description: 'The type of this field varies between items', + }); + } else { + type = typeof value; + } + } + } + + // collect the original indexes of items to be removed + const removedIndexes: number[] = []; + let temp = newItems[0]; + for (let index = 1; index < newItems.length; index++) { + if (compareItems(newItems[index], temp, keys, disableDotNotation, this.getNode())) { + removedIndexes.push(newItems[index].json.__INDEX as unknown as number); + } else { + temp = newItems[index]; + } + } + + let returnData = items.filter((_, index) => !removedIndexes.includes(index)); + + if (removeOtherFields) { + returnData = returnData.map((item, index) => ({ + json: pick(item.json, ...keys), + pairedItem: { item: index }, + })); + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/sort.operation.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/sort.operation.ts new file mode 100644 index 0000000000..18d2392dc2 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/sort.operation.ts @@ -0,0 +1,303 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { updateDisplayOptions } from '@utils/utilities'; + +import type { NodeVMOptions } from 'vm2'; +import { NodeVM } from 'vm2'; + +import get from 'lodash/get'; + +import isEqual from 'lodash/isEqual'; +import lt from 'lodash/lt'; + +import { shuffleArray } from '../../helpers/utils'; +import { disableDotNotationBoolean } from '../common.descriptions'; + +const properties: INodeProperties[] = [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Simple', + value: 'simple', + }, + { + name: 'Random', + value: 'random', + }, + { + name: 'Code', + value: 'code', + }, + ], + default: 'simple', + description: 'The fields of the input items to compare to see if they are the same', + }, + { + displayName: 'Fields To Sort By', + name: 'sortFieldsUi', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Field To Sort By', + options: [ + { + displayName: '', + name: 'sortField', + values: [ + { + displayName: 'Field Name', + name: 'fieldName', + type: 'string', + required: true, + default: '', + description: 'The field to sort by', + // eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id + placeholder: 'e.g. id', + hint: ' Enter the field name as text', + requiresDataPath: 'single', + }, + { + displayName: 'Order', + name: 'order', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'ascending', + }, + { + name: 'Descending', + value: 'descending', + }, + ], + default: 'ascending', + description: 'The order to sort by', + }, + ], + }, + ], + default: {}, + description: 'The fields of the input items to compare to see if they are the same', + displayOptions: { + show: { + type: ['simple'], + }, + }, + }, + { + displayName: 'Code', + name: 'code', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + editor: 'code', + rows: 10, + }, + default: `// The two items to compare are in the variables a and b +// Access the fields in a.json and b.json +// Return -1 if a should go before b +// Return 1 if b should go before a +// Return 0 if there's no difference + +fieldName = 'myField'; + +if (a.json[fieldName] < b.json[fieldName]) { +return -1; +} +if (a.json[fieldName] > b.json[fieldName]) { +return 1; +} +return 0;`, + description: 'Javascript code to determine the order of any two items', + displayOptions: { + show: { + type: ['code'], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + type: ['simple'], + }, + }, + options: [disableDotNotationBoolean], + }, +]; + +const displayOptions = { + show: { + resource: ['itemList'], + operation: ['sort'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + let returnData = [...items]; + const type = this.getNodeParameter('type', 0) as string; + const disableDotNotation = this.getNodeParameter( + 'options.disableDotNotation', + 0, + false, + ) as boolean; + + if (type === 'random') { + shuffleArray(returnData); + return returnData; + } + + if (type === 'simple') { + const sortFieldsUi = this.getNodeParameter('sortFieldsUi', 0) as IDataObject; + const sortFields = sortFieldsUi.sortField as Array<{ + fieldName: string; + order: 'ascending' | 'descending'; + }>; + + if (!sortFields?.length) { + throw new NodeOperationError( + this.getNode(), + 'No sorting specified. Please add a field to sort by', + ); + } + + for (const { fieldName } of sortFields) { + let found = false; + for (const item of items) { + if (!disableDotNotation) { + if (get(item.json, fieldName) !== undefined) { + found = true; + } + } else if (item.json.hasOwnProperty(fieldName)) { + found = true; + } + } + if (!found && disableDotNotation && fieldName.includes('.')) { + throw new NodeOperationError( + this.getNode(), + `Couldn't find the field '${fieldName}' in the input data`, + { + description: + "If you're trying to use a nested field, make sure you turn off 'disable dot notation' in the node options", + }, + ); + } else if (!found) { + throw new NodeOperationError( + this.getNode(), + `Couldn't find the field '${fieldName}' in the input data`, + ); + } + } + + const sortFieldsWithDirection = sortFields.map((field) => ({ + name: field.fieldName, + dir: field.order === 'ascending' ? 1 : -1, + })); + + returnData.sort((a, b) => { + let result = 0; + for (const field of sortFieldsWithDirection) { + let equal; + if (!disableDotNotation) { + const _a = + typeof get(a.json, field.name) === 'string' + ? (get(a.json, field.name) as string).toLowerCase() + : get(a.json, field.name); + const _b = + typeof get(b.json, field.name) === 'string' + ? (get(b.json, field.name) as string).toLowerCase() + : get(b.json, field.name); + equal = isEqual(_a, _b); + } else { + const _a = + typeof a.json[field.name] === 'string' + ? (a.json[field.name] as string).toLowerCase() + : a.json[field.name]; + const _b = + typeof b.json[field.name] === 'string' + ? (b.json[field.name] as string).toLowerCase() + : b.json[field.name]; + equal = isEqual(_a, _b); + } + + if (!equal) { + let lessThan; + if (!disableDotNotation) { + const _a = + typeof get(a.json, field.name) === 'string' + ? (get(a.json, field.name) as string).toLowerCase() + : get(a.json, field.name); + const _b = + typeof get(b.json, field.name) === 'string' + ? (get(b.json, field.name) as string).toLowerCase() + : get(b.json, field.name); + lessThan = lt(_a, _b); + } else { + const _a = + typeof a.json[field.name] === 'string' + ? (a.json[field.name] as string).toLowerCase() + : a.json[field.name]; + const _b = + typeof b.json[field.name] === 'string' + ? (b.json[field.name] as string).toLowerCase() + : b.json[field.name]; + lessThan = lt(_a, _b); + } + if (lessThan) { + result = -1 * field.dir; + } else { + result = 1 * field.dir; + } + break; + } + } + return result; + }); + } else { + const code = this.getNodeParameter('code', 0) as string; + const regexCheck = /\breturn\b/g.exec(code); + + if (regexCheck?.length) { + const sandbox = { + newItems: returnData, + }; + const mode = this.getMode(); + const options = { + console: mode === 'manual' ? 'redirect' : 'inherit', + sandbox, + }; + const vm = new NodeVM(options as unknown as NodeVMOptions); + + returnData = await vm.run( + ` + module.exports = async function() { + newItems.sort( (a,b) => { + ${code} + }) + return newItems; + }()`, + __dirname, + ); + } else { + throw new NodeOperationError( + this.getNode(), + "Sort code doesn't return. Please add a 'return' statement to your code", + ); + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/splitOutItems.operation.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/splitOutItems.operation.ts new file mode 100644 index 0000000000..cc4ee758a6 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/splitOutItems.operation.ts @@ -0,0 +1,218 @@ +import type { IExecuteFunctions } from 'n8n-core'; +import type { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { deepCopy, NodeOperationError } from 'n8n-workflow'; + +import { updateDisplayOptions } from '@utils/utilities'; + +import get from 'lodash/get'; +import unset from 'lodash/unset'; +import { disableDotNotationBoolean } from '../common.descriptions'; +import { prepareFieldsArray } from '../../helpers/utils'; + +const properties: INodeProperties[] = [ + { + displayName: 'Fields To Split Out', + name: 'fieldToSplitOut', + type: 'string', + default: '', + required: true, + description: 'The name of the input fields to break out into separate items', + requiresDataPath: 'multiple', + }, + { + displayName: 'Include', + name: 'include', + type: 'options', + options: [ + { + name: 'No Other Fields', + value: 'noOtherFields', + }, + { + name: 'All Other Fields', + value: 'allOtherFields', + }, + { + name: 'Selected Other Fields', + value: 'selectedOtherFields', + }, + ], + default: 'noOtherFields', + description: 'Whether to copy any other fields into the new items', + }, + { + displayName: 'Fields To Include', + name: 'fieldsToInclude', + type: 'string', + placeholder: 'e.g. email, name', + requiresDataPath: 'multiple', + description: 'Fields in the input items to aggregate together', + default: '', + displayOptions: { + show: { + include: ['selectedOtherFields'], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + disableDotNotationBoolean, + { + displayName: 'Destination Field Name', + name: 'destinationFieldName', + type: 'string', + requiresDataPath: 'multiple', + default: '', + description: 'The field in the output under which to put the split field contents', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['itemList'], + operation: ['splitOutItems'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + const fieldsToSplitOut = (this.getNodeParameter('fieldToSplitOut', i) as string) + .split(',') + .map((field) => field.trim()); + const disableDotNotation = this.getNodeParameter( + 'options.disableDotNotation', + 0, + false, + ) as boolean; + + const destinationFields = ( + this.getNodeParameter('options.destinationFieldName', i, '') as string + ) + .split(',') + .filter((field) => field.trim() !== '') + .map((field) => field.trim()); + + if (destinationFields.length && destinationFields.length !== fieldsToSplitOut.length) { + throw new NodeOperationError( + this.getNode(), + 'If multiple fields to split out are given, the same number of destination fields must be given', + ); + } + + const include = this.getNodeParameter('include', i) as + | 'selectedOtherFields' + | 'allOtherFields' + | 'noOtherFields'; + + const multiSplit = fieldsToSplitOut.length > 1; + + const item = { ...items[i].json }; + const splited: IDataObject[] = []; + for (const [entryIndex, fieldToSplitOut] of fieldsToSplitOut.entries()) { + const destinationFieldName = destinationFields[entryIndex] || ''; + + let arrayToSplit; + if (!disableDotNotation) { + arrayToSplit = get(item, fieldToSplitOut); + } else { + arrayToSplit = item[fieldToSplitOut]; + } + + if (arrayToSplit === undefined) { + arrayToSplit = []; + } + + if (typeof arrayToSplit !== 'object' || arrayToSplit === null) { + arrayToSplit = [arrayToSplit]; + } + + if (!Array.isArray(arrayToSplit)) { + arrayToSplit = Object.values(arrayToSplit); + } + + for (const [elementIndex, element] of arrayToSplit.entries()) { + if (splited[elementIndex] === undefined) { + splited[elementIndex] = {}; + } + + const fieldName = destinationFieldName || fieldToSplitOut; + + if (typeof element === 'object' && element !== null && include === 'noOtherFields') { + if (destinationFieldName === '' && !multiSplit) { + splited[elementIndex] = { ...splited[elementIndex], ...element }; + } else { + splited[elementIndex][fieldName] = element; + } + } else { + splited[elementIndex][fieldName] = element; + } + } + } + + for (const splitEntry of splited) { + let newItem: IDataObject = {}; + + if (include === 'noOtherFields') { + newItem = splitEntry; + } + + if (include === 'allOtherFields') { + const itemCopy = deepCopy(item); + for (const fieldToSplitOut of fieldsToSplitOut) { + if (!disableDotNotation) { + unset(itemCopy, fieldToSplitOut); + } else { + delete itemCopy[fieldToSplitOut]; + } + } + newItem = { ...itemCopy, ...splitEntry }; + } + + if (include === 'selectedOtherFields') { + const fieldsToInclude = prepareFieldsArray( + this.getNodeParameter('fieldsToInclude', i, '') as string, + 'Fields To Include', + ); + + if (!fieldsToInclude.length) { + throw new NodeOperationError(this.getNode(), 'No fields specified', { + description: 'Please add a field to include', + }); + } + + for (const field of fieldsToInclude) { + if (!disableDotNotation) { + splitEntry[field] = get(item, field); + } else { + splitEntry[field] = item[field]; + } + } + + newItem = splitEntry; + } + + returnData.push({ + json: newItem, + pairedItem: { + item: i, + }, + }); + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/summarize.operation.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/summarize.operation.ts new file mode 100644 index 0000000000..4ff21ac137 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/itemList/summarize.operation.ts @@ -0,0 +1,617 @@ +import type { + GenericValue, + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { updateDisplayOptions } from '@utils/utilities'; + +import get from 'lodash/get'; +import { disableDotNotationBoolean } from '../common.descriptions'; + +type AggregationType = + | 'append' + | 'average' + | 'concatenate' + | 'count' + | 'countUnique' + | 'max' + | 'min' + | 'sum'; + +type Aggregation = { + aggregation: AggregationType; + field: string; + includeEmpty?: boolean; + separateBy?: string; + customSeparator?: string; +}; + +type Aggregations = Aggregation[]; + +// eslint-disable-next-line no-restricted-syntax +const AggregationDisplayNames = { + append: 'appended_', + average: 'average_', + concatenate: 'concatenated_', + count: 'count_', + countUnique: 'unique_count_', + max: 'max_', + min: 'min_', + sum: 'sum_', +}; + +const NUMERICAL_AGGREGATIONS = ['average', 'sum']; + +type SummarizeOptions = { + disableDotNotation?: boolean; + outputFormat?: 'separateItems' | 'singleItem'; + skipEmptySplitFields?: boolean; +}; + +type ValueGetterFn = ( + item: IDataObject, + field: string, +) => IDataObject | IDataObject[] | GenericValue | GenericValue[]; + +export const properties: INodeProperties[] = [ + { + displayName: 'Fields to Summarize', + name: 'fieldsToSummarize', + type: 'fixedCollection', + placeholder: 'Add Field', + default: { values: [{ aggregation: 'count', field: '' }] }, + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: '', + name: 'values', + values: [ + { + displayName: 'Aggregation', + name: 'aggregation', + type: 'options', + options: [ + { + name: 'Append', + value: 'append', + }, + { + name: 'Average', + value: 'average', + }, + { + name: 'Concatenate', + value: 'concatenate', + }, + { + name: 'Count', + value: 'count', + }, + { + name: 'Count Unique', + value: 'countUnique', + }, + { + name: 'Max', + value: 'max', + }, + { + name: 'Min', + value: 'min', + }, + { + name: 'Sum', + value: 'sum', + }, + ], + default: 'count', + description: 'How to combine the values of the field you want to summarize', + }, + //field repeated to have different descriptions for different aggregations -------------------------------- + { + displayName: 'Field', + name: 'field', + type: 'string', + default: '', + description: 'The name of an input field that you want to summarize', + placeholder: 'e.g. cost', + hint: ' Enter the field name as text', + displayOptions: { + hide: { + aggregation: [...NUMERICAL_AGGREGATIONS, 'countUnique', 'count', 'max', 'min'], + }, + }, + requiresDataPath: 'single', + }, + { + displayName: 'Field', + name: 'field', + type: 'string', + default: '', + description: + 'The name of an input field that you want to summarize. The field should contain numerical values; null, undefined, empty strings would be ignored.', + placeholder: 'e.g. cost', + hint: ' Enter the field name as text', + displayOptions: { + show: { + aggregation: NUMERICAL_AGGREGATIONS, + }, + }, + requiresDataPath: 'single', + }, + { + displayName: 'Field', + name: 'field', + type: 'string', + default: '', + description: + 'The name of an input field that you want to summarize; null, undefined, empty strings would be ignored', + placeholder: 'e.g. cost', + hint: ' Enter the field name as text', + displayOptions: { + show: { + aggregation: ['countUnique', 'count', 'max', 'min'], + }, + }, + requiresDataPath: 'single', + }, + // ---------------------------------------------------------------------------------------------------------- + { + displayName: 'Include Empty Values', + name: 'includeEmpty', + type: 'boolean', + default: false, + displayOptions: { + show: { + aggregation: ['append', 'concatenate'], + }, + }, + }, + { + displayName: 'Separator', + name: 'separateBy', + type: 'options', + default: ',', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Comma', + value: ',', + }, + { + name: 'Comma and Space', + value: ', ', + }, + { + name: 'New Line', + value: '\n', + }, + { + name: 'None', + value: '', + }, + { + name: 'Space', + value: ' ', + }, + { + name: 'Other', + value: 'other', + }, + ], + hint: 'What to insert between values', + displayOptions: { + show: { + aggregation: ['concatenate'], + }, + }, + }, + { + displayName: 'Custom Separator', + name: 'customSeparator', + type: 'string', + default: '', + displayOptions: { + show: { + aggregation: ['concatenate'], + separateBy: ['other'], + }, + }, + }, + ], + }, + ], + }, + // fieldsToSplitBy repeated to have different displayName for singleItem and separateItems ----------------------------- + { + displayName: 'Fields to Split By', + name: 'fieldsToSplitBy', + type: 'string', + placeholder: 'e.g. country, city', + default: '', + description: 'The name of the input fields that you want to split the summary by', + hint: 'Enter the name of the fields as text (separated by commas)', + displayOptions: { + hide: { + '/options.outputFormat': ['singleItem'], + }, + }, + requiresDataPath: 'multiple', + }, + { + displayName: 'Fields to Group By', + name: 'fieldsToSplitBy', + type: 'string', + placeholder: 'e.g. country, city', + default: '', + description: 'The name of the input fields that you want to split the summary by', + hint: 'Enter the name of the fields as text (separated by commas)', + displayOptions: { + show: { + '/options.outputFormat': ['singleItem'], + }, + }, + requiresDataPath: 'multiple', + }, + // ---------------------------------------------------------------------------------------------------------- + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + disableDotNotationBoolean, + { + displayName: 'Output Format', + name: 'outputFormat', + type: 'options', + default: 'separateItems', + options: [ + { + name: 'Each Split in a Separate Item', + value: 'separateItems', + }, + { + name: 'All Splits in a Single Item', + value: 'singleItem', + }, + ], + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + displayName: 'Ignore items without valid fields to group by', + name: 'skipEmptySplitFields', + type: 'boolean', + default: false, + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['itemList'], + operation: ['summarize'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +function isEmpty(value: T) { + return value === undefined || value === null || value === ''; +} + +function parseReturnData(returnData: IDataObject) { + const regexBrackets = /[\]\["]/g; + const regexSpaces = /[ .]/g; + for (const key of Object.keys(returnData)) { + if (key.match(regexBrackets)) { + const newKey = key.replace(regexBrackets, ''); + returnData[newKey] = returnData[key]; + delete returnData[key]; + } + } + for (const key of Object.keys(returnData)) { + if (key.match(regexSpaces)) { + const newKey = key.replace(regexSpaces, '_'); + returnData[newKey] = returnData[key]; + delete returnData[key]; + } + } +} + +function parseFieldName(fieldName: string[]) { + const regexBrackets = /[\]\["]/g; + const regexSpaces = /[ .]/g; + fieldName = fieldName.map((field) => { + field = field.replace(regexBrackets, ''); + field = field.replace(regexSpaces, '_'); + return field; + }); + return fieldName; +} + +const fieldValueGetter = (disableDotNotation?: boolean) => { + if (disableDotNotation) { + return (item: IDataObject, field: string) => item[field]; + } else { + return (item: IDataObject, field: string) => get(item, field); + } +}; + +function checkIfFieldExists( + this: IExecuteFunctions, + items: IDataObject[], + aggregations: Aggregations, + getValue: ValueGetterFn, +) { + for (const aggregation of aggregations) { + if (aggregation.field === '') { + continue; + } + const exist = items.some((item) => getValue(item, aggregation.field) !== undefined); + if (!exist) { + throw new NodeOperationError( + this.getNode(), + `The field '${aggregation.field}' does not exist in any items`, + ); + } + } +} + +function aggregate(items: IDataObject[], entry: Aggregation, getValue: ValueGetterFn) { + const { aggregation, field } = entry; + let data = [...items]; + + if (NUMERICAL_AGGREGATIONS.includes(aggregation)) { + data = data.filter( + (item) => typeof getValue(item, field) === 'number' && !isEmpty(getValue(item, field)), + ); + } + + switch (aggregation) { + //combine operations + case 'append': + if (!entry.includeEmpty) { + data = data.filter((item) => !isEmpty(getValue(item, field))); + } + return data.map((item) => getValue(item, field)); + case 'concatenate': + const separateBy = entry.separateBy === 'other' ? entry.customSeparator : entry.separateBy; + if (!entry.includeEmpty) { + data = data.filter((item) => !isEmpty(getValue(item, field))); + } + return data + .map((item) => { + let value = getValue(item, field); + if (typeof value === 'object') { + value = JSON.stringify(value); + } + if (typeof value === 'undefined') { + value = 'undefined'; + } + + return value; + }) + .join(separateBy); + + //numerical operations + case 'average': + return ( + data.reduce((acc, item) => { + return acc + (getValue(item, field) as number); + }, 0) / data.length + ); + case 'sum': + return data.reduce((acc, item) => { + return acc + (getValue(item, field) as number); + }, 0); + //comparison operations + case 'min': + let min; + for (const item of data) { + const value = getValue(item, field); + if (value !== undefined && value !== null && value !== '') { + if (min === undefined || value < min) { + min = value; + } + } + } + return min !== undefined ? min : null; + case 'max': + let max; + for (const item of data) { + const value = getValue(item, field); + if (value !== undefined && value !== null && value !== '') { + if (max === undefined || value > max) { + max = value; + } + } + } + return max !== undefined ? max : null; + + //count operations + case 'countUnique': + return new Set(data.map((item) => getValue(item, field)).filter((item) => !isEmpty(item))) + .size; + default: + //count by default + return data.filter((item) => !isEmpty(getValue(item, field))).length; + } +} + +function aggregateData( + data: IDataObject[], + fieldsToSummarize: Aggregations, + options: SummarizeOptions, + getValue: ValueGetterFn, +) { + const returnData = fieldsToSummarize.reduce((acc, aggregation) => { + acc[`${AggregationDisplayNames[aggregation.aggregation]}${aggregation.field}`] = aggregate( + data, + aggregation, + getValue, + ); + return acc; + }, {} as IDataObject); + parseReturnData(returnData); + if (options.outputFormat === 'singleItem') { + parseReturnData(returnData); + return returnData; + } else { + return { ...returnData, pairedItems: data.map((item) => item._itemIndex as number) }; + } +} + +function splitData( + splitKeys: string[], + data: IDataObject[], + fieldsToSummarize: Aggregations, + options: SummarizeOptions, + getValue: ValueGetterFn, +) { + if (!splitKeys || splitKeys.length === 0) { + return aggregateData(data, fieldsToSummarize, options, getValue); + } + + const [firstSplitKey, ...restSplitKeys] = splitKeys; + + const groupedData = data.reduce((acc, item) => { + let keyValuee = getValue(item, firstSplitKey) as string; + + if (typeof keyValuee === 'object') { + keyValuee = JSON.stringify(keyValuee); + } + + if (options.skipEmptySplitFields && typeof keyValuee !== 'number' && !keyValuee) { + return acc; + } + + if (acc[keyValuee] === undefined) { + acc[keyValuee] = [item]; + } else { + (acc[keyValuee] as IDataObject[]).push(item); + } + return acc; + }, {} as IDataObject); + + return Object.keys(groupedData).reduce((acc, key) => { + const value = groupedData[key] as IDataObject[]; + acc[key] = splitData(restSplitKeys, value, fieldsToSummarize, options, getValue); + return acc; + }, {} as IDataObject); +} + +function aggregationToArray( + aggregationResult: IDataObject, + fieldsToSplitBy: string[], + previousStage: IDataObject = {}, +) { + const returnData: IDataObject[] = []; + fieldsToSplitBy = parseFieldName(fieldsToSplitBy); + const splitFieldName = fieldsToSplitBy[0]; + const isNext = fieldsToSplitBy[1]; + + if (isNext === undefined) { + for (const fieldName of Object.keys(aggregationResult)) { + returnData.push({ + ...previousStage, + [splitFieldName]: fieldName, + ...(aggregationResult[fieldName] as IDataObject), + }); + } + return returnData; + } else { + for (const key of Object.keys(aggregationResult)) { + returnData.push( + ...aggregationToArray(aggregationResult[key] as IDataObject, fieldsToSplitBy.slice(1), { + ...previousStage, + [splitFieldName]: key, + }), + ); + } + return returnData; + } +} + +export async function execute( + this: IExecuteFunctions, + items: INodeExecutionData[], +): Promise { + const newItems = items.map(({ json }, i) => ({ ...json, _itemIndex: i })); + + const options = this.getNodeParameter('options', 0, {}) as SummarizeOptions; + + const fieldsToSplitBy = (this.getNodeParameter('fieldsToSplitBy', 0, '') as string) + .split(',') + .map((field) => field.trim()) + .filter((field) => field); + + const fieldsToSummarize = this.getNodeParameter( + 'fieldsToSummarize.values', + 0, + [], + ) as Aggregations; + + if (fieldsToSummarize.filter((aggregation) => aggregation.field !== '').length === 0) { + throw new NodeOperationError( + this.getNode(), + "You need to add at least one aggregation to 'Fields to Summarize' with non empty 'Field'", + ); + } + + const getValue = fieldValueGetter(options.disableDotNotation); + + const nodeVersion = this.getNode().typeVersion; + + if (nodeVersion < 2.1) { + checkIfFieldExists.call(this, newItems, fieldsToSummarize, getValue); + } + + const aggregationResult = splitData( + fieldsToSplitBy, + newItems, + fieldsToSummarize, + options, + getValue, + ); + + if (options.outputFormat === 'singleItem') { + const executionData: INodeExecutionData = { + json: aggregationResult, + pairedItem: newItems.map((_v, index) => ({ + item: index, + })), + }; + return [executionData]; + } else { + if (!fieldsToSplitBy.length) { + const { pairedItems, ...json } = aggregationResult; + const executionData: INodeExecutionData = { + json, + pairedItem: ((pairedItems as number[]) || []).map((index: number) => ({ + item: index, + })), + }; + return [executionData]; + } + const returnData = aggregationToArray(aggregationResult, fieldsToSplitBy); + const executionData = returnData.map((item) => { + const { pairedItems, ...json } = item; + return { + json, + pairedItem: ((pairedItems as number[]) || []).map((index: number) => ({ + item: index, + })), + }; + }); + return executionData; + } +} diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/node.type.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/node.type.ts new file mode 100644 index 0000000000..86750f30ea --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/node.type.ts @@ -0,0 +1,13 @@ +import type { AllEntities } from 'n8n-workflow'; + +type NodeMap = { + itemList: + | 'concatenateItems' + | 'limit' + | 'removeDuplicates' + | 'sort' + | 'splitOutItems' + | 'summarize'; +}; + +export type ItemListsType = AllEntities; diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/router.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/router.ts new file mode 100644 index 0000000000..a5f24e6b86 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/router.ts @@ -0,0 +1,35 @@ +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import type { ItemListsType } from './node.type'; + +import * as itemList from './itemList'; + +export async function router(this: IExecuteFunctions): Promise { + let returnData: INodeExecutionData[] = []; + + const items = this.getInputData(); + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + + const itemListsNodeData = { + resource, + operation, + } as ItemListsType; + + try { + switch (itemListsNodeData.resource) { + case 'itemList': + returnData = await itemList[itemListsNodeData.operation].execute.call(this, items); + break; + default: + throw new NodeOperationError( + this.getNode(), + `The operation "${operation}" is not supported!`, + ); + } + } catch (error) { + throw error; + } + + return this.prepareOutputData(returnData); +} diff --git a/packages/nodes-base/nodes/ItemLists/V3/actions/versionDescription.ts b/packages/nodes-base/nodes/ItemLists/V3/actions/versionDescription.ts new file mode 100644 index 0000000000..728f646769 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/actions/versionDescription.ts @@ -0,0 +1,35 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { INodeTypeDescription } from 'n8n-workflow'; + +import * as itemList from './itemList'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Item Lists', + name: 'itemLists', + icon: 'file:itemLists.svg', + group: ['input'], + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Helper for working with lists of items and transforming arrays', + version: 3, + defaults: { + name: 'Item Lists', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'hidden', + options: [ + { + name: 'Item List', + value: 'itemList', + }, + ], + default: 'itemList', + }, + ...itemList.description, + ], +}; diff --git a/packages/nodes-base/nodes/ItemLists/V3/helpers/utils.ts b/packages/nodes-base/nodes/ItemLists/V3/helpers/utils.ts new file mode 100644 index 0000000000..60600eb8d6 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/V3/helpers/utils.ts @@ -0,0 +1,59 @@ +import type { IDataObject, INode, INodeExecutionData } from 'n8n-workflow'; + +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import isObject from 'lodash/isObject'; +import merge from 'lodash/merge'; +import reduce from 'lodash/reduce'; + +export const compareItems = ( + obj: INodeExecutionData, + obj2: INodeExecutionData, + keys: string[], + disableDotNotation: boolean, + _node: INode, +) => { + let result = true; + for (const key of keys) { + if (!disableDotNotation) { + if (!isEqual(get(obj.json, key), get(obj2.json, key))) { + result = false; + break; + } + } else { + if (!isEqual(obj.json[key], obj2.json[key])) { + result = false; + break; + } + } + } + return result; +}; + +export const flattenKeys = (obj: IDataObject, path: string[] = []): IDataObject => { + return !isObject(obj) + ? { [path.join('.')]: obj } + : reduce(obj, (cum, next, key) => merge(cum, flattenKeys(next as IDataObject, [...path, key])), {}); //prettier-ignore +}; + +export const shuffleArray = (array: any[]) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } +}; + +export const prepareFieldsArray = (fields: string | string[], fieldName = 'Fields') => { + if (typeof fields === 'string') { + return fields + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry !== ''); + } + if (Array.isArray(fields)) { + return fields; + } + throw new Error( + `The \'${fieldName}\' parameter must be a string of fields separated by commas or an array of strings.`, + ); +}; diff --git a/packages/nodes-base/nodes/ItemLists/test/node/workflow.update_3.json b/packages/nodes-base/nodes/ItemLists/test/node/workflow.update_3.json new file mode 100644 index 0000000000..3bbd2517f8 --- /dev/null +++ b/packages/nodes-base/nodes/ItemLists/test/node/workflow.update_3.json @@ -0,0 +1,606 @@ +{ + "name": "itemList refactor", + "nodes": [ + { + "parameters": {}, + "id": "e7ecaa9c-e35d-4095-a85b-85b83f807c2a", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [420, 400] + }, + { + "parameters": { + "operation": "getAllPeople", + "returnAll": true + }, + "id": "7d925077-afaa-46d5-ba2f-0c19d93afecc", + "name": "Customer Datastore (n8n training)", + "type": "n8n-nodes-base.n8nTrainingCustomerDatastore", + "typeVersion": 1, + "position": [640, 400] + }, + { + "parameters": { + "operation": "concatenateItems", + "fieldsToAggregate": { + "fieldToAggregate": [ + { + "fieldToAggregate": "email" + }, + { + "fieldToAggregate": "notes" + } + ] + }, + "options": {} + }, + "id": "f80182d8-54f6-4a26-82b3-27d67e4ca39b", + "name": "Item Lists1", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3, + "position": [1120, -120] + }, + { + "parameters": { + "operation": "concatenateItems", + "aggregate": "aggregateAllItemData", + "destinationFieldName": "data2" + }, + "id": "23eefe2c-6394-4b53-a791-852dcd671ea0", + "name": "Item Lists", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3, + "position": [1120, 40] + }, + { + "parameters": { + "operation": "limit", + "maxItems": 2 + }, + "id": "676dc72f-9766-43c0-aab7-2f8a93ed46e5", + "name": "Item Lists2", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3, + "position": [1120, 200] + }, + { + "parameters": { + "operation": "limit", + "keep": "lastItems" + }, + "id": "9615299b-5acc-4459-a7d3-2cb23c3224ab", + "name": "Item Lists3", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3, + "position": [1120, 360] + }, + { + "parameters": { + "operation": "sort", + "sortFieldsUi": { + "sortField": [ + { + "fieldName": "country" + } + ] + }, + "options": {} + }, + "id": "fd2f190a-b161-48ff-93e1-0f30efce051a", + "name": "Item Lists4", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3, + "position": [1120, 540] + }, + { + "parameters": { + "operation": "limit", + "maxItems": 4 + }, + "id": "14759521-76ad-46d8-8add-1d7361788fe1", + "name": "Item Lists5", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3, + "position": [1360, 540] + }, + { + "parameters": { + "operation": "removeDuplicates", + "compare": "selectedFields", + "fieldsToCompare": "country", + "options": {} + }, + "id": "c0eba4d0-f975-4987-8bb2-853e8a98665e", + "name": "Item Lists6", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3, + "position": [1560, 540] + }, + { + "parameters": { + "operation": "concatenateItems", + "aggregate": "aggregateAllItemData", + "include": "specifiedFields", + "fieldsToInclude": "country, notes, name, created" + }, + "id": "b5962f40-a891-4b02-8fec-c2d76f85375f", + "name": "Item Lists7", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3, + "position": [1120, 740] + }, + { + "parameters": { + "fieldToSplitOut": "data", + "include": "allOtherFields", + "options": { + "destinationFieldName": "newData" + } + }, + "id": "15c6fc86-7e38-4d76-836a-8f8426ca05e3", + "name": "Item Lists8", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3, + "position": [1380, 740] + }, + { + "parameters": { + "operation": "summarize", + "fieldsToSummarize": { + "values": [ + { + "aggregation": "append", + "field": "newData.notes" + }, + { + "aggregation": "max", + "field": "newData.created" + }, + { + "aggregation": "min", + "field": "newData.created" + } + ] + }, + "options": {} + }, + "id": "8c51ae57-487e-472a-b268-6e8ad347edbb", + "name": "Item Lists9", + "type": "n8n-nodes-base.itemLists", + "typeVersion": 3, + "position": [1560, 940] + }, + { + "parameters": {}, + "id": "e859b082-284c-4bb3-96b6-39a86152d8f6", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1760, -120] + }, + { + "parameters": {}, + "id": "9da56c21-739d-4f2f-adf7-953cf0550d97", + "name": "No Operation, do nothing1", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1760, 40] + }, + { + "parameters": {}, + "id": "f85ac031-24de-4701-bb3d-76c684924002", + "name": "No Operation, do nothing2", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1760, 200] + }, + { + "parameters": {}, + "id": "e7faff14-55d6-4d78-983d-027fd56bcd5a", + "name": "No Operation, do nothing3", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1760, 360] + }, + { + "parameters": {}, + "id": "dc8b7bbc-b1a8-4ba8-b214-d73341cb9f85", + "name": "No Operation, do nothing4", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1760, 740] + }, + { + "parameters": {}, + "id": "09bdeca1-6a9e-4668-b9d1-14ed6139e047", + "name": "No Operation, do nothing5", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1760, 540] + }, + { + "parameters": {}, + "id": "7d508889-d94e-4818-abc8-b669a0fe64ea", + "name": "No Operation, do nothing6", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1760, 940] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "email": [ + "gatsby@west-egg.com", + "jab@macondo.co", + "info@in-and-out-of-weeks.org", + "captain@heartofgold.com", + "edmund@narnia.gov" + ], + "notes": [ + "Keeps asking about a green light??", + "Lots of people named after him. Very confusing", + "Keeps rolling his terrible eyes", + "Felt like I was talking to more than one person", + "Passionate sailor" + ] + } + } + ], + "No Operation, do nothing1": [ + { + "json": { + "data2": [ + { + "id": "23423532", + "name": "Jay Gatsby", + "email": "gatsby@west-egg.com", + "notes": "Keeps asking about a green light??", + "country": "US", + "created": "1925-04-10" + }, + { + "id": "23423533", + "name": "José Arcadio Buendía", + "email": "jab@macondo.co", + "notes": "Lots of people named after him. Very confusing", + "country": "CO", + "created": "1967-05-05" + }, + { + "id": "23423534", + "name": "Max Sendak", + "email": "info@in-and-out-of-weeks.org", + "notes": "Keeps rolling his terrible eyes", + "country": "US", + "created": "1963-04-09" + }, + { + "id": "23423535", + "name": "Zaphod Beeblebrox", + "email": "captain@heartofgold.com", + "notes": "Felt like I was talking to more than one person", + "country": null, + "created": "1979-10-12" + }, + { + "id": "23423536", + "name": "Edmund Pevensie", + "email": "edmund@narnia.gov", + "notes": "Passionate sailor", + "country": "UK", + "created": "1950-10-16" + } + ] + } + } + ], + "No Operation, do nothing2": [ + { + "json": { + "id": "23423532", + "name": "Jay Gatsby", + "email": "gatsby@west-egg.com", + "notes": "Keeps asking about a green light??", + "country": "US", + "created": "1925-04-10" + } + }, + { + "json": { + "id": "23423533", + "name": "José Arcadio Buendía", + "email": "jab@macondo.co", + "notes": "Lots of people named after him. Very confusing", + "country": "CO", + "created": "1967-05-05" + } + } + ], + "No Operation, do nothing3": [ + { + "json": { + "id": "23423536", + "name": "Edmund Pevensie", + "email": "edmund@narnia.gov", + "notes": "Passionate sailor", + "country": "UK", + "created": "1950-10-16" + } + } + ], + "No Operation, do nothing5": [ + { + "json": { + "id": "23423533", + "name": "José Arcadio Buendía", + "email": "jab@macondo.co", + "notes": "Lots of people named after him. Very confusing", + "country": "CO", + "created": "1967-05-05" + } + }, + { + "json": { + "id": "23423536", + "name": "Edmund Pevensie", + "email": "edmund@narnia.gov", + "notes": "Passionate sailor", + "country": "UK", + "created": "1950-10-16" + } + }, + { + "json": { + "id": "23423532", + "name": "Jay Gatsby", + "email": "gatsby@west-egg.com", + "notes": "Keeps asking about a green light??", + "country": "US", + "created": "1925-04-10" + } + } + ], + "No Operation, do nothing4": [ + { + "json": { + "newData": { + "name": "Jay Gatsby", + "notes": "Keeps asking about a green light??", + "country": "US", + "created": "1925-04-10" + } + } + }, + { + "json": { + "newData": { + "name": "José Arcadio Buendía", + "notes": "Lots of people named after him. Very confusing", + "country": "CO", + "created": "1967-05-05" + } + } + }, + { + "json": { + "newData": { + "name": "Max Sendak", + "notes": "Keeps rolling his terrible eyes", + "country": "US", + "created": "1963-04-09" + } + } + }, + { + "json": { + "newData": { + "name": "Zaphod Beeblebrox", + "notes": "Felt like I was talking to more than one person", + "country": null, + "created": "1979-10-12" + } + } + }, + { + "json": { + "newData": { + "name": "Edmund Pevensie", + "notes": "Passionate sailor", + "country": "UK", + "created": "1950-10-16" + } + } + } + ], + "No Operation, do nothing6": [ + { + "json": { + "appended_newData_notes": [ + "Keeps asking about a green light??", + "Lots of people named after him. Very confusing", + "Keeps rolling his terrible eyes", + "Felt like I was talking to more than one person", + "Passionate sailor" + ], + "max_newData_created": "1979-10-12", + "min_newData_created": "1925-04-10" + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Customer Datastore (n8n training)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Customer Datastore (n8n training)": { + "main": [ + [ + { + "node": "Item Lists1", + "type": "main", + "index": 0 + }, + { + "node": "Item Lists", + "type": "main", + "index": 0 + }, + { + "node": "Item Lists2", + "type": "main", + "index": 0 + }, + { + "node": "Item Lists3", + "type": "main", + "index": 0 + }, + { + "node": "Item Lists4", + "type": "main", + "index": 0 + }, + { + "node": "Item Lists7", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists1": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists4": { + "main": [ + [ + { + "node": "Item Lists5", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists5": { + "main": [ + [ + { + "node": "Item Lists6", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists7": { + "main": [ + [ + { + "node": "Item Lists8", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists8": { + "main": [ + [ + { + "node": "Item Lists9", + "type": "main", + "index": 0 + }, + { + "node": "No Operation, do nothing4", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists": { + "main": [ + [ + { + "node": "No Operation, do nothing1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists2": { + "main": [ + [ + { + "node": "No Operation, do nothing2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists3": { + "main": [ + [ + { + "node": "No Operation, do nothing3", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists6": { + "main": [ + [ + { + "node": "No Operation, do nothing5", + "type": "main", + "index": 0 + } + ] + ] + }, + "Item Lists9": { + "main": [ + [ + { + "node": "No Operation, do nothing6", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "ce3e0124-aa56-497c-a2e1-24158837c7f9", + "id": "m7QDuxo599dkZ0Ex", + "meta": { + "instanceId": "e34acda144ba98351e38adb4db781751ca8cd64a8248aef8b65608fc9a49008c" + }, + "tags": [] +} From 54444fa388da12d75553e66e53a8cf6f8a99b6fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Mart=C3=ADnez?= <91728333+adrian-martinez-vdshop@users.noreply.github.com> Date: Mon, 3 Jul 2023 17:05:28 +0200 Subject: [PATCH 35/46] fix(Strapi Node): Fix issue with pagination (#4991) --- packages/nodes-base/nodes/Strapi/GenericFunctions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Strapi/GenericFunctions.ts b/packages/nodes-base/nodes/Strapi/GenericFunctions.ts index 9c3fd5ed6a..d5b0aa1834 100644 --- a/packages/nodes-base/nodes/Strapi/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Strapi/GenericFunctions.ts @@ -92,7 +92,7 @@ export async function strapiApiRequestAllItems( let responseData; if (apiVersion === 'v4') { query['pagination[pageSize]'] = 20; - query['pagination[page]'] = 0; + query['pagination[page]'] = 1; do { ({ data: responseData } = await strapiApiRequest.call( this, @@ -103,7 +103,7 @@ export async function strapiApiRequestAllItems( undefined, headers, )); - query['pagination[page]'] += query['pagination[pageSize]']; + query['pagination[page]']++; returnData.push.apply(returnData, responseData as IDataObject[]); } while (responseData.length !== 0); } else { From 3ca66be38082e7a3866d53d07328be58e913067f Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 4 Jul 2023 09:42:58 +0200 Subject: [PATCH 36/46] fix(editor): Show retry information in execution list only when it exists (#6587) * fix(editor): Show retry information in execution list only when it exists * build: Fix checking for test files --- .github/scripts/check-tests.mjs | 20 ++- .../src/components/ExecutionsList.vue | 4 +- .../__tests__/ExecutionsList.test.ts | 149 ++++++++++-------- packages/workflow/src/Interfaces.ts | 4 +- 4 files changed, 105 insertions(+), 72 deletions(-) diff --git a/.github/scripts/check-tests.mjs b/.github/scripts/check-tests.mjs index b1b859c869..1694fad35d 100644 --- a/.github/scripts/check-tests.mjs +++ b/.github/scripts/check-tests.mjs @@ -17,6 +17,18 @@ const filterAsync = async (asyncPredicate, arr) => { return filterResults.filter(({shouldKeep}) => shouldKeep).map(({item}) => item); } +const isAbstractClass = (node) => { + if (ts.isClassDeclaration(node)) { + return node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword) || false; + } + return false; +} + +const isAbstractMethod = (node) => { + return ts.isMethodDeclaration(node) && Boolean(node.modifiers?.find((modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword)); +} + + // Function to check if a file has a function declaration, function expression, object method or class const hasFunctionOrClass = async filePath => { const fileContent = await readFileAsync(filePath, 'utf-8'); @@ -24,7 +36,13 @@ const hasFunctionOrClass = async filePath => { let hasFunctionOrClass = false; const visit = node => { - if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isMethodDeclaration(node) || ts.isClassDeclaration(node)) { + if ( + ts.isFunctionDeclaration(node) + || ts.isFunctionExpression(node) + || ts.isArrowFunction(node) + || (ts.isMethodDeclaration(node) && !isAbstractMethod(node)) + || (ts.isClassDeclaration(node) && !isAbstractClass(node)) + ) { hasFunctionOrClass = true; } node.forEachChild(visit); diff --git a/packages/editor-ui/src/components/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsList.vue index e9c8e99292..3045185a4c 100644 --- a/packages/editor-ui/src/components/ExecutionsList.vue +++ b/packages/editor-ui/src/components/ExecutionsList.vue @@ -126,13 +126,13 @@ #{{ execution.id }} - +
({{ $locale.baseText('executionsList.retryOf') }} #{{ execution.retryOf }})
- +
({{ $locale.baseText('executionsList.successRetry') }} #{{ diff --git a/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts b/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts index dd238c617f..acafa33217 100644 --- a/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts +++ b/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts @@ -1,5 +1,5 @@ import { vi, describe, it, expect } from 'vitest'; -import Vue from 'vue'; +import { merge } from 'lodash-es'; import { PiniaVuePlugin } from 'pinia'; import { createTestingPinia } from '@pinia/testing'; import { render } from '@testing-library/vue'; @@ -7,15 +7,29 @@ import userEvent from '@testing-library/user-event'; import { faker } from '@faker-js/faker'; import { STORES } from '@/constants'; import ExecutionsList from '@/components/ExecutionsList.vue'; -import { externalHooks } from '@/mixins/externalHooks'; -import { genericHelpers } from '@/mixins/genericHelpers'; -import { executionHelpers } from '@/mixins/executionsHelpers'; import { i18nInstance } from '@/plugins/i18n'; import type { IWorkflowDb } from '@/Interface'; import type { IExecutionsSummary } from 'n8n-workflow'; -import { retry, waitAllPromises } from '@/__tests__/utils'; +import { retry, SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils'; import { useWorkflowsStore } from '@/stores'; +let pinia: ReturnType; + +const generateUndefinedNullOrString = () => { + switch (Math.floor(Math.random() * 4)) { + case 0: + return undefined; + case 1: + return null; + case 2: + return faker.datatype.uuid(); + case 3: + return ''; + default: + return undefined; + } +}; + const workflowDataFactory = (): IWorkflowDb => ({ createdAt: faker.date.past().toDateString(), updatedAt: faker.date.past().toDateString(), @@ -38,83 +52,71 @@ const executionDataFactory = (): IExecutionsSummary => ({ workflowName: faker.datatype.string(), status: faker.helpers.arrayElement(['failed', 'success']), nodeExecutionStatus: {}, + retryOf: generateUndefinedNullOrString(), + retrySuccessId: generateUndefinedNullOrString(), }); -const workflowsData = Array.from({ length: 10 }, workflowDataFactory); +const generateWorkflowsData = () => Array.from({ length: 10 }, workflowDataFactory); -const executionsData = Array.from({ length: 2 }, () => ({ - count: 20, - results: Array.from({ length: 10 }, executionDataFactory), - estimated: false, -})); - -const renderOptions = { - pinia: createTestingPinia({ - initialState: { - [STORES.SETTINGS]: { - settings: { - templates: { - enabled: true, - host: 'https://api.n8n.io/api/', - }, - license: { - environment: 'development', - }, - deployment: { - type: 'default', - }, - enterprise: { - advancedExecutionFilters: true, - }, - }, - }, - }, - }), - propsData: { - autoRefreshEnabled: false, - }, - i18n: i18nInstance, - stubs: ['font-awesome-icon'], - mixins: [externalHooks, genericHelpers, executionHelpers], -}; - -function TelemetryPlugin(vue: typeof Vue): void { - Object.defineProperty(vue, '$telemetry', { - get() { - return { - track: () => {}, - }; - }, - }); - Object.defineProperty(vue.prototype, '$telemetry', { - get() { - return { - track: () => {}, - }; - }, - }); -} +const generateExecutionsData = () => + Array.from({ length: 2 }, () => ({ + count: 20, + results: Array.from({ length: 10 }, executionDataFactory), + estimated: false, + })); const renderComponent = async () => { - const renderResult = render(ExecutionsList, renderOptions); + const renderResult = render( + ExecutionsList, + { + pinia, + propsData: { + autoRefreshEnabled: false, + }, + i18n: i18nInstance, + stubs: ['font-awesome-icon'], + }, + (vue) => { + vue.use(PiniaVuePlugin); + vue.prototype.$telemetry = { + track: () => {}, + }; + }, + ); await waitAllPromises(); return renderResult; }; -Vue.use(TelemetryPlugin); -Vue.use(PiniaVuePlugin); - describe('ExecutionsList.vue', () => { - const workflowsStore: ReturnType = useWorkflowsStore(); + let workflowsStore: ReturnType; + let workflowsData: IWorkflowDb[]; + let executionsData: Array<{ + count: number; + results: IExecutionsSummary[]; + estimated: boolean; + }>; + beforeEach(() => { + workflowsData = generateWorkflowsData(); + executionsData = generateExecutionsData(); + + pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, { + enterprise: { + advancedExecutionFilters: true, + }, + }), + }, + }, + }); + workflowsStore = useWorkflowsStore(); + vi.spyOn(workflowsStore, 'fetchAllWorkflows').mockResolvedValue(workflowsData); vi.spyOn(workflowsStore, 'getCurrentExecutions').mockResolvedValue([]); }); - afterEach(() => { - vi.clearAllMocks(); - }); - it('should render empty list', async () => { vi.spyOn(workflowsStore, 'getPastExecutions').mockResolvedValueOnce({ count: 0, @@ -182,4 +184,17 @@ describe('ExecutionsList.vue', () => { expect(getByTestId('select-visible-executions-checkbox')).toBeInTheDocument(); expect(queryByTestId('select-all-executions-checkbox')).not.toBeInTheDocument(); }); + + it('should show "retry" data when appropriate', async () => { + vi.spyOn(workflowsStore, 'getPastExecutions').mockResolvedValue(executionsData[0]); + const retryOf = executionsData[0].results.filter((execution) => execution.retryOf); + const retrySuccessId = executionsData[0].results.filter( + (execution) => !execution.retryOf && execution.retrySuccessId, + ); + + const { queryAllByText } = await renderComponent(); + + expect(queryAllByText(/Retry of/).length).toBe(retryOf.length); + expect(queryAllByText(/Success retry/).length).toBe(retrySuccessId.length); + }); }); diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index df3216de2e..e9d4ed1f61 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1958,8 +1958,8 @@ export interface IExecutionsSummary { id: string; finished?: boolean; mode: WorkflowExecuteMode; - retryOf?: string; - retrySuccessId?: string; + retryOf?: string | null; + retrySuccessId?: string | null; waitTill?: Date; startedAt: Date; stoppedAt?: Date; From 29882a6f39dddcd1c8c107c20a548ce8dc665cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 4 Jul 2023 16:02:40 +0200 Subject: [PATCH 37/46] fix(core): Fix migrations for MySQL/MariaDB (#6591) --- .../1690000000030-SeparateExecutionData.ts | 4 ++-- .../1690000000031-FixExecutionDataType.ts | 17 +++++++++++++++++ .../src/databases/migrations/mysqldb/index.ts | 2 ++ 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/databases/migrations/mysqldb/1690000000031-FixExecutionDataType.ts diff --git a/packages/cli/src/databases/migrations/mysqldb/1690000000030-SeparateExecutionData.ts b/packages/cli/src/databases/migrations/mysqldb/1690000000030-SeparateExecutionData.ts index 7bf58e9c7d..cb56cf7b63 100644 --- a/packages/cli/src/databases/migrations/mysqldb/1690000000030-SeparateExecutionData.ts +++ b/packages/cli/src/databases/migrations/mysqldb/1690000000030-SeparateExecutionData.ts @@ -6,7 +6,7 @@ export class SeparateExecutionData1690000000030 implements ReversibleMigration { `CREATE TABLE ${tablePrefix}execution_data ( executionId int(11) NOT NULL primary key, workflowData json NOT NULL, - data TEXT NOT NULL, + data MEDIUMTEXT NOT NULL, CONSTRAINT \`${tablePrefix}execution_data_FK\` FOREIGN KEY (\`executionId\`) REFERENCES \`${tablePrefix}execution_entity\` (\`id\`) ON DELETE CASCADE ) ENGINE=InnoDB`, @@ -30,7 +30,7 @@ export class SeparateExecutionData1690000000030 implements ReversibleMigration { await queryRunner.query( `ALTER TABLE ${tablePrefix}execution_entity ADD workflowData json NULL, - ADD data text NULL`, + ADD data MEDIUMTEXT NULL`, ); await queryRunner.query( diff --git a/packages/cli/src/databases/migrations/mysqldb/1690000000031-FixExecutionDataType.ts b/packages/cli/src/databases/migrations/mysqldb/1690000000031-FixExecutionDataType.ts new file mode 100644 index 0000000000..7107eaec13 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1690000000031-FixExecutionDataType.ts @@ -0,0 +1,17 @@ +import type { MigrationContext, IrreversibleMigration } from '@db/types'; + +export class FixExecutionDataType1690000000031 implements IrreversibleMigration { + async up({ queryRunner, tablePrefix }: MigrationContext) { + /** + * SeparateExecutionData migration for MySQL/MariaDB accidentally changed the data-type for `data` column to `TEXT`. + * This migration changes it back. + * The previous migration has been patched to avoid converting to `TEXT`, which might fail. + * + * For any users who already ran the previous migration, this migration should fix the column type. + * For any users who run these migrations in the same batch, this migration would be no-op, as the column type is already `MEDIUMTEXT` + */ + await queryRunner.query( + 'ALTER TABLE `' + tablePrefix + 'execution_data` MODIFY COLUMN `data` MEDIUMTEXT', + ); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index ac84b759e2..00bb34a875 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -40,6 +40,7 @@ import { CreateVariables1677501636753 } from './1677501636753-CreateVariables'; import { AddUserActivatedProperty1681134145996 } from './1681134145996-AddUserActivatedProperty'; import { MigrateIntegerKeysToString1690000000001 } from './1690000000001-MigrateIntegerKeysToString'; import { SeparateExecutionData1690000000030 } from './1690000000030-SeparateExecutionData'; +import { FixExecutionDataType1690000000031 } from './1690000000031-FixExecutionDataType'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -83,4 +84,5 @@ export const mysqlMigrations: Migration[] = [ AddUserActivatedProperty1681134145996, MigrateIntegerKeysToString1690000000001, SeparateExecutionData1690000000030, + FixExecutionDataType1690000000031, ]; From 4c854f4f23c9fada6caa499681b98ca86ef125a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 4 Jul 2023 16:17:50 +0200 Subject: [PATCH 38/46] refactor: Setup node context API, and consolidate code between Webhook and Wait nodes (no-changelog) (#6464) --- packages/cli/src/WaitingWebhooks.ts | 4 +- packages/editor-ui/src/constants.ts | 6 +- packages/nodes-base/nodes/Wait/Wait.node.ts | 666 +++------------- .../nodes-base/nodes/Webhook/Webhook.node.ts | 720 +++++------------- .../nodes-base/nodes/Webhook/description.ts | 341 +++++++++ packages/nodes-base/nodes/Webhook/error.ts | 13 + packages/workflow/src/Interfaces.ts | 10 + packages/workflow/src/Workflow.ts | 13 +- 8 files changed, 679 insertions(+), 1094 deletions(-) create mode 100644 packages/nodes-base/nodes/Webhook/description.ts create mode 100644 packages/nodes-base/nodes/Webhook/error.ts diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index bd2a6d5388..50ed41c56c 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -106,13 +106,13 @@ export class WaitingWebhooks { workflow, workflow.getNode(lastNodeExecuted) as INode, additionalData, - ).filter((webhook) => { + ).find((webhook) => { return ( webhook.httpMethod === httpMethod && webhook.path === path && webhook.webhookDescription.restartWebhook === true ); - })[0]; + }); if (webhookData === undefined) { // If no data got found it means that the execution can not be started via a webhook. diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 9086e185f3..d7b5765578 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -518,7 +518,11 @@ export const N8N_CONTACT_EMAIL = 'contact@n8n.io'; export const EXPRESSION_EDITOR_PARSER_TIMEOUT = 15_000; // ms -export const KEEP_AUTH_IN_NDV_FOR_NODES = [HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_TYPE]; +export const KEEP_AUTH_IN_NDV_FOR_NODES = [ + HTTP_REQUEST_NODE_TYPE, + WEBHOOK_NODE_TYPE, + WAIT_NODE_TYPE, +]; export const MAIN_AUTH_FIELD_NAME = 'authentication'; export const NODE_RESOURCE_FIELD_NAME = 'resource'; diff --git a/packages/nodes-base/nodes/Wait/Wait.node.ts b/packages/nodes-base/nodes/Wait/Wait.node.ts index a6eff82ad7..85604f5b8c 100644 --- a/packages/nodes-base/nodes/Wait/Wait.node.ts +++ b/packages/nodes-base/nodes/Wait/Wait.node.ts @@ -1,44 +1,34 @@ import type { IExecuteFunctions, - ICredentialDataDecryptedObject, - IDataObject, INodeExecutionData, - INodeType, INodeTypeDescription, - IWebhookFunctions, - IWebhookResponseData, + INodeProperties, + IDisplayOptions, } from 'n8n-workflow'; -import { BINARY_ENCODING, WAIT_TIME_UNLIMITED, NodeOperationError } from 'n8n-workflow'; +import { WAIT_TIME_UNLIMITED } from 'n8n-workflow'; -import fs from 'fs'; -import stream from 'stream'; -import { promisify } from 'util'; -import basicAuth from 'basic-auth'; -import type { Response } from 'express'; -import formidable from 'formidable'; -import isbot from 'isbot'; -import { file as tmpFile } from 'tmp-promise'; +import { + authenticationProperty, + credentialsProperty, + defaultWebhookDescription, + httpMethodsProperty, + optionsProperty, + responseBinaryPropertyNameProperty, + responseCodeProperty, + responseDataProperty, + responseModeProperty, +} from '../Webhook/description'; +import { Webhook } from '../Webhook/Webhook.node'; -const pipeline = promisify(stream.pipeline); +const displayOnWebhook: IDisplayOptions = { + show: { + resume: ['webhook'], + }, +}; -function authorizationError(resp: Response, realm: string, responseCode: number, message?: string) { - if (message === undefined) { - message = 'Authorization problem!'; - if (responseCode === 401) { - message = 'Authorization is required!'; - } else if (responseCode === 403) { - message = 'Authorization data is wrong!'; - } - } +export class Wait extends Webhook { + authPropertyName = 'incomingAuthentication'; - resp.writeHead(responseCode, { 'WWW-Authenticate': `Basic realm="${realm}"` }); - resp.end(message); - return { - noWebhookResponse: true, - }; -} - -export class Wait implements INodeType { description: INodeTypeDescription = { displayName: 'Wait', name: 'wait', @@ -52,70 +42,16 @@ export class Wait implements INodeType { }, inputs: ['main'], outputs: ['main'], - credentials: [ - { - name: 'httpBasicAuth', - required: true, - displayOptions: { - show: { - incomingAuthentication: ['basicAuth'], - }, - }, - }, - { - name: 'httpHeaderAuth', - required: true, - displayOptions: { - show: { - incomingAuthentication: ['headerAuth'], - }, - }, - }, - ], + credentials: credentialsProperty(this.authPropertyName), webhooks: [ { - name: 'default', - httpMethod: '={{$parameter["httpMethod"]}}', - isFullPath: true, - responseCode: '={{$parameter["responseCode"]}}', - responseMode: '={{$parameter["responseMode"]}}', + ...defaultWebhookDescription, responseData: '={{$parameter["responseData"]}}', - responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}', - responseContentType: '={{$parameter["options"]["responseContentType"]}}', - responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}', - responseHeaders: '={{$parameter["options"]["responseHeaders"]}}', path: '={{$parameter["options"]["webhookSuffix"] || ""}}', restartWebhook: true, }, ], properties: [ - { - displayName: 'Webhook Authentication', - name: 'incomingAuthentication', - type: 'options', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, - options: [ - { - name: 'Basic Auth', - value: 'basicAuth', - }, - { - name: 'Header Auth', - value: 'headerAuth', - }, - { - name: 'None', - value: 'none', - }, - ], - default: 'none', - description: - 'If and how incoming resume-webhook-requests to $execution.resumeUrl should be authenticated for additional security', - }, { displayName: 'Resume', name: 'resume', @@ -140,6 +76,12 @@ export class Wait implements INodeType { default: 'timeInterval', description: 'Determines the waiting mode to use before the workflow continues', }, + { + ...authenticationProperty(this.authPropertyName), + description: + 'If and how incoming resume-webhook-requests to $execution.resumeUrl should be authenticated for additional security', + displayOptions: displayOnWebhook, + }, // ---------------------------------- // resume:specificTime @@ -215,142 +157,39 @@ export class Wait implements INodeType { 'The webhook URL will be generated at run time. It can be referenced with the $execution.resumeUrl variable. Send it somewhere before getting to this node. More info', name: 'webhookNotice', type: 'notice', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, + displayOptions: displayOnWebhook, default: '', }, { - displayName: 'HTTP Method', - name: 'httpMethod', - type: 'options', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, - options: [ - { - name: 'DELETE', - value: 'DELETE', - }, - { - name: 'GET', - value: 'GET', - }, - { - name: 'HEAD', - value: 'HEAD', - }, - { - name: 'PATCH', - value: 'PATCH', - }, - { - name: 'POST', - value: 'POST', - }, - { - name: 'PUT', - value: 'PUT', - }, - ], - default: 'GET', + ...httpMethodsProperty, + displayOptions: displayOnWebhook, description: 'The HTTP method of the Webhook call', }, { - displayName: 'Response Code', - name: 'responseCode', - type: 'number', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, - typeOptions: { - minValue: 100, - maxValue: 599, - }, - default: 200, - description: 'The HTTP Response code to return', + ...responseCodeProperty, + displayOptions: displayOnWebhook, }, { - displayName: 'Respond', - name: 'responseMode', - type: 'options', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, - options: [ - { - name: 'Immediately', - value: 'onReceived', - description: 'As soon as this node executes', - }, - { - name: 'When Last Node Finishes', - value: 'lastNode', - description: 'Returns data of the last-executed node', - }, - { - name: "Using 'Respond to Webhook' Node", - value: 'responseNode', - description: 'Response defined in that node', - }, - ], - default: 'onReceived', - description: 'When and how to respond to the webhook', + ...responseModeProperty, + displayOptions: displayOnWebhook, }, { - displayName: 'Response Data', - name: 'responseData', - type: 'options', + ...responseDataProperty, displayOptions: { show: { - resume: ['webhook'], - responseMode: ['lastNode'], + ...responseDataProperty.displayOptions?.show, + ...displayOnWebhook.show, }, }, - options: [ - { - name: 'All Entries', - value: 'allEntries', - description: 'Returns all the entries of the last node. Always returns an array.', - }, - { - name: 'First Entry JSON', - value: 'firstEntryJson', - description: - 'Returns the JSON data of the first entry of the last node. Always returns a JSON object.', - }, - { - name: 'First Entry Binary', - value: 'firstEntryBinary', - description: - 'Returns the binary data of the first entry of the last node. Always returns a binary file.', - }, - ], - default: 'firstEntryJson', - description: - 'What data should be returned. If it should return all the items as array or only the first item as object.', }, { - displayName: 'Property Name', - name: 'responseBinaryPropertyName', - type: 'string', - required: true, - default: 'data', + ...responseBinaryPropertyNameProperty, displayOptions: { show: { - resume: ['webhook'], - responseData: ['firstEntryBinary'], + ...responseBinaryPropertyNameProperty.displayOptions?.show, + ...displayOnWebhook.show, }, }, - description: 'Name of the binary property to return', }, { displayName: 'Limit Wait Time', @@ -360,11 +199,7 @@ export class Wait implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether description: 'If no webhook call is received, the workflow will automatically resume execution after the specified limit type', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, + displayOptions: displayOnWebhook, }, { displayName: 'Limit Type', @@ -376,7 +211,7 @@ export class Wait implements INodeType { displayOptions: { show: { limitWaitTime: [true], - resume: ['webhook'], + ...displayOnWebhook.show, }, }, options: [ @@ -400,7 +235,7 @@ export class Wait implements INodeType { show: { limitType: ['afterTimeInterval'], limitWaitTime: [true], - resume: ['webhook'], + ...displayOnWebhook.show, }, }, typeOptions: { @@ -418,7 +253,7 @@ export class Wait implements INodeType { show: { limitType: ['afterTimeInterval'], limitWaitTime: [true], - resume: ['webhook'], + ...displayOnWebhook.show, }, }, options: [ @@ -450,132 +285,17 @@ export class Wait implements INodeType { show: { limitType: ['atSpecifiedTime'], limitWaitTime: [true], - resume: ['webhook'], + ...displayOnWebhook.show, }, }, default: '', description: 'Continue execution after the specified date and time', }, { - displayName: 'Options', - name: 'options', - type: 'collection', - displayOptions: { - show: { - resume: ['webhook'], - }, - }, - placeholder: 'Add Option', - default: {}, + ...optionsProperty, + displayOptions: displayOnWebhook, options: [ - { - displayName: 'Binary Data', - name: 'binaryData', - type: 'boolean', - displayOptions: { - show: { - '/httpMethod': ['PATCH', 'PUT', 'POST'], - }, - }, - default: false, - description: 'Whether the webhook will receive binary data', - }, - { - displayName: 'Binary Property', - name: 'binaryPropertyName', - type: 'string', - default: 'data', - displayOptions: { - show: { - binaryData: [true], - }, - }, - description: - 'Name of the binary property to which to write the data of the received file. If the data gets received via "Form-Data Multipart" it will be the prefix and a number starting with 0 will be attached to it.', - }, - { - displayName: 'Ignore Bots', - name: 'ignoreBots', - type: 'boolean', - default: false, - description: - 'Whether to ignore requests from bots like link previewers and web crawlers', - }, - { - displayName: 'Response Data', - name: 'responseData', - type: 'string', - displayOptions: { - show: { - '/responseMode': ['onReceived'], - }, - }, - default: '', - placeholder: 'success', - description: 'Custom response data to send', - }, - { - displayName: 'Response Content-Type', - name: 'responseContentType', - type: 'string', - displayOptions: { - show: { - '/responseData': ['firstEntryJson'], - '/responseMode': ['lastNode'], - }, - }, - default: '', - placeholder: 'application/xml', - // eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-json - description: - 'Set a custom content-type to return if another one as the "application/json" should be returned', - }, - { - displayName: 'Response Headers', - name: 'responseHeaders', - placeholder: 'Add Response Header', - description: 'Add headers to the webhook response', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - default: {}, - options: [ - { - name: 'entries', - displayName: 'Entries', - values: [ - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - description: 'Name of the header', - }, - { - displayName: 'Value', - name: 'value', - type: 'string', - default: '', - description: 'Value of the header', - }, - ], - }, - ], - }, - { - displayName: 'Property Name', - name: 'responsePropertyName', - type: 'string', - displayOptions: { - show: { - '/responseData': ['firstEntryJson'], - '/responseMode': ['lastNode'], - }, - }, - default: 'data', - description: 'Name of the property to return the data of instead of the whole JSON', - }, + ...(optionsProperty.options as INodeProperties[]), { displayName: 'Webhook Suffix', name: 'webhookSuffix', @@ -585,253 +305,23 @@ export class Wait implements INodeType { description: 'This suffix path will be appended to the restart URL. Helpful when using multiple wait nodes. Note: Does not support expressions.', }, - // { - // displayName: 'Raw Body', - // name: 'rawBody', - // type: 'boolean', - // displayOptions: { - // hide: { - // binaryData: [ - // true, - // ], - // }, - // }, - // default: false, - // description: 'Raw body (binary)', - // }, ], }, ], }; - async webhook(this: IWebhookFunctions): Promise { - // INFO: Currently (20.06.2021) 100% identical with Webhook-Node - const incomingAuthentication = this.getNodeParameter('incomingAuthentication') as string; - const options = this.getNodeParameter('options', {}) as IDataObject; - const req = this.getRequestObject(); - const resp = this.getResponseObject(); - const headers = this.getHeaderData(); - const realm = 'Webhook'; - - const ignoreBots = options.ignoreBots as boolean; - if (ignoreBots && isbot((headers as IDataObject)['user-agent'] as string)) { - return authorizationError(resp, realm, 403); - } - - if (incomingAuthentication === 'basicAuth') { - // Basic authorization is needed to call webhook - let httpBasicAuth: ICredentialDataDecryptedObject | undefined; - - try { - httpBasicAuth = await this.getCredentials('httpBasicAuth'); - } catch (error) { - // Do nothing - } - - if (httpBasicAuth === undefined || !httpBasicAuth.user || !httpBasicAuth.password) { - // Data is not defined on node so can not authenticate - return authorizationError(resp, realm, 500, 'No authentication data defined on node!'); - } - - const basicAuthData = basicAuth(req); - - if (basicAuthData === undefined) { - // Authorization data is missing - return authorizationError(resp, realm, 401); - } - - if ( - basicAuthData.name !== httpBasicAuth.user || - basicAuthData.pass !== httpBasicAuth.password - ) { - // Provided authentication data is wrong - return authorizationError(resp, realm, 403); - } - } else if (incomingAuthentication === 'headerAuth') { - // Special header with value is needed to call webhook - let httpHeaderAuth: ICredentialDataDecryptedObject | undefined; - - try { - httpHeaderAuth = await this.getCredentials('httpHeaderAuth'); - } catch (error) { - // Do nothing - } - - if (httpHeaderAuth === undefined || !httpHeaderAuth.name || !httpHeaderAuth.value) { - // Data is not defined on node so can not authenticate - return authorizationError(resp, realm, 500, 'No authentication data defined on node!'); - } - const headerName = (httpHeaderAuth.name as string).toLowerCase(); - const headerValue = httpHeaderAuth.value as string; - - if ( - !headers.hasOwnProperty(headerName) || - (headers as IDataObject)[headerName] !== headerValue - ) { - // Provided authentication data is wrong - return authorizationError(resp, realm, 403); - } - } - - const mimeType = headers['content-type'] ?? 'application/json'; - if (mimeType.includes('multipart/form-data')) { - const form = new formidable.IncomingForm({ multiples: true }); - - return new Promise((resolve, _reject) => { - form.parse(req, async (err, data, files) => { - const returnItem: INodeExecutionData = { - binary: {}, - json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: data, - }, - }; - - let count = 0; - for (const xfile of Object.keys(files)) { - const processFiles: formidable.File[] = []; - let multiFile = false; - if (Array.isArray(files[xfile])) { - processFiles.push(...(files[xfile] as formidable.File[])); - multiFile = true; - } else { - processFiles.push(files[xfile] as formidable.File); - } - - let fileCount = 0; - for (const file of processFiles) { - let binaryPropertyName = xfile; - if (binaryPropertyName.endsWith('[]')) { - binaryPropertyName = binaryPropertyName.slice(0, -2); - } - if (multiFile) { - binaryPropertyName += fileCount++; - } - if (options.binaryPropertyName) { - binaryPropertyName = `${options.binaryPropertyName}${count}`; - } - - const fileJson = file.toJSON(); - returnItem.binary![binaryPropertyName] = await this.nodeHelpers.copyBinaryFile( - file.path, - fileJson.name || fileJson.filename, - fileJson.type as string, - ); - - count += 1; - } - } - resolve({ - workflowData: [[returnItem]], - }); - }); - }); - } - - if (options.binaryData === true) { - const binaryFile = await tmpFile({ prefix: 'n8n-webhook-' }); - - try { - await pipeline(req, fs.createWriteStream(binaryFile.path)); - - const returnItem: INodeExecutionData = { - binary: {}, - json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: this.getBodyData(), - }, - }; - - const binaryPropertyName = (options.binaryPropertyName || 'data') as string; - returnItem.binary![binaryPropertyName] = await this.nodeHelpers.copyBinaryFile( - binaryFile.path, - mimeType, - ); - - return { - workflowData: [[returnItem]], - }; - } catch (error) { - throw new NodeOperationError(this.getNode(), error as Error); - } finally { - await binaryFile.cleanup(); - } - } - - const response: INodeExecutionData = { - json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: this.getBodyData(), - }, - }; - - if (options.rawBody) { - response.binary = { - data: { - data: req.rawBody.toString(BINARY_ENCODING), - mimeType, - }, - }; - } - - let webhookResponse: string | undefined; - if (options.responseData) { - webhookResponse = options.responseData as string; - } - - return { - webhookResponse, - workflowData: [[response]], - }; - } - - async execute(this: IExecuteFunctions): Promise { - const resume = this.getNodeParameter('resume', 0) as string; + async execute(context: IExecuteFunctions): Promise { + const resume = context.getNodeParameter('resume', 0) as string; if (resume === 'webhook') { - let waitTill = new Date(WAIT_TIME_UNLIMITED); - - const limitWaitTime = this.getNodeParameter('limitWaitTime', 0); - - if (limitWaitTime === true) { - const limitType = this.getNodeParameter('limitType', 0); - if (limitType === 'afterTimeInterval') { - let waitAmount = this.getNodeParameter('resumeAmount', 0) as number; - const resumeUnit = this.getNodeParameter('resumeUnit', 0); - if (resumeUnit === 'minutes') { - waitAmount *= 60; - } - if (resumeUnit === 'hours') { - waitAmount *= 60 * 60; - } - if (resumeUnit === 'days') { - waitAmount *= 60 * 60 * 24; - } - - waitAmount *= 1000; - - waitTill = new Date(new Date().getTime() + waitAmount); - } else { - waitTill = new Date(this.getNodeParameter('maxDateAndTime', 0) as string); - } - } - - await this.putExecutionToWait(waitTill); - - return [this.getInputData()]; + return this.handleWebhookResume(context); } let waitTill: Date; if (resume === 'timeInterval') { - const unit = this.getNodeParameter('unit', 0) as string; + const unit = context.getNodeParameter('unit', 0) as string; - let waitAmount = this.getNodeParameter('amount', 0) as number; + let waitAmount = context.getNodeParameter('amount', 0) as number; if (unit === 'minutes') { waitAmount *= 60; } @@ -847,7 +337,7 @@ export class Wait implements INodeType { waitTill = new Date(new Date().getTime() + waitAmount); } else { // resume: dateTime - const dateTime = this.getNodeParameter('dateTime', 0) as string; + const dateTime = context.getNodeParameter('dateTime', 0) as string; waitTill = new Date(dateTime); } @@ -859,14 +349,48 @@ export class Wait implements INodeType { // we just check the database every 60 seconds. return new Promise((resolve, _reject) => { setTimeout(() => { - resolve([this.getInputData()]); + resolve([context.getInputData()]); }, waitValue); }); } - // If longer than 60 seconds put execution to wait - await this.putExecutionToWait(waitTill); + // If longer than 65 seconds put execution to wait + return this.putToWait(context, waitTill); + } - return [this.getInputData()]; + private async handleWebhookResume(context: IExecuteFunctions) { + let waitTill = new Date(WAIT_TIME_UNLIMITED); + + const limitWaitTime = context.getNodeParameter('limitWaitTime', 0); + + if (limitWaitTime === true) { + const limitType = context.getNodeParameter('limitType', 0); + if (limitType === 'afterTimeInterval') { + let waitAmount = context.getNodeParameter('resumeAmount', 0) as number; + const resumeUnit = context.getNodeParameter('resumeUnit', 0); + if (resumeUnit === 'minutes') { + waitAmount *= 60; + } + if (resumeUnit === 'hours') { + waitAmount *= 60 * 60; + } + if (resumeUnit === 'days') { + waitAmount *= 60 * 60 * 24; + } + + waitAmount *= 1000; + + waitTill = new Date(new Date().getTime() + waitAmount); + } else { + waitTill = new Date(context.getNodeParameter('maxDateAndTime', 0) as string); + } + } + + return this.putToWait(context, waitTill); + } + + private async putToWait(context: IExecuteFunctions, waitTill: Date) { + await context.putExecutionToWait(waitTill); + return [context.getInputData()]; } } diff --git a/packages/nodes-base/nodes/Webhook/Webhook.node.ts b/packages/nodes-base/nodes/Webhook/Webhook.node.ts index f2fa74f7b6..2e8546bac5 100644 --- a/packages/nodes-base/nodes/Webhook/Webhook.node.ts +++ b/packages/nodes-base/nodes/Webhook/Webhook.node.ts @@ -1,43 +1,40 @@ +/* eslint-disable n8n-nodes-base/node-execute-block-wrong-error-thrown */ import type { IWebhookFunctions, ICredentialDataDecryptedObject, IDataObject, INodeExecutionData, - INodeType, INodeTypeDescription, IWebhookResponseData, } from 'n8n-workflow'; -import { BINARY_ENCODING, NodeOperationError } from 'n8n-workflow'; +import { BINARY_ENCODING, NodeOperationError, Node } from 'n8n-workflow'; import fs from 'fs'; import stream from 'stream'; import { promisify } from 'util'; import basicAuth from 'basic-auth'; -import type { Response } from 'express'; import formidable from 'formidable'; import isbot from 'isbot'; import { file as tmpFile } from 'tmp-promise'; +import { + authenticationProperty, + credentialsProperty, + defaultWebhookDescription, + httpMethodsProperty, + optionsProperty, + responseBinaryPropertyNameProperty, + responseCodeProperty, + responseDataProperty, + responseModeProperty, +} from './description'; +import { WebhookAuthorizationError } from './error'; + const pipeline = promisify(stream.pipeline); -function authorizationError(resp: Response, realm: string, responseCode: number, message?: string) { - if (message === undefined) { - message = 'Authorization problem!'; - if (responseCode === 401) { - message = 'Authorization is required!'; - } else if (responseCode === 403) { - message = 'Authorization data is wrong!'; - } - } +export class Webhook extends Node { + authPropertyName = 'authentication'; - resp.writeHead(responseCode, { 'WWW-Authenticate': `Basic realm="${realm}"` }); - resp.end(message); - return { - noWebhookResponse: true, - }; -} - -export class Webhook implements INodeType { description: INodeTypeDescription = { displayName: 'Webhook', icon: 'file:webhook.svg', @@ -64,97 +61,11 @@ export class Webhook implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node inputs: [], outputs: ['main'], - credentials: [ - { - name: 'httpBasicAuth', - required: true, - displayOptions: { - show: { - authentication: ['basicAuth'], - }, - }, - }, - { - name: 'httpHeaderAuth', - required: true, - displayOptions: { - show: { - authentication: ['headerAuth'], - }, - }, - }, - ], - webhooks: [ - { - name: 'default', - httpMethod: '={{$parameter["httpMethod"]}}', - isFullPath: true, - responseCode: '={{$parameter["responseCode"]}}', - responseMode: '={{$parameter["responseMode"]}}', - responseData: - '={{$parameter["responseData"] || ($parameter.options.noResponseBody ? "noData" : undefined) }}', - responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}', - responseContentType: '={{$parameter["options"]["responseContentType"]}}', - responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}', - responseHeaders: '={{$parameter["options"]["responseHeaders"]}}', - path: '={{$parameter["path"]}}', - }, - ], + credentials: credentialsProperty(this.authPropertyName), + webhooks: [defaultWebhookDescription], properties: [ - { - displayName: 'Authentication', - name: 'authentication', - type: 'options', - options: [ - { - name: 'Basic Auth', - value: 'basicAuth', - }, - { - name: 'Header Auth', - value: 'headerAuth', - }, - { - name: 'None', - value: 'none', - }, - ], - default: 'none', - description: 'The way to authenticate', - }, - { - displayName: 'HTTP Method', - name: 'httpMethod', - type: 'options', - options: [ - { - name: 'DELETE', - value: 'DELETE', - }, - { - name: 'GET', - value: 'GET', - }, - { - name: 'HEAD', - value: 'HEAD', - }, - { - name: 'PATCH', - value: 'PATCH', - }, - { - name: 'POST', - value: 'POST', - }, - { - name: 'PUT', - value: 'PUT', - }, - ], - default: 'GET', - description: 'The HTTP method to listen to', - }, + authenticationProperty(this.authPropertyName), + httpMethodsProperty, { displayName: 'Path', name: 'path', @@ -164,30 +75,7 @@ export class Webhook implements INodeType { required: true, description: 'The path to listen to', }, - { - displayName: 'Respond', - name: 'responseMode', - type: 'options', - options: [ - { - name: 'Immediately', - value: 'onReceived', - description: 'As soon as this node executes', - }, - { - name: 'When Last Node Finishes', - value: 'lastNode', - description: 'Returns data of the last-executed node', - }, - { - name: "Using 'Respond to Webhook' Node", - value: 'responseNode', - description: 'Response defined in that node', - }, - ], - default: 'onReceived', - description: 'When and how to respond to the webhook', - }, + responseModeProperty, { displayName: 'Insert a \'Respond to Webhook\' node to control when and how you respond. More details', @@ -200,406 +88,206 @@ export class Webhook implements INodeType { }, default: '', }, - { - displayName: 'Response Code', - name: 'responseCode', - type: 'number', - displayOptions: { - hide: { - responseMode: ['responseNode'], - }, - }, - typeOptions: { - minValue: 100, - maxValue: 599, - }, - default: 200, - description: 'The HTTP Response code to return', - }, - { - displayName: 'Response Data', - name: 'responseData', - type: 'options', - displayOptions: { - show: { - responseMode: ['lastNode'], - }, - }, - options: [ - { - name: 'All Entries', - value: 'allEntries', - description: 'Returns all the entries of the last node. Always returns an array.', - }, - { - name: 'First Entry JSON', - value: 'firstEntryJson', - description: - 'Returns the JSON data of the first entry of the last node. Always returns a JSON object.', - }, - { - name: 'First Entry Binary', - value: 'firstEntryBinary', - description: - 'Returns the binary data of the first entry of the last node. Always returns a binary file.', - }, - { - name: 'No Response Body', - value: 'noData', - description: 'Returns without a body', - }, - ], - default: 'firstEntryJson', - description: - 'What data should be returned. If it should return all items as an array or only the first item as object.', - }, - { - displayName: 'Property Name', - name: 'responseBinaryPropertyName', - type: 'string', - required: true, - default: 'data', - displayOptions: { - show: { - responseData: ['firstEntryBinary'], - }, - }, - description: 'Name of the binary property to return', - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - options: [ - { - displayName: 'Binary Data', - name: 'binaryData', - type: 'boolean', - displayOptions: { - show: { - '/httpMethod': ['PATCH', 'PUT', 'POST'], - }, - }, - default: false, - description: 'Whether the webhook will receive binary data', - }, - { - displayName: 'Binary Property', - name: 'binaryPropertyName', - type: 'string', - default: 'data', - required: true, - displayOptions: { - show: { - binaryData: [true], - }, - }, - description: - 'Name of the binary property to write the data of the received file to. If the data gets received via "Form-Data Multipart" it will be the prefix and a number starting with 0 will be attached to it.', - }, - { - displayName: 'Ignore Bots', - name: 'ignoreBots', - type: 'boolean', - default: false, - description: - 'Whether to ignore requests from bots like link previewers and web crawlers', - }, - { - displayName: 'No Response Body', - name: 'noResponseBody', - type: 'boolean', - default: false, - description: 'Whether to send any body in the response', - displayOptions: { - hide: { - rawBody: [true], - }, - show: { - '/responseMode': ['onReceived'], - }, - }, - }, - { - displayName: 'Raw Body', - name: 'rawBody', - type: 'boolean', - displayOptions: { - hide: { - binaryData: [true], - noResponseBody: [true], - }, - }, - default: false, - // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether - description: 'Raw body (binary)', - }, - { - displayName: 'Response Data', - name: 'responseData', - type: 'string', - displayOptions: { - show: { - '/responseMode': ['onReceived'], - }, - hide: { - noResponseBody: [true], - }, - }, - default: '', - placeholder: 'success', - description: 'Custom response data to send', - }, - { - displayName: 'Response Content-Type', - name: 'responseContentType', - type: 'string', - displayOptions: { - show: { - '/responseData': ['firstEntryJson'], - '/responseMode': ['lastNode'], - }, - }, - default: '', - placeholder: 'application/xml', - // eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-json - description: - 'Set a custom content-type to return if another one as the "application/json" should be returned', - }, - { - displayName: 'Response Headers', - name: 'responseHeaders', - placeholder: 'Add Response Header', - description: 'Add headers to the webhook response', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - default: {}, - options: [ - { - name: 'entries', - displayName: 'Entries', - values: [ - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - description: 'Name of the header', - }, - { - displayName: 'Value', - name: 'value', - type: 'string', - default: '', - description: 'Value of the header', - }, - ], - }, - ], - }, - { - displayName: 'Property Name', - name: 'responsePropertyName', - type: 'string', - displayOptions: { - show: { - '/responseData': ['firstEntryJson'], - '/responseMode': ['lastNode'], - }, - }, - default: 'data', - description: 'Name of the property to return the data of instead of the whole JSON', - }, - ], - }, + responseCodeProperty, + responseDataProperty, + responseBinaryPropertyNameProperty, + optionsProperty, ], }; - async webhook(this: IWebhookFunctions): Promise { - const authentication = this.getNodeParameter('authentication') as string; - const options = this.getNodeParameter('options', {}) as IDataObject; - const req = this.getRequestObject(); - const resp = this.getResponseObject(); - const headers = this.getHeaderData(); - const realm = 'Webhook'; + async webhook(context: IWebhookFunctions): Promise { + const options = context.getNodeParameter('options', {}) as { + binaryData: boolean; + ignoreBots: boolean; + rawBody: Buffer; + responseData?: string; + }; + const req = context.getRequestObject(); + const resp = context.getResponseObject(); - const ignoreBots = options.ignoreBots as boolean; - if (ignoreBots && isbot((headers as IDataObject)['user-agent'] as string)) { - return authorizationError(resp, realm, 403); + try { + if (options.ignoreBots && isbot(req.headers['user-agent'])) + throw new WebhookAuthorizationError(403); + await this.validateAuth(context); + } catch (error) { + if (error instanceof WebhookAuthorizationError) { + resp.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' }); + resp.end(error.message); + return { noWebhookResponse: true }; + } + throw error; } - if (authentication === 'basicAuth') { - // Basic authorization is needed to call webhook - let httpBasicAuth: ICredentialDataDecryptedObject | undefined; - try { - httpBasicAuth = await this.getCredentials('httpBasicAuth'); - } catch (error) { - // Do nothing - } - - if (httpBasicAuth === undefined || !httpBasicAuth.user || !httpBasicAuth.password) { - // Data is not defined on node so can not authenticate - return authorizationError(resp, realm, 500, 'No authentication data defined on node!'); - } - - const basicAuthData = basicAuth(req); - - if (basicAuthData === undefined) { - // Authorization data is missing - return authorizationError(resp, realm, 401); - } - - if ( - basicAuthData.name !== httpBasicAuth.user || - basicAuthData.pass !== httpBasicAuth.password - ) { - // Provided authentication data is wrong - return authorizationError(resp, realm, 403); - } - } else if (authentication === 'headerAuth') { - // Special header with value is needed to call webhook - let httpHeaderAuth: ICredentialDataDecryptedObject | undefined; - try { - httpHeaderAuth = await this.getCredentials('httpHeaderAuth'); - } catch (error) { - // Do nothing - } - - if (httpHeaderAuth === undefined || !httpHeaderAuth.name || !httpHeaderAuth.value) { - // Data is not defined on node so can not authenticate - return authorizationError(resp, realm, 500, 'No authentication data defined on node!'); - } - const headerName = (httpHeaderAuth.name as string).toLowerCase(); - const headerValue = httpHeaderAuth.value as string; - - if ( - !headers.hasOwnProperty(headerName) || - (headers as IDataObject)[headerName] !== headerValue - ) { - // Provided authentication data is wrong - return authorizationError(resp, realm, 403); - } - } - - const mimeType = headers['content-type'] ?? 'application/json'; + const mimeType = req.headers['content-type'] ?? 'application/json'; if (mimeType.includes('multipart/form-data')) { - const form = new formidable.IncomingForm({ multiples: true }); - - return new Promise((resolve, _reject) => { - form.parse(req, async (err, data, files) => { - const returnItem: INodeExecutionData = { - binary: {}, - json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: data, - }, - }; - - let count = 0; - for (const xfile of Object.keys(files)) { - const processFiles: formidable.File[] = []; - let multiFile = false; - if (Array.isArray(files[xfile])) { - processFiles.push(...(files[xfile] as formidable.File[])); - multiFile = true; - } else { - processFiles.push(files[xfile] as formidable.File); - } - - let fileCount = 0; - for (const file of processFiles) { - let binaryPropertyName = xfile; - if (binaryPropertyName.endsWith('[]')) { - binaryPropertyName = binaryPropertyName.slice(0, -2); - } - if (multiFile) { - binaryPropertyName += fileCount++; - } - if (options.binaryPropertyName) { - binaryPropertyName = `${options.binaryPropertyName}${count}`; - } - - const fileJson = file.toJSON(); - returnItem.binary![binaryPropertyName] = await this.nodeHelpers.copyBinaryFile( - file.path, - fileJson.name || fileJson.filename, - fileJson.type as string, - ); - - count += 1; - } - } - resolve({ - workflowData: [[returnItem]], - }); - }); - }); + return this.handleFormData(context); } - if (options.binaryData === true) { - const binaryFile = await tmpFile({ prefix: 'n8n-webhook-' }); - - try { - await pipeline(req, fs.createWriteStream(binaryFile.path)); - - const returnItem: INodeExecutionData = { - binary: {}, - json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: this.getBodyData(), - }, - }; - - const binaryPropertyName = (options.binaryPropertyName || 'data') as string; - returnItem.binary![binaryPropertyName] = await this.nodeHelpers.copyBinaryFile( - binaryFile.path, - mimeType, - ); - - return { - workflowData: [[returnItem]], - }; - } catch (error) { - throw new NodeOperationError(this.getNode(), error as Error); - } finally { - await binaryFile.cleanup(); - } + if (options.binaryData) { + return this.handleBinaryData(context); } const response: INodeExecutionData = { json: { - headers, - params: this.getParamsData(), - query: this.getQueryData(), - body: this.getBodyData(), + headers: req.headers, + params: req.params, + query: req.query, + body: req.body, }, + binary: options.rawBody + ? { + data: { + data: req.rawBody.toString(BINARY_ENCODING), + mimeType, + }, + } + : undefined, }; - if (options.rawBody) { - response.binary = { - data: { - data: req.rawBody.toString(BINARY_ENCODING), - mimeType, - }, - }; - } - - let webhookResponse: string | undefined; - if (options.responseData) { - webhookResponse = options.responseData as string; - } - return { - webhookResponse, + webhookResponse: options.responseData, workflowData: [[response]], }; } + + private async validateAuth(context: IWebhookFunctions) { + const authentication = context.getNodeParameter(this.authPropertyName) as string; + if (authentication === 'none') return; + + const req = context.getRequestObject(); + const headers = context.getHeaderData(); + + if (authentication === 'basicAuth') { + // Basic authorization is needed to call webhook + let expectedAuth: ICredentialDataDecryptedObject | undefined; + try { + expectedAuth = await context.getCredentials('httpBasicAuth'); + } catch {} + + if (expectedAuth === undefined || !expectedAuth.user || !expectedAuth.password) { + // Data is not defined on node so can not authenticate + throw new WebhookAuthorizationError(500, 'No authentication data defined on node!'); + } + + const providedAuth = basicAuth(req); + // Authorization data is missing + if (!providedAuth) throw new WebhookAuthorizationError(401); + + if (providedAuth.name !== expectedAuth.user || providedAuth.pass !== expectedAuth.password) { + // Provided authentication data is wrong + throw new WebhookAuthorizationError(403); + } + } else if (authentication === 'headerAuth') { + // Special header with value is needed to call webhook + let expectedAuth: ICredentialDataDecryptedObject | undefined; + try { + expectedAuth = await context.getCredentials('httpHeaderAuth'); + } catch {} + + if (expectedAuth === undefined || !expectedAuth.name || !expectedAuth.value) { + // Data is not defined on node so can not authenticate + throw new WebhookAuthorizationError(500, 'No authentication data defined on node!'); + } + const headerName = (expectedAuth.name as string).toLowerCase(); + const expectedValue = expectedAuth.value as string; + + if ( + !headers.hasOwnProperty(headerName) || + (headers as IDataObject)[headerName] !== expectedValue + ) { + // Provided authentication data is wrong + throw new WebhookAuthorizationError(403); + } + } + } + + private async handleFormData(context: IWebhookFunctions) { + const req = context.getRequestObject(); + const options = context.getNodeParameter('options', {}) as IDataObject; + + const form = new formidable.IncomingForm({ multiples: true }); + + return new Promise((resolve, _reject) => { + form.parse(req, async (err, data, files) => { + const returnItem: INodeExecutionData = { + binary: {}, + json: { + headers: req.headers, + params: req.params, + query: req.query, + body: data, + }, + }; + + let count = 0; + for (const xfile of Object.keys(files)) { + const processFiles: formidable.File[] = []; + let multiFile = false; + if (Array.isArray(files[xfile])) { + processFiles.push(...(files[xfile] as formidable.File[])); + multiFile = true; + } else { + processFiles.push(files[xfile] as formidable.File); + } + + let fileCount = 0; + for (const file of processFiles) { + let binaryPropertyName = xfile; + if (binaryPropertyName.endsWith('[]')) { + binaryPropertyName = binaryPropertyName.slice(0, -2); + } + if (multiFile) { + binaryPropertyName += fileCount++; + } + if (options.binaryPropertyName) { + binaryPropertyName = `${options.binaryPropertyName}${count}`; + } + + const fileJson = file.toJSON(); + returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile( + file.path, + fileJson.name || fileJson.filename, + fileJson.type as string, + ); + + count += 1; + } + } + resolve({ workflowData: [[returnItem]] }); + }); + }); + } + + private async handleBinaryData(context: IWebhookFunctions): Promise { + const req = context.getRequestObject(); + const options = context.getNodeParameter('options', {}) as IDataObject; + + const binaryFile = await tmpFile({ prefix: 'n8n-webhook-' }); + + try { + await pipeline(req, fs.createWriteStream(binaryFile.path)); + + const returnItem: INodeExecutionData = { + binary: {}, + json: { + headers: req.headers, + params: req.params, + query: req.query, + body: req.body, + }, + }; + + const binaryPropertyName = (options.binaryPropertyName || 'data') as string; + returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile( + binaryFile.path, + req.headers['content-type'] ?? 'application/octet-stream', + ); + + return { workflowData: [[returnItem]] }; + } catch (error) { + throw new NodeOperationError(context.getNode(), error as Error); + } finally { + await binaryFile.cleanup(); + } + } } diff --git a/packages/nodes-base/nodes/Webhook/description.ts b/packages/nodes-base/nodes/Webhook/description.ts new file mode 100644 index 0000000000..4fd3a5bb17 --- /dev/null +++ b/packages/nodes-base/nodes/Webhook/description.ts @@ -0,0 +1,341 @@ +import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from 'n8n-workflow'; + +export const defaultWebhookDescription: IWebhookDescription = { + name: 'default', + httpMethod: '={{$parameter["httpMethod"]}}', + isFullPath: true, + responseCode: '={{$parameter["responseCode"]}}', + responseMode: '={{$parameter["responseMode"]}}', + responseData: + '={{$parameter["responseData"] || ($parameter.options.noResponseBody ? "noData" : undefined) }}', + responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}', + responseContentType: '={{$parameter["options"]["responseContentType"]}}', + responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}', + responseHeaders: '={{$parameter["options"]["responseHeaders"]}}', + path: '={{$parameter["path"]}}', +}; + +export const credentialsProperty = ( + propertyName: string = 'authentication', +): INodeTypeDescription['credentials'] => [ + { + name: 'httpBasicAuth', + required: true, + displayOptions: { + show: { + [propertyName]: ['basicAuth'], + }, + }, + }, + { + name: 'httpHeaderAuth', + required: true, + displayOptions: { + show: { + [propertyName]: ['headerAuth'], + }, + }, + }, +]; + +export const authenticationProperty = ( + propertyName: string = 'authentication', +): INodeProperties => ({ + displayName: 'Authentication', + name: propertyName, + type: 'options', + options: [ + { + name: 'Basic Auth', + value: 'basicAuth', + }, + { + name: 'Header Auth', + value: 'headerAuth', + }, + { + name: 'None', + value: 'none', + }, + ], + default: 'none', + description: 'The way to authenticate', +}); + +export const httpMethodsProperty: INodeProperties = { + displayName: 'HTTP Method', + name: 'httpMethod', + type: 'options', + options: [ + { + name: 'DELETE', + value: 'DELETE', + }, + { + name: 'GET', + value: 'GET', + }, + { + name: 'HEAD', + value: 'HEAD', + }, + { + name: 'PATCH', + value: 'PATCH', + }, + { + name: 'POST', + value: 'POST', + }, + { + name: 'PUT', + value: 'PUT', + }, + ], + default: 'GET', + description: 'The HTTP method to listen to', +}; + +export const responseCodeProperty: INodeProperties = { + displayName: 'Response Code', + name: 'responseCode', + type: 'number', + displayOptions: { + hide: { + responseMode: ['responseNode'], + }, + }, + typeOptions: { + minValue: 100, + maxValue: 599, + }, + default: 200, + description: 'The HTTP Response code to return', +}; + +export const responseModeProperty: INodeProperties = { + displayName: 'Respond', + name: 'responseMode', + type: 'options', + options: [ + { + name: 'Immediately', + value: 'onReceived', + description: 'As soon as this node executes', + }, + { + name: 'When Last Node Finishes', + value: 'lastNode', + description: 'Returns data of the last-executed node', + }, + { + name: "Using 'Respond to Webhook' Node", + value: 'responseNode', + description: 'Response defined in that node', + }, + ], + default: 'onReceived', + description: 'When and how to respond to the webhook', +}; + +export const responseDataProperty: INodeProperties = { + displayName: 'Response Data', + name: 'responseData', + type: 'options', + displayOptions: { + show: { + responseMode: ['lastNode'], + }, + }, + options: [ + { + name: 'All Entries', + value: 'allEntries', + description: 'Returns all the entries of the last node. Always returns an array.', + }, + { + name: 'First Entry JSON', + value: 'firstEntryJson', + description: + 'Returns the JSON data of the first entry of the last node. Always returns a JSON object.', + }, + { + name: 'First Entry Binary', + value: 'firstEntryBinary', + description: + 'Returns the binary data of the first entry of the last node. Always returns a binary file.', + }, + { + name: 'No Response Body', + value: 'noData', + description: 'Returns without a body', + }, + ], + default: 'firstEntryJson', + description: + 'What data should be returned. If it should return all items as an array or only the first item as object.', +}; + +export const responseBinaryPropertyNameProperty: INodeProperties = { + displayName: 'Property Name', + name: 'responseBinaryPropertyName', + type: 'string', + required: true, + default: 'data', + displayOptions: { + show: { + responseData: ['firstEntryBinary'], + }, + }, + description: 'Name of the binary property to return', +}; + +export const optionsProperty: INodeProperties = { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Binary Data', + name: 'binaryData', + type: 'boolean', + displayOptions: { + show: { + '/httpMethod': ['PATCH', 'PUT', 'POST'], + }, + }, + default: false, + description: 'Whether the webhook will receive binary data', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + binaryData: [true], + }, + }, + description: + 'Name of the binary property to write the data of the received file to. If the data gets received via "Form-Data Multipart" it will be the prefix and a number starting with 0 will be attached to it.', + }, + { + displayName: 'Ignore Bots', + name: 'ignoreBots', + type: 'boolean', + default: false, + description: 'Whether to ignore requests from bots like link previewers and web crawlers', + }, + { + displayName: 'No Response Body', + name: 'noResponseBody', + type: 'boolean', + default: false, + description: 'Whether to send any body in the response', + displayOptions: { + hide: { + rawBody: [true], + }, + show: { + '/responseMode': ['onReceived'], + }, + }, + }, + { + displayName: 'Raw Body', + name: 'rawBody', + type: 'boolean', + displayOptions: { + hide: { + binaryData: [true], + noResponseBody: [true], + }, + }, + default: false, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: 'Raw body (binary)', + }, + { + displayName: 'Response Data', + name: 'responseData', + type: 'string', + displayOptions: { + show: { + '/responseMode': ['onReceived'], + }, + hide: { + noResponseBody: [true], + }, + }, + default: '', + placeholder: 'success', + description: 'Custom response data to send', + }, + { + displayName: 'Response Content-Type', + name: 'responseContentType', + type: 'string', + displayOptions: { + show: { + '/responseData': ['firstEntryJson'], + '/responseMode': ['lastNode'], + }, + }, + default: '', + placeholder: 'application/xml', + // eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-json + description: + 'Set a custom content-type to return if another one as the "application/json" should be returned', + }, + { + displayName: 'Response Headers', + name: 'responseHeaders', + placeholder: 'Add Response Header', + description: 'Add headers to the webhook response', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'entries', + displayName: 'Entries', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the header', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value of the header', + }, + ], + }, + ], + }, + { + displayName: 'Property Name', + name: 'responsePropertyName', + type: 'string', + displayOptions: { + show: { + '/responseData': ['firstEntryJson'], + '/responseMode': ['lastNode'], + }, + }, + default: 'data', + description: 'Name of the property to return the data of instead of the whole JSON', + }, + ], +}; diff --git a/packages/nodes-base/nodes/Webhook/error.ts b/packages/nodes-base/nodes/Webhook/error.ts new file mode 100644 index 0000000000..d9397a93e0 --- /dev/null +++ b/packages/nodes-base/nodes/Webhook/error.ts @@ -0,0 +1,13 @@ +export class WebhookAuthorizationError extends Error { + constructor(readonly responseCode: number, message?: string) { + if (message === undefined) { + message = 'Authorization problem!'; + if (responseCode === 401) { + message = 'Authorization is required!'; + } else if (responseCode === 403) { + message = 'Authorization data is wrong!'; + } + } + super(message); + } +} diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index e9d4ed1f61..f6c3ac92a6 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1258,6 +1258,16 @@ export interface INodeType { }; } +/** + * This class serves as the base for all nodes using the new context API + * having this as a class enables us to identify these instances at runtime + */ +export abstract class Node { + abstract description: INodeTypeDescription; + execute?(context: IExecuteFunctions): Promise; + webhook?(context: IWebhookFunctions): Promise; +} + export interface IVersionedNodeType { nodeVersions: { [key: number]: INodeType; diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 18092ce3e7..4262006fe6 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -49,6 +49,7 @@ import type { IRunNodeResponse, NodeParameterValueType, } from './Interfaces'; +import { Node } from './Interfaces'; import type { IDeferredPromise } from './DeferredPromise'; import * as NodeHelpers from './NodeHelpers'; @@ -1137,14 +1138,14 @@ export class Workflow { throw new Error(`The node "${node.name}" does not have any webhooks defined.`); } - const thisArgs = nodeExecuteFunctions.getExecuteWebhookFunctions( + const context = nodeExecuteFunctions.getExecuteWebhookFunctions( this, node, additionalData, mode, webhookData, ); - return nodeType.webhook.call(thisArgs); + return nodeType instanceof Node ? nodeType.webhook(context) : nodeType.webhook.call(context); } /** @@ -1255,7 +1256,7 @@ export class Workflow { return { data: [promiseResults] }; } } else if (nodeType.execute) { - const thisArgs = nodeExecuteFunctions.getExecuteFunctions( + const context = nodeExecuteFunctions.getExecuteFunctions( this, runExecutionData, runIndex, @@ -1266,7 +1267,11 @@ export class Workflow { executionData, mode, ); - return { data: await nodeType.execute.call(thisArgs) }; + const data = + nodeType instanceof Node + ? await nodeType.execute(context) + : await nodeType.execute.call(context); + return { data }; } else if (nodeType.poll) { if (mode === 'manual') { // In manual mode run the poll function From 225e849960ce65d7f85b482f05fb3d7ffb4f9427 Mon Sep 17 00:00:00 2001 From: Romeo Balta Date: Tue, 4 Jul 2023 15:43:53 +0100 Subject: [PATCH 39/46] feat(Notion Node): Add option to update icon when updating a page (#5670) --- .../nodes/Notion/DatabasePageDescription.ts | 42 +++++++++++++++++++ .../nodes/Notion/v2/NotionV2.node.ts | 10 +++++ 2 files changed, 52 insertions(+) diff --git a/packages/nodes-base/nodes/Notion/DatabasePageDescription.ts b/packages/nodes-base/nodes/Notion/DatabasePageDescription.ts index c28d61ed42..4ab8e42313 100644 --- a/packages/nodes-base/nodes/Notion/DatabasePageDescription.ts +++ b/packages/nodes-base/nodes/Notion/DatabasePageDescription.ts @@ -1000,6 +1000,48 @@ export const databasePageFields: INodeProperties[] = [ }, ], }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + resource: ['databasePage'], + operation: ['update'], + }, + }, + default: {}, + placeholder: 'Add Option', + options: [ + { + displayName: 'Icon Type', + name: 'iconType', + type: 'options', + options: [ + { + name: 'Emoji', + value: 'emoji', + description: 'Use an Emoji for the icon', + }, + { + name: 'File', + value: 'file', + description: 'Use a file for the icon', + }, + ], + default: 'emoji', + description: 'The icon type for the database page, Either a URL or an Emoji', + }, + { + displayName: 'Icon', + name: 'icon', + type: 'string', + default: '', + description: 'Emoji or File URL to use as the icon', + }, + ], + }, + /* -------------------------------------------------------------------------- */ /* databasePage:get */ /* -------------------------------------------------------------------------- */ diff --git a/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts b/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts index 4f85773124..fa55c4ce2a 100644 --- a/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts +++ b/packages/nodes-base/nodes/Notion/v2/NotionV2.node.ts @@ -600,6 +600,16 @@ export class NotionV2 implements INodeType { if (properties.length !== 0) { body.properties = mapProperties.call(this, properties, timezone, 2) as IDataObject; } + + const options = this.getNodeParameter('options', i); + if (options.icon) { + if (options.iconType && options.iconType === 'file') { + body.icon = { type: 'external', external: { url: options.icon } }; + } else { + body.icon = { type: 'emoji', emoji: options.icon }; + } + } + responseData = await notionApiRequest.call(this, 'PATCH', `/pages/${pageId}`, body); if (simple) { responseData = simplifyObjects(responseData, false); From 7495e31a5b25e97683c7ea38225ba253d8fae8b7 Mon Sep 17 00:00:00 2001 From: ZergRael Date: Tue, 4 Jul 2023 16:48:33 +0200 Subject: [PATCH 40/46] feat(Strava Node): Add hide_from_home field in Activity Update (#5883) --- packages/nodes-base/nodes/Strava/ActivityDescription.ts | 7 +++++++ packages/nodes-base/nodes/Strava/Strava.node.ts | 8 -------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Strava/ActivityDescription.ts b/packages/nodes-base/nodes/Strava/ActivityDescription.ts index c2f8da8d09..d6a0f147a1 100644 --- a/packages/nodes-base/nodes/Strava/ActivityDescription.ts +++ b/packages/nodes-base/nodes/Strava/ActivityDescription.ts @@ -233,6 +233,13 @@ export const activityFields: INodeProperties[] = [ description: 'Identifier for the gear associated with the activity. ‘none’ clears gear from activity.', }, + { + displayName: 'Mute activity', + name: 'hide_from_home', + type: 'boolean', + default: false, + description: 'Do not publish to Home or Club feeds', + }, { displayName: 'Name', name: 'name', diff --git a/packages/nodes-base/nodes/Strava/Strava.node.ts b/packages/nodes-base/nodes/Strava/Strava.node.ts index 15535b9851..a95fa4b915 100644 --- a/packages/nodes-base/nodes/Strava/Strava.node.ts +++ b/packages/nodes-base/nodes/Strava/Strava.node.ts @@ -161,14 +161,6 @@ export class Strava implements INodeType { const updateFields = this.getNodeParameter('updateFields', i); - if (updateFields.trainer === true) { - updateFields.trainer = 1; - } - - if (updateFields.commute === true) { - updateFields.commute = 1; - } - const body: IDataObject = {}; Object.assign(body, updateFields); From 9def7a729b52cd6b4698c47e190e9e2bd7894da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 5 Jul 2023 11:26:49 +0200 Subject: [PATCH 41/46] fix(core): Remove typeorm patches, but still enforce transactions on every migration (#6594) * revert(core): Remove typeorm patches, but still enforce transactions on every migration This reverts #6519 * always re-enable foreign keys, and explicitly rollback transaction --- package.json | 3 +- .../sqlite/1652367743993-AddUserSettings.ts | 2 + .../sqlite/1652905585850-AddAPIKeyColumn.ts | 2 + ...268682475-DeleteExecutionsWithWorkflows.ts | 2 + ...690000000002-MigrateIntegerKeysToString.ts | 13 ++-- packages/cli/src/databases/types.ts | 12 ++-- .../src/databases/utils/migrationHelpers.ts | 62 +++++++++++++------ patches/typeorm@0.3.12.patch | 31 ---------- pnpm-lock.yaml | 8 +-- 9 files changed, 69 insertions(+), 66 deletions(-) delete mode 100644 patches/typeorm@0.3.12.patch diff --git a/package.json b/package.json index 5d6c77cf6c..4435306ea8 100644 --- a/package.json +++ b/package.json @@ -95,8 +95,7 @@ "element-ui@2.15.12": "patches/element-ui@2.15.12.patch", "typedi@0.10.0": "patches/typedi@0.10.0.patch", "@sentry/cli@2.17.0": "patches/@sentry__cli@2.17.0.patch", - "pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch", - "typeorm@0.3.12": "patches/typeorm@0.3.12.patch" + "pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch" } } } diff --git a/packages/cli/src/databases/migrations/sqlite/1652367743993-AddUserSettings.ts b/packages/cli/src/databases/migrations/sqlite/1652367743993-AddUserSettings.ts index 82efcf0043..ee73395478 100644 --- a/packages/cli/src/databases/migrations/sqlite/1652367743993-AddUserSettings.ts +++ b/packages/cli/src/databases/migrations/sqlite/1652367743993-AddUserSettings.ts @@ -1,6 +1,8 @@ import type { MigrationContext, ReversibleMigration } from '@db/types'; export class AddUserSettings1652367743993 implements ReversibleMigration { + transaction = false as const; + async up({ queryRunner, tablePrefix }: MigrationContext) { await queryRunner.query( `CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, diff --git a/packages/cli/src/databases/migrations/sqlite/1652905585850-AddAPIKeyColumn.ts b/packages/cli/src/databases/migrations/sqlite/1652905585850-AddAPIKeyColumn.ts index fa319b9808..74440331bb 100644 --- a/packages/cli/src/databases/migrations/sqlite/1652905585850-AddAPIKeyColumn.ts +++ b/packages/cli/src/databases/migrations/sqlite/1652905585850-AddAPIKeyColumn.ts @@ -1,6 +1,8 @@ import type { MigrationContext, ReversibleMigration } from '@db/types'; export class AddAPIKeyColumn1652905585850 implements ReversibleMigration { + transaction = false as const; + async up({ queryRunner, tablePrefix }: MigrationContext) { await queryRunner.query( `CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, "apiKey" varchar, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, diff --git a/packages/cli/src/databases/migrations/sqlite/1673268682475-DeleteExecutionsWithWorkflows.ts b/packages/cli/src/databases/migrations/sqlite/1673268682475-DeleteExecutionsWithWorkflows.ts index 250c747ef4..cfd670bc62 100644 --- a/packages/cli/src/databases/migrations/sqlite/1673268682475-DeleteExecutionsWithWorkflows.ts +++ b/packages/cli/src/databases/migrations/sqlite/1673268682475-DeleteExecutionsWithWorkflows.ts @@ -1,6 +1,8 @@ import type { MigrationContext, ReversibleMigration } from '@db/types'; export class DeleteExecutionsWithWorkflows1673268682475 implements ReversibleMigration { + transaction = false as const; + async up({ queryRunner, tablePrefix }: MigrationContext) { const workflowIds = (await queryRunner.query(` SELECT id FROM "${tablePrefix}workflow_entity" diff --git a/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts b/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts index 50cd5c57b2..80e8dcaed6 100644 --- a/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts +++ b/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts @@ -1,18 +1,21 @@ import type { MigrationContext, IrreversibleMigration } from '@db/types'; export class MigrateIntegerKeysToString1690000000002 implements IrreversibleMigration { + transaction = false as const; + async up({ queryRunner, tablePrefix }: MigrationContext) { await queryRunner.query(` -CREATE TABLE "${tablePrefix}TMP_workflow_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text, "connections" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "settings" text, "staticData" text, "pinData" text, "versionId" varchar(36), "triggerCount" integer NOT NULL DEFAULT 0);`); + CREATE TABLE "${tablePrefix}TMP_workflow_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text, "connections" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "settings" text, "staticData" text, "pinData" text, "versionId" varchar(36), "triggerCount" integer NOT NULL DEFAULT 0);`); await queryRunner.query( `INSERT INTO "${tablePrefix}TMP_workflow_entity" (id, name, active, nodes, connections, createdAt, updatedAt, settings, staticData, pinData, triggerCount, versionId) SELECT id, name, active, nodes, connections, createdAt, updatedAt, settings, staticData, pinData, triggerCount, versionId FROM "${tablePrefix}workflow_entity";`, ); await queryRunner.query(`DROP TABLE "${tablePrefix}workflow_entity";`); - await queryRunner.query(`ALTER TABLE "${tablePrefix}TMP_workflow_entity" RENAME TO "${tablePrefix}workflow_entity"; -`); + await queryRunner.query( + `ALTER TABLE "${tablePrefix}TMP_workflow_entity" RENAME TO "${tablePrefix}workflow_entity"`, + ); await queryRunner.query(` -CREATE TABLE "${tablePrefix}TMP_tag_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')));`); + CREATE TABLE "${tablePrefix}TMP_tag_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')));`); await queryRunner.query( `INSERT INTO "${tablePrefix}TMP_tag_entity" SELECT * FROM "${tablePrefix}tag_entity";`, ); @@ -22,7 +25,7 @@ CREATE TABLE "${tablePrefix}TMP_tag_entity" ("id" varchar(36) PRIMARY KEY NOT NU ); await queryRunner.query(` -CREATE TABLE "${tablePrefix}TMP_workflows_tags" ("workflowId" varchar(36) NOT NULL, "tagId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}workflows_tags_workflow_entity" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}workflows_tags_tag_entity" FOREIGN KEY ("tagId") REFERENCES "${tablePrefix}tag_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("workflowId", "tagId"));`); + CREATE TABLE "${tablePrefix}TMP_workflows_tags" ("workflowId" varchar(36) NOT NULL, "tagId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}workflows_tags_workflow_entity" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefix}workflows_tags_tag_entity" FOREIGN KEY ("tagId") REFERENCES "${tablePrefix}tag_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("workflowId", "tagId"));`); await queryRunner.query( `INSERT INTO "${tablePrefix}TMP_workflows_tags" SELECT * FROM "${tablePrefix}workflows_tags";`, ); diff --git a/packages/cli/src/databases/types.ts b/packages/cli/src/databases/types.ts index 19d4813163..ada0268756 100644 --- a/packages/cli/src/databases/types.ts +++ b/packages/cli/src/databases/types.ts @@ -12,15 +12,19 @@ export interface MigrationContext { migrationName: string; } -type MigrationFn = (ctx: MigrationContext) => Promise; +export type MigrationFn = (ctx: MigrationContext) => Promise; -export interface ReversibleMigration { +export interface BaseMigration { up: MigrationFn; + down?: MigrationFn | never; + transaction?: false; +} + +export interface ReversibleMigration extends BaseMigration { down: MigrationFn; } -export interface IrreversibleMigration { - up: MigrationFn; +export interface IrreversibleMigration extends BaseMigration { down?: never; } diff --git a/packages/cli/src/databases/utils/migrationHelpers.ts b/packages/cli/src/databases/utils/migrationHelpers.ts index a1f8bf223f..5c1a91763e 100644 --- a/packages/cli/src/databases/utils/migrationHelpers.ts +++ b/packages/cli/src/databases/utils/migrationHelpers.ts @@ -1,11 +1,10 @@ -/* eslint-disable no-await-in-loop */ import { readFileSync, rmSync } from 'fs'; import { UserSettings } from 'n8n-core'; import type { QueryRunner } from 'typeorm/query-runner/QueryRunner'; import config from '@/config'; import { getLogger } from '@/Logger'; import { inTest } from '@/constants'; -import type { Migration, MigrationContext } from '@db/types'; +import type { BaseMigration, Migration, MigrationContext, MigrationFn } from '@db/types'; const logger = getLogger(); @@ -39,30 +38,47 @@ export function loadSurveyFromDisk(): string | null { } } -let logFinishTimeout: NodeJS.Timeout; +let runningMigrations = false; -export function logMigrationStart(migrationName: string, disableLogging = inTest): void { - if (disableLogging) return; +function logMigrationStart(migrationName: string): void { + if (inTest) return; - if (!logFinishTimeout) { + if (!runningMigrations) { logger.warn('Migrations in progress, please do NOT stop the process.'); + runningMigrations = true; } logger.debug(`Starting migration ${migrationName}`); - - clearTimeout(logFinishTimeout); } -export function logMigrationEnd(migrationName: string, disableLogging = inTest): void { - if (disableLogging) return; +function logMigrationEnd(migrationName: string): void { + if (inTest) return; logger.debug(`Finished migration ${migrationName}`); - - logFinishTimeout = setTimeout(() => { - logger.warn('Migrations finished.'); - }, 100); } +const runDisablingForeignKeys = async ( + migration: BaseMigration, + context: MigrationContext, + fn: MigrationFn, +) => { + const { dbType, queryRunner } = context; + if (dbType !== 'sqlite') throw new Error('Disabling transactions only available in sqlite'); + await queryRunner.query('PRAGMA foreign_keys=OFF'); + await queryRunner.startTransaction(); + try { + await fn.call(migration, context); + await queryRunner.commitTransaction(); + } catch (e) { + try { + await queryRunner.rollbackTransaction(); + } catch {} + throw e; + } finally { + await queryRunner.query('PRAGMA foreign_keys=ON'); + } +}; + export const wrapMigration = (migration: Migration) => { const dbType = config.getEnv('database.type'); const dbName = config.getEnv(`database.${dbType === 'mariadb' ? 'mysqldb' : dbType}.database`); @@ -78,13 +94,23 @@ export const wrapMigration = (migration: Migration) => { const { up, down } = migration.prototype; Object.assign(migration.prototype, { - async up(queryRunner: QueryRunner) { + async up(this: BaseMigration, queryRunner: QueryRunner) { logMigrationStart(migrationName); - await up.call(this, { queryRunner, ...context }); + if (!this.transaction) { + await runDisablingForeignKeys(this, { queryRunner, ...context }, up); + } else { + await up.call(this, { queryRunner, ...context }); + } logMigrationEnd(migrationName); }, - async down(queryRunner: QueryRunner) { - await down?.call(this, { queryRunner, ...context }); + async down(this: BaseMigration, queryRunner: QueryRunner) { + if (down) { + if (!this.transaction) { + await runDisablingForeignKeys(this, { queryRunner, ...context }, up); + } else { + await down.call(this, { queryRunner, ...context }); + } + } }, }); }; diff --git a/patches/typeorm@0.3.12.patch b/patches/typeorm@0.3.12.patch deleted file mode 100644 index 8b09c80421..0000000000 --- a/patches/typeorm@0.3.12.patch +++ /dev/null @@ -1,31 +0,0 @@ -diff --git a/migration/MigrationExecutor.js b/migration/MigrationExecutor.js -index 5d37b9cf9ca2505242f05160f05ff683e00c1e5d..4a768819f86b8f176bd3b826a649afe54ab39598 100644 ---- a/migration/MigrationExecutor.js -+++ b/migration/MigrationExecutor.js -@@ -216,15 +216,17 @@ class MigrationExecutor { - // nothing else needs to be done, continue to next migration - continue; - } -+ await queryRunner.beforeMigration(); - if (migration.transaction && !queryRunner.isTransactionActive) { - await queryRunner.startTransaction(); - transactionStartedByUs = true; - } - await migration - .instance.up(queryRunner) -- .catch((error) => { -+ .catch(async (error) => { - // informative log about migration failure - this.connection.logger.logMigration(`Migration "${migration.name}" failed, error: ${error === null || error === void 0 ? void 0 : error.message}`); -+ await queryRunner.afterMigration(queryRunner); - throw error; - }) - .then(async () => { -@@ -233,6 +235,7 @@ class MigrationExecutor { - // commit transaction if we started it - if (migration.transaction && transactionStartedByUs) - await queryRunner.commitTransaction(); -+ await queryRunner.afterMigration(queryRunner); - }) - .then(() => { - // informative log about migration success \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd69c2452..cb4a966acd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,6 @@ patchedDependencies: typedi@0.10.0: hash: 62r6bc2crgimafeyruodhqlgo4 path: patches/typedi@0.10.0.patch - typeorm@0.3.12: - hash: yav7zi22hnry26k2lwg6jcumde - path: patches/typeorm@0.3.12.patch importers: @@ -442,7 +439,7 @@ importers: version: 0.10.0(patch_hash=62r6bc2crgimafeyruodhqlgo4) typeorm: specifier: ^0.3.12 - version: 0.3.12(patch_hash=yav7zi22hnry26k2lwg6jcumde)(ioredis@5.2.4)(mysql2@2.3.3)(pg@8.8.0)(sqlite3@5.1.6) + version: 0.3.12(ioredis@5.2.4)(mysql2@2.3.3)(pg@8.8.0)(sqlite3@5.1.6) uuid: specifier: ^8.3.2 version: 8.3.2 @@ -21388,7 +21385,7 @@ packages: dev: false patched: true - /typeorm@0.3.12(patch_hash=yav7zi22hnry26k2lwg6jcumde)(ioredis@5.2.4)(mysql2@2.3.3)(pg@8.8.0)(sqlite3@5.1.6): + /typeorm@0.3.12(ioredis@5.2.4)(mysql2@2.3.3)(pg@8.8.0)(sqlite3@5.1.6): resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==} engines: {node: '>= 12.9.0'} hasBin: true @@ -21470,7 +21467,6 @@ packages: transitivePeerDependencies: - supports-color dev: false - patched: true /typescript@5.1.3: resolution: {integrity: sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==} From 7ffe3cb36adeecaca6cc6ddf067a701ee55c18d1 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 5 Jul 2023 11:37:34 +0100 Subject: [PATCH 42/46] fix(Salesforce Node): Fix typo for adding a contact to a campaign (#6598) --- packages/nodes-base/nodes/Salesforce/ContactDescription.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nodes-base/nodes/Salesforce/ContactDescription.ts b/packages/nodes-base/nodes/Salesforce/ContactDescription.ts index 0a38facab3..39680eaab2 100644 --- a/packages/nodes-base/nodes/Salesforce/ContactDescription.ts +++ b/packages/nodes-base/nodes/Salesforce/ContactDescription.ts @@ -13,10 +13,10 @@ export const contactOperations: INodeProperties[] = [ }, options: [ { - name: 'Add Lead To Campaign', + name: 'Add Contact To Campaign', value: 'addToCampaign', - description: 'Add lead to a campaign', - action: 'Add a lead to a campaign', + description: 'Add contact to a campaign', + action: 'Add a contact to a campaign', }, { name: 'Add Note', From 76aca62dd35027450f5e9272db856a3eac07f0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 5 Jul 2023 12:57:50 +0200 Subject: [PATCH 43/46] refactor: Switch cloud plan retrieval endpoint (no-changelog) (#6529) * refactor: Switch cloud plan retrieval endpoint * refactor: Remove user ID from request * refactor: Update endpoint * refactor: Update to /rest/admin/cloud-plan * refactor: Remove redundant `/rest` segment --- packages/editor-ui/src/api/cloudPlans.ts | 7 ++----- packages/editor-ui/src/stores/cloudPlan.store.ts | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/editor-ui/src/api/cloudPlans.ts b/packages/editor-ui/src/api/cloudPlans.ts index 27dc6cc7ed..e0d15299dc 100644 --- a/packages/editor-ui/src/api/cloudPlans.ts +++ b/packages/editor-ui/src/api/cloudPlans.ts @@ -1,11 +1,8 @@ import type { Cloud, IRestApiContext, InstanceUsage } from '@/Interface'; import { get } from '@/utils'; -export async function getCurrentPlan( - context: IRestApiContext, - cloudUserId: string, -): Promise { - return get(context.baseUrl, `/user/${cloudUserId}/plan`); +export async function getCurrentPlan(context: IRestApiContext): Promise { + return get(context.baseUrl, '/admin/cloud-plan'); } export async function getCurrentUsage(context: IRestApiContext): Promise { diff --git a/packages/editor-ui/src/stores/cloudPlan.store.ts b/packages/editor-ui/src/stores/cloudPlan.store.ts index 6bf6465d9c..38194ee15a 100644 --- a/packages/editor-ui/src/stores/cloudPlan.store.ts +++ b/packages/editor-ui/src/stores/cloudPlan.store.ts @@ -53,7 +53,7 @@ export const useCloudPlanStore = defineStore('cloudPlan', () => { state.loadingPlan = true; let plan; try { - plan = await getCurrentPlan(rootStore.getRestCloudApiContext, `${cloudUserId}`); + plan = await getCurrentPlan(rootStore.getRestApiContext); state.data = plan; state.loadingPlan = false; } catch (error) { From 31cba87d307183d613890c7e6d627636b5280b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 5 Jul 2023 13:46:42 +0200 Subject: [PATCH 44/46] fix(core): Improve the performance of last 2 sqlite migrations (#6522) --- ...690000000002-MigrateIntegerKeysToString.ts | 57 +++++++++++++++++-- .../1690000000010-SeparateExecutionData.ts | 14 ++--- .../src/databases/utils/migrationHelpers.ts | 31 ++++++++++ 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts b/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts index 80e8dcaed6..d3ea972cce 100644 --- a/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts +++ b/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts @@ -1,9 +1,19 @@ +import { statSync } from 'fs'; +import path from 'path'; +import { UserSettings } from 'n8n-core'; import type { MigrationContext, IrreversibleMigration } from '@db/types'; +import config from '@/config'; +import { copyTable } from '@/databases/utils/migrationHelpers'; export class MigrateIntegerKeysToString1690000000002 implements IrreversibleMigration { transaction = false as const; - async up({ queryRunner, tablePrefix }: MigrationContext) { + async up(context: MigrationContext) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + await pruneExecutionsData(context); + + const { queryRunner, tablePrefix } = context; + await queryRunner.query(` CREATE TABLE "${tablePrefix}TMP_workflow_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text, "connections" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "settings" text, "staticData" text, "pinData" text, "versionId" varchar(36), "triggerCount" integer NOT NULL DEFAULT 0);`); await queryRunner.query( @@ -108,9 +118,7 @@ export class MigrateIntegerKeysToString1690000000002 implements IrreversibleMigr "data" text NOT NULL, "status" varchar, FOREIGN KEY("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE );`); - await queryRunner.query( - `INSERT INTO "${tablePrefix}TMP_execution_entity" SELECT * FROM "${tablePrefix}execution_entity";`, - ); + await copyTable({ tablePrefix, queryRunner }, 'execution_entity', 'TMP_execution_entity'); await queryRunner.query(`DROP TABLE "${tablePrefix}execution_entity";`); await queryRunner.query( `ALTER TABLE "${tablePrefix}TMP_execution_entity" RENAME TO "${tablePrefix}execution_entity";`, @@ -178,3 +186,44 @@ export class MigrateIntegerKeysToString1690000000002 implements IrreversibleMigr ); } } + +const DESIRED_DATABASE_FILE_SIZE = 1 * 1024 * 1024 * 1024; // 1 GB +const migrationsPruningEnabled = process.env.MIGRATIONS_PRUNING_ENABLED === 'true'; + +function getSqliteDbFileSize(): number { + const filename = path.resolve( + UserSettings.getUserN8nFolderPath(), + config.getEnv('database.sqlite.database'), + ); + const { size } = statSync(filename); + return size; +} + +const pruneExecutionsData = async ({ queryRunner, tablePrefix }: MigrationContext) => { + if (migrationsPruningEnabled) { + const dbFileSize = getSqliteDbFileSize(); + if (dbFileSize < DESIRED_DATABASE_FILE_SIZE) { + console.log(`DB Size not large enough to prune: ${dbFileSize}`); + return; + } + + console.time('pruningData'); + const counting = (await queryRunner.query( + `select count(id) as rows from "${tablePrefix}execution_entity";`, + )) as Array<{ rows: number }>; + + const averageExecutionSize = dbFileSize / counting[0].rows; + const numberOfExecutionsToKeep = Math.floor(DESIRED_DATABASE_FILE_SIZE / averageExecutionSize); + + const query = `SELECT id FROM "${tablePrefix}execution_entity" ORDER BY id DESC limit ${numberOfExecutionsToKeep}, 1`; + const idToKeep = await queryRunner + .query(query) + .then((rows: Array<{ id: number }>) => rows[0].id); + + const removalQuery = `DELETE FROM "${tablePrefix}execution_entity" WHERE id < ${idToKeep} and status IN ('success')`; + await queryRunner.query(removalQuery); + console.timeEnd('pruningData'); + } else { + console.log('Pruning was requested, but was not enabled'); + } +}; diff --git a/packages/cli/src/databases/migrations/sqlite/1690000000010-SeparateExecutionData.ts b/packages/cli/src/databases/migrations/sqlite/1690000000010-SeparateExecutionData.ts index 3d8943a7a6..55f3e74687 100644 --- a/packages/cli/src/databases/migrations/sqlite/1690000000010-SeparateExecutionData.ts +++ b/packages/cli/src/databases/migrations/sqlite/1690000000010-SeparateExecutionData.ts @@ -1,4 +1,5 @@ import type { MigrationContext, ReversibleMigration } from '@/databases/types'; +import { copyTable } from '@/databases/utils/migrationHelpers'; export class SeparateExecutionData1690000000010 implements ReversibleMigration { async up({ queryRunner, tablePrefix }: MigrationContext): Promise { @@ -11,13 +12,12 @@ export class SeparateExecutionData1690000000010 implements ReversibleMigration { )`, ); - await queryRunner.query( - `INSERT INTO "${tablePrefix}execution_data" ( - "executionId", - "workflowData", - "data") - SELECT "id", "workflowData", "data" FROM "${tablePrefix}execution_entity" - `, + await copyTable( + { tablePrefix, queryRunner }, + 'execution_entity', + 'execution_data', + ['id', 'workflowData', 'data'], + ['executionId', 'workflowData', 'data'], ); await queryRunner.query( diff --git a/packages/cli/src/databases/utils/migrationHelpers.ts b/packages/cli/src/databases/utils/migrationHelpers.ts index 5c1a91763e..bacfcfc315 100644 --- a/packages/cli/src/databases/utils/migrationHelpers.ts +++ b/packages/cli/src/databases/utils/migrationHelpers.ts @@ -115,6 +115,37 @@ export const wrapMigration = (migration: Migration) => { }); }; +export const copyTable = async ( + { tablePrefix, queryRunner }: Pick, + fromTable: string, + toTable: string, + fromFields: string[] = [], + toFields: string[] = [], + batchSize = 10, +) => { + const driver = queryRunner.connection.driver; + fromTable = driver.escape(`${tablePrefix}${fromTable}`); + toTable = driver.escape(`${tablePrefix}${toTable}`); + const fromFieldsStr = fromFields.length + ? fromFields.map((f) => driver.escape(f)).join(', ') + : '*'; + const toFieldsStr = toFields.length + ? `(${toFields.map((f) => driver.escape(f)).join(', ')})` + : ''; + + const total = await queryRunner + .query(`SELECT COUNT(*) as count from ${fromTable}`) + .then((rows: Array<{ count: number }>) => rows[0].count); + + let migrated = 0; + while (migrated < total) { + await queryRunner.query( + `INSERT INTO ${toTable} ${toFieldsStr} SELECT ${fromFieldsStr} FROM ${fromTable} LIMIT ${migrated}, ${batchSize}`, + ); + migrated += batchSize; + } +}; + function batchQuery(query: string, limit: number, offset = 0): string { return ` ${query} From 282e6aedb36571b78100964cc6586b1498bf8deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 5 Jul 2023 14:40:35 +0200 Subject: [PATCH 45/46] fix: Fix migrations for postgres and mysql (no-changelog) (#6600) fix: Fix migrations for postgres and mysql --- packages/cli/src/databases/utils/migrationHelpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/databases/utils/migrationHelpers.ts b/packages/cli/src/databases/utils/migrationHelpers.ts index bacfcfc315..bbb626395c 100644 --- a/packages/cli/src/databases/utils/migrationHelpers.ts +++ b/packages/cli/src/databases/utils/migrationHelpers.ts @@ -96,7 +96,7 @@ export const wrapMigration = (migration: Migration) => { Object.assign(migration.prototype, { async up(this: BaseMigration, queryRunner: QueryRunner) { logMigrationStart(migrationName); - if (!this.transaction) { + if (this.transaction === false) { await runDisablingForeignKeys(this, { queryRunner, ...context }, up); } else { await up.call(this, { queryRunner, ...context }); @@ -105,7 +105,7 @@ export const wrapMigration = (migration: Migration) => { }, async down(this: BaseMigration, queryRunner: QueryRunner) { if (down) { - if (!this.transaction) { + if (this.transaction === false) { await runDisablingForeignKeys(this, { queryRunner, ...context }, up); } else { await down.call(this, { queryRunner, ...context }); From dd0fe2a7ab6d2e5f690a7bf67473f3932715ce2c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Jul 2023 15:34:52 +0200 Subject: [PATCH 46/46] :rocket: Release 0.236.0 (#6602) Co-authored-by: netroy --- CHANGELOG.md | 29 ++++++++++++++++++++++++ package.json | 2 +- packages/@n8n/client-oauth2/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/design-system/package.json | 2 +- packages/editor-ui/package.json | 2 +- packages/node-dev/package.json | 2 +- packages/nodes-base/package.json | 2 +- packages/workflow/package.json | 2 +- 10 files changed, 38 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 269e98ea45..ed4eea94d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +# [0.236.0](https://github.com/n8n-io/n8n/compare/n8n@0.235.0...n8n@0.236.0) (2023-07-05) + + +### Bug Fixes + +* **Brevo Node:** Rename SendInBlue node to Brevo node ([#6521](https://github.com/n8n-io/n8n/issues/6521)) ([e63b398](https://github.com/n8n-io/n8n/commit/e63b3982d200ade34461b9159eb1e988f494c025)) +* **core:** Fix credentials test ([#6569](https://github.com/n8n-io/n8n/issues/6569)) ([1abd172](https://github.com/n8n-io/n8n/commit/1abd172f73e171e37c4cc3ccfaa395c6a46bdf48)) +* **core:** Fix migrations for MySQL/MariaDB ([#6591](https://github.com/n8n-io/n8n/issues/6591)) ([29882a6](https://github.com/n8n-io/n8n/commit/29882a6f39dddcd1c8c107c20a548ce8dc665cba)) +* **core:** Improve the performance of last 2 sqlite migrations ([#6522](https://github.com/n8n-io/n8n/issues/6522)) ([31cba87](https://github.com/n8n-io/n8n/commit/31cba87d307183d613890c7e6d627636b5280b52)) +* **core:** Remove typeorm patches, but still enforce transactions on every migration ([#6594](https://github.com/n8n-io/n8n/issues/6594)) ([9def7a7](https://github.com/n8n-io/n8n/commit/9def7a729b52cd6b4698c47e190e9e2bd7894da5)), closes [#6519](https://github.com/n8n-io/n8n/issues/6519) +* **core:** Use owners file to export wf owners ([#6547](https://github.com/n8n-io/n8n/issues/6547)) ([4b755fb](https://github.com/n8n-io/n8n/commit/4b755fb0b441a37eb804c9e70d4b071a341f7155)) +* **editor:** Show retry information in execution list only when it exists ([#6587](https://github.com/n8n-io/n8n/issues/6587)) ([3ca66be](https://github.com/n8n-io/n8n/commit/3ca66be38082e7a3866d53d07328be58e913067f)) +* **Salesforce Node:** Fix typo for adding a contact to a campaign ([#6598](https://github.com/n8n-io/n8n/issues/6598)) ([7ffe3cb](https://github.com/n8n-io/n8n/commit/7ffe3cb36adeecaca6cc6ddf067a701ee55c18d1)) +* **Strapi Node:** Fix issue with pagination ([#4991](https://github.com/n8n-io/n8n/issues/4991)) ([54444fa](https://github.com/n8n-io/n8n/commit/54444fa388da12d75553e66e53a8cf6f8a99b6fc)) +* **XML Node:** Fix issue with not returning valid data ([#6565](https://github.com/n8n-io/n8n/issues/6565)) ([cdd215f](https://github.com/n8n-io/n8n/commit/cdd215f642b47413c05f229e641074d0d4048f68)) + + +### Features + +* Add crowd.dev node and trigger node ([#6082](https://github.com/n8n-io/n8n/issues/6082)) ([238a78f](https://github.com/n8n-io/n8n/commit/238a78f0582dbf439a9799de0edcb2e9bef29978)) +* Add various source control improvements ([#6533](https://github.com/n8n-io/n8n/issues/6533)) ([68fdc20](https://github.com/n8n-io/n8n/commit/68fdc2078928be478a286774f2889feba1c3f5fe)) +* **HTTP Request Node:** New http request generic custom auth credential ([#5798](https://github.com/n8n-io/n8n/issues/5798)) ([b17b458](https://github.com/n8n-io/n8n/commit/b17b4582a059104665888a2369c3e2256db4c1ed)) +* **Microsoft To Do Node:** Add an option to set a reminder when creating a task ([#5757](https://github.com/n8n-io/n8n/issues/5757)) ([b19833d](https://github.com/n8n-io/n8n/commit/b19833d673bd554ba86c0b234e8d13633912563a)) +* **Notion Node:** Add option to update icon when updating a page ([#5670](https://github.com/n8n-io/n8n/issues/5670)) ([225e849](https://github.com/n8n-io/n8n/commit/225e849960ce65d7f85b482f05fb3d7ffb4f9427)) +* **Strava Node:** Add hide_from_home field in Activity Update ([#5883](https://github.com/n8n-io/n8n/issues/5883)) ([7495e31](https://github.com/n8n-io/n8n/commit/7495e31a5b25e97683c7ea38225ba253d8fae8b7)) +* **Twitter Node:** Node overhaul ([#4788](https://github.com/n8n-io/n8n/issues/4788)) ([42721db](https://github.com/n8n-io/n8n/commit/42721dba80077fb796086a2bf0ecce256bf3a50f)) + + + # [0.235.0](https://github.com/n8n-io/n8n/compare/n8n@0.234.0...n8n@0.235.0) (2023-06-28) diff --git a/package.json b/package.json index 1ef2a9be0f..6cfc9679ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.235.0", + "version": "0.236.0", "private": true, "homepage": "https://n8n.io", "engines": { diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index 20cfdbdcfb..b3acee86b2 100644 --- a/packages/@n8n/client-oauth2/package.json +++ b/packages/@n8n/client-oauth2/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/client-oauth2", - "version": "0.3.0", + "version": "0.4.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/cli/package.json b/packages/cli/package.json index 32bb56c577..b93e31d014 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.235.0", + "version": "0.236.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/core/package.json b/packages/core/package.json index 331d110463..04e38d3fba 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.174.0", + "version": "0.175.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 4010a05b9b..51fdc8c877 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "0.69.0", + "version": "0.70.0", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", "author": { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 530976b463..b84c950008 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.201.0", + "version": "0.202.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index b9b8126e1c..6ead7f28e6 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "0.113.0", + "version": "0.114.0", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 2c17f69f9a..006026b108 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.233.0", + "version": "0.234.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/workflow/package.json b/packages/workflow/package.json index a1ce633088..34129708a1 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "0.155.0", + "version": "0.156.0", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io",