From ff8dd4e604216203800d9b12fd5f1105356cf03e Mon Sep 17 00:00:00 2001 From: Bram Kn Date: Tue, 19 Mar 2024 15:52:45 +0100 Subject: [PATCH] feat: Add Onedrive Trigger Node (#8742) Co-authored-by: Giulio Andreini Co-authored-by: Marcus --- .../Microsoft/OneDrive/GenericFunctions.ts | 82 ++++- .../MicrosoftOneDriveTrigger.node.json | 25 ++ .../OneDrive/MicrosoftOneDriveTrigger.node.ts | 173 +++++++++++ .../Microsoft/OneDrive/TriggerDescription.ts | 288 ++++++++++++++++++ .../nodes/Microsoft/OneDrive/constants.ts | 5 + packages/nodes-base/package.json | 1 + 6 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.json create mode 100644 packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Microsoft/OneDrive/TriggerDescription.ts create mode 100644 packages/nodes-base/nodes/Microsoft/OneDrive/constants.ts diff --git a/packages/nodes-base/nodes/Microsoft/OneDrive/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/OneDrive/GenericFunctions.ts index cb88bd0a5b..b709cb0e68 100644 --- a/packages/nodes-base/nodes/Microsoft/OneDrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/OneDrive/GenericFunctions.ts @@ -5,11 +5,13 @@ import type { JsonObject, IHttpRequestMethods, IRequestOptions, + IPollFunctions, } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; +import { DateTime } from 'luxon'; export async function microsoftApiRequest( - this: IExecuteFunctions | ILoadOptionsFunctions, + this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, method: IHttpRequestMethods, resource: string, @@ -47,7 +49,7 @@ export async function microsoftApiRequest( } export async function microsoftApiRequestAllItems( - this: IExecuteFunctions | ILoadOptionsFunctions, + this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, propertyName: string, method: IHttpRequestMethods, endpoint: string, @@ -74,7 +76,7 @@ export async function microsoftApiRequestAllItems( } export async function microsoftApiRequestAllItemsSkip( - this: IExecuteFunctions | ILoadOptionsFunctions, + this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, propertyName: string, method: IHttpRequestMethods, endpoint: string, @@ -96,3 +98,77 @@ export async function microsoftApiRequestAllItemsSkip( return returnData; } + +export async function microsoftApiRequestAllItemsDelta( + this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, + link: string, + lastDate: DateTime, + eventType: string, +): Promise { + const returnData: IDataObject[] = []; + + let responseData; + let deltaLink: string = ''; + let uri: string = link; + + do { + responseData = (await microsoftApiRequest.call(this, 'GET', '', {}, {}, uri)) as IDataObject; + uri = responseData['@odata.nextLink'] as string; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + for (const value of responseData.value as IDataObject[]) { + if (value.fileSystemInfo as IDataObject) { + const updatedTimeStamp = (value.fileSystemInfo as IDataObject) + ?.lastModifiedDateTime as string; + const createdTimeStamp = (value.fileSystemInfo as IDataObject)?.createdDateTime as string; + if (eventType === 'created') { + if (DateTime.fromISO(createdTimeStamp) >= lastDate) { + returnData.push(value); + } + } + if (eventType === 'updated') { + if ( + DateTime.fromISO(updatedTimeStamp) >= lastDate && + DateTime.fromISO(createdTimeStamp) < lastDate + ) { + returnData.push(value); + } + } + } + } + //returnData.push.apply(returnData, responseData.value as IDataObject[]); + deltaLink = (responseData['@odata.deltaLink'] as string) || ''; + } while (responseData['@odata.nextLink'] !== undefined); + + return { deltaLink, returnData }; +} + +export async function getPath( + this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, + itemId: string, +): Promise { + const responseData = (await microsoftApiRequest.call( + this, + 'GET', + '', + {}, + {}, + `https://graph.microsoft.com/v1.0/me/drive/items/${itemId}`, + )) as IDataObject; + if (responseData.folder) { + return (responseData?.parentReference as IDataObject)?.path + `/${responseData?.name}`; + } else { + const workflow = this.getWorkflow(); + const node = this.getNode(); + this.logger.error( + `There was a problem in '${node.name}' node in workflow '${workflow.id}': 'Item to watch is not a folder'`, + { + node: node.name, + workflowId: workflow.id, + error: 'Item to watch is not a folder', + }, + ); + throw new NodeApiError(this.getNode(), { + error: 'Item to watch is not a folder', + } as JsonObject); + } +} diff --git a/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.json b/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.json new file mode 100644 index 0000000000..34d750416a --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.json @@ -0,0 +1,25 @@ +{ + "node": "n8n-nodes-base.microsoftOneDriveTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Data & Storage"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/microsoft" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.microsoftonedrivetrigger/" + } + ], + "generic": [ + { + "label": "Hey founders! Your business doesn't need you to operate", + "icon": " 🖥️", + "url": "https://n8n.io/blog/your-business-doesnt-need-you-to-operate/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.ts b/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.ts new file mode 100644 index 0000000000..111b7b5146 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.ts @@ -0,0 +1,173 @@ +import type { + IPollFunctions, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { DateTime } from 'luxon'; +import { triggerDescription } from './TriggerDescription'; +import { getPath, microsoftApiRequest, microsoftApiRequestAllItemsDelta } from './GenericFunctions'; + +export class MicrosoftOneDriveTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Microsoft OneDrive Trigger', + name: 'microsoftOneDriveTrigger', + icon: 'file:oneDrive.svg', + group: ['trigger'], + version: 1, + description: 'Trigger for Microsoft OneDrive API.', + subtitle: '={{($parameter["event"])}}', + defaults: { + name: 'Microsoft OneDrive Trigger', + }, + credentials: [ + { + name: 'microsoftOneDriveOAuth2Api', + required: true, + }, + ], + polling: true, + inputs: [], + outputs: ['main'], + properties: [...triggerDescription], + }; + + methods = { + loadOptions: {}, + }; + + async poll(this: IPollFunctions): Promise { + const workflowData = this.getWorkflowStaticData('node'); + let responseData: IDataObject[]; + + const lastLink: string = + (workflowData.LastLink as string) || + 'https://graph.microsoft.com/v1.0/me/drive/root/delta?token=latest'; + + const now = DateTime.now().toUTC(); + const start = DateTime.fromISO(workflowData.lastTimeChecked as string) || now; + const end = now; + const event = this.getNodeParameter('event', 'fileCreated') as string; + const watch = this.getNodeParameter('watch', 'anyFile') as string; + const watchFolder = (this.getNodeParameter('watchFolder', false) as boolean) || false; + const folderChild = (this.getNodeParameter('options.folderChild', false) as boolean) || false; + + let eventType = 'created'; + let eventResource = 'file'; + if (event.includes('Updated')) { + eventType = 'updated'; + } + if (event.includes('folder')) { + eventResource = 'folder'; + } + try { + if (this.getMode() === 'manual') { + responseData = ( + await microsoftApiRequest.call( + this, + 'GET', + '', + {}, + {}, + 'https://graph.microsoft.com/v1.0/me/drive/root/delta', + ) + ).value as IDataObject[]; + } else { + const response: IDataObject = (await microsoftApiRequestAllItemsDelta.call( + this, + lastLink, + start, + eventType, + )) as IDataObject; + responseData = response.returnData as IDataObject[]; + workflowData.LastLink = response.deltaLink; + } + + workflowData.lastTimeChecked = end.toISO(); + if (watch === 'selectedFile') { + const fileId = ( + this.getNodeParameter('fileId', '', { + extractValue: true, + }) as string + ).replace('%21', '!'); + if (fileId) { + responseData = responseData.filter((item: IDataObject) => item.id === fileId); + } + } + + if ( + !folderChild && + (watch === 'oneSelectedFolder' || watch === 'selectedFolder' || watchFolder) + ) { + const folderId = ( + this.getNodeParameter('folderId', '', { + extractValue: true, + }) as string + ).replace('%21', '!'); + if (folderId) { + if (watch === 'oneSelectedFolder') { + responseData = responseData.filter((item: IDataObject) => item.id === folderId); + } else { + responseData = responseData.filter( + (item: IDataObject) => (item.parentReference as IDataObject).id === folderId, + ); + } + } + } + if (folderChild && (watch === 'selectedFolder' || watchFolder)) { + const folderId = ( + this.getNodeParameter('folderId', '', { + extractValue: true, + }) as string + ).replace('%21', '!'); + const folderPath = await getPath.call(this, folderId); + responseData = responseData.filter((item: IDataObject) => + ((item.parentReference as IDataObject).path as string).startsWith(folderPath), + ); + } + responseData = responseData.filter((item: IDataObject) => item[eventResource]); + if (!responseData?.length) { + return null; + } + + const simplify = this.getNodeParameter('simple') as boolean; + if (simplify) { + responseData = responseData.map((x) => ({ + id: x.id, + createdDateTime: (x.fileSystemInfo as IDataObject)?.createdDateTime, + lastModifiedDateTime: (x.fileSystemInfo as IDataObject)?.lastModifiedDateTime, + name: x.name, + webUrl: x.webUrl, + size: x.size, + path: (x.parentReference as IDataObject)?.path || '', + mimeType: (x.file as IDataObject)?.mimeType || '', + })); + } + + if (this.getMode() === 'manual') { + return [this.helpers.returnJsonArray(responseData[0])]; + } else { + return [this.helpers.returnJsonArray(responseData)]; + } + } catch (error) { + if (this.getMode() === 'manual' || !workflowData.lastTimeChecked) { + throw error; + } + const workflow = this.getWorkflow(); + const node = this.getNode(); + this.logger.error( + `There was a problem in '${node.name}' node in workflow '${workflow.id}': '${error.description}'`, + { + node: node.name, + workflowId: workflow.id, + error, + }, + ); + throw error; + } + + return null; + } +} diff --git a/packages/nodes-base/nodes/Microsoft/OneDrive/TriggerDescription.ts b/packages/nodes-base/nodes/Microsoft/OneDrive/TriggerDescription.ts new file mode 100644 index 0000000000..0a1a713b3d --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/OneDrive/TriggerDescription.ts @@ -0,0 +1,288 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { MICROSOFT_DRIVE_FILE_URL_REGEX, MICROSOFT_DRIVE_FOLDER_URL_REGEX } from './constants'; + +export const fileRLC: INodeProperties = { + displayName: 'File', + name: 'fileId', + type: 'resourceLocator', + default: { mode: 'id', value: '' }, + required: true, + modes: [ + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: + 'e.g. https://onedrive.live.com/edit.aspx?resid=170B5C65E30736A3!257&cid=170b5c65e30736a3&CT=1708697995542&OR=ItemsView', + extractValue: { + type: 'regex', + regex: MICROSOFT_DRIVE_FILE_URL_REGEX, + }, + validation: [ + { + type: 'regex', + properties: { + regex: MICROSOFT_DRIVE_FILE_URL_REGEX, + errorMessage: 'Not a valid Microsoft Drive File URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 170B5C65E30736A3!257', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\!%21]{5,}', + errorMessage: 'Not a valid Microsoft Drive File ID', + }, + }, + ], + url: '=https://onedrive.live.com/?id={{$value}}', + }, + ], + description: + "The file to operate on. The 'By URL' option only accepts URLs that start with 'https://onedrive.live.com'.", +}; + +export const folderRLC: INodeProperties = { + displayName: 'Folder', + name: 'folderId', + type: 'resourceLocator', + default: { mode: 'id', value: '', cachedResultName: '' }, + required: true, + modes: [ + { + displayName: 'Link', + name: 'url', + type: 'string', + placeholder: 'e.g. https://onedrive.live.com/?id=170B5C65E30736A3%21103&cid=170B5C65E30736A3', + extractValue: { + type: 'regex', + regex: MICROSOFT_DRIVE_FOLDER_URL_REGEX, + }, + validation: [ + { + type: 'regex', + properties: { + regex: MICROSOFT_DRIVE_FOLDER_URL_REGEX, + errorMessage: 'Not a valid Microsoft Drive Folder URL', + }, + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 170B5C65E30736A3%21136', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9\\!%21]{5,}', + errorMessage: 'Not a valid Microsoft Drive Folder ID', + }, + }, + ], + url: '=https://onedrive.live.com/?id={{$value}}', + }, + ], + description: + "The folder to operate on. The 'By URL' option only accepts URLs that start with 'https://onedrive.live.com'.", +}; + +export const triggerDescription: INodeProperties[] = [ + { + displayName: 'Trigger On', + name: 'event', + type: 'options', + default: 'fileCreated', + options: [ + { + name: 'File Created', + value: 'fileCreated', + description: 'When a new file is created', + }, + { + name: 'File Updated', + value: 'fileUpdated', + description: 'When an existing file is modified', + }, + { + name: 'Folder Created', + value: 'folderCreated', + description: 'When a new folder is created', + }, + { + name: 'Folder Updated', + value: 'folderUpdated', + description: 'When an existing folder is modified', + }, + ], + }, + { + displayName: 'Simplify', + name: 'simple', + description: 'Whether to return a simplified version of the response instead of the raw data', + type: 'boolean', + default: true, + }, + { + displayName: 'Watch Folder', + name: 'watchFolder', + description: + 'Whether to watch for the created file in a given folder, rather than the entire OneDrive', + type: 'boolean', + default: false, + displayOptions: { + show: { + event: ['fileCreated'], + }, + }, + }, + { + displayName: 'Watch', + name: 'watch', + description: 'How to select which file to watch', + type: 'options', + default: 'anyFile', + displayOptions: { + show: { + event: ['fileUpdated'], + }, + }, + options: [ + { + name: 'Any File', + value: 'anyFile', + description: 'Watch for updated files in the entire OneDrive', + }, + { + name: 'Inside a Folder', + value: 'selectedFolder', + description: 'Watch for updated files inside a selected folder', + }, + { + name: 'A Selected File', + value: 'selectedFile', + description: 'Watch a specific file for updates', + }, + ], + }, + { + displayName: 'Watch Folder', + name: 'watchFolder', + description: + 'Whether to watch for the created folder in a given folder, rather than the entire OneDrive', + type: 'boolean', + default: false, + displayOptions: { + show: { + event: ['folderCreated'], + }, + }, + }, + { + displayName: 'Watch', + name: 'watch', + description: 'How to select which folder to watch', + type: 'options', + default: 'anyFolder', + displayOptions: { + show: { + event: ['folderUpdated'], + }, + }, + options: [ + { + name: 'Any Folder', + value: 'anyFolder', + description: 'Watch for updated folders in the entire OneDrive', + }, + { + name: 'Inside a Folder', + value: 'selectedFolder', + description: 'Watch for updated folders inside a selected folder', + }, + { + name: 'A Selected Folder', + value: 'oneSelectedFolder', + description: 'Watch a specific folder for updates', + }, + ], + }, + { + ...fileRLC, + displayOptions: { + show: { + event: ['fileUpdated'], + watch: ['selectedFile'], + }, + }, + }, + { + ...folderRLC, + displayOptions: { + show: { + watch: ['selectedFolder', 'oneSelectedFolder'], + }, + }, + }, + { + ...folderRLC, + displayOptions: { + show: { + watchFolder: [true], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + watch: ['selectedFolder'], + }, + }, + options: [ + { + displayName: 'Watch Nested Folders', + name: 'folderChild', + type: 'boolean', + default: false, + description: + 'Whether to look for modified files/folders in all nested folders, rather than only direct descendants', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + watchFolder: [true], + }, + }, + options: [ + { + displayName: 'Watch Nested Folders', + name: 'folderChild', + type: 'boolean', + default: false, + description: + 'Whether to look for modified files/folders in all nested folders, rather than only direct descendants', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Microsoft/OneDrive/constants.ts b/packages/nodes-base/nodes/Microsoft/OneDrive/constants.ts new file mode 100644 index 0000000000..ef620940b1 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/OneDrive/constants.ts @@ -0,0 +1,5 @@ +export const MICROSOFT_DRIVE_FILE_URL_REGEX = + 'https:\\/\\/onedrive.live.com(?:\\/.*?(?:\\&|\\?)(?:id=|resid=))(.+?)(?:\\&.*)'; + +export const MICROSOFT_DRIVE_FOLDER_URL_REGEX = + 'https:\\/\\/onedrive.live.com(?:\\/.*id=)(.+)(?:\\&cid.*)'; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 4bbca354ef..816a65427e 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -616,6 +616,7 @@ "dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js", "dist/nodes/Microsoft/GraphSecurity/MicrosoftGraphSecurity.node.js", "dist/nodes/Microsoft/OneDrive/MicrosoftOneDrive.node.js", + "dist/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.js", "dist/nodes/Microsoft/Outlook/MicrosoftOutlook.node.js", "dist/nodes/Microsoft/Outlook/MicrosoftOutlookTrigger.node.js", "dist/nodes/Microsoft/Sql/MicrosoftSql.node.js",