From a94d289d81a0474c97fa8ef75bb2f0aa2462a86d Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 27 Oct 2019 21:44:21 +0100 Subject: [PATCH] :sparkles: Add GoogleDrive-Node --- packages/nodes-base/nodes/Google/GoogleApi.ts | 22 + .../nodes/Google/GoogleDrive.node.ts | 954 ++++++++++++++++++ .../{GoogleSheets => Google}/GoogleSheet.ts | 14 +- .../GoogleSheets.node.ts | 0 .../nodes-base/nodes/Google/googleDrive.png | Bin 0 -> 3060 bytes .../{GoogleSheets => Google}/googlesheets.png | Bin packages/nodes-base/package.json | 3 +- 7 files changed, 980 insertions(+), 13 deletions(-) create mode 100644 packages/nodes-base/nodes/Google/GoogleApi.ts create mode 100644 packages/nodes-base/nodes/Google/GoogleDrive.node.ts rename packages/nodes-base/nodes/{GoogleSheets => Google}/GoogleSheet.ts (97%) rename packages/nodes-base/nodes/{GoogleSheets => Google}/GoogleSheets.node.ts (100%) create mode 100644 packages/nodes-base/nodes/Google/googleDrive.png rename packages/nodes-base/nodes/{GoogleSheets => Google}/googlesheets.png (100%) diff --git a/packages/nodes-base/nodes/Google/GoogleApi.ts b/packages/nodes-base/nodes/Google/GoogleApi.ts new file mode 100644 index 0000000000..c433613ae2 --- /dev/null +++ b/packages/nodes-base/nodes/Google/GoogleApi.ts @@ -0,0 +1,22 @@ + +import { JWT } from 'google-auth-library'; +import { google } from 'googleapis'; + + +/** + * Returns the authentication client needed to access spreadsheet + */ +export async function getAuthenticationClient(email: string, privateKey: string, scopes: string[]): Promise { + const client = new google.auth.JWT( + email, + undefined, + privateKey, + scopes, + undefined + ); + + // TODO: Check later if this or the above should be cached + await client.authorize(); + + return client; +} diff --git a/packages/nodes-base/nodes/Google/GoogleDrive.node.ts b/packages/nodes-base/nodes/Google/GoogleDrive.node.ts new file mode 100644 index 0000000000..4db2cae3c7 --- /dev/null +++ b/packages/nodes-base/nodes/Google/GoogleDrive.node.ts @@ -0,0 +1,954 @@ +import { google } from 'googleapis'; +const { Readable } = require('stream'); + +import { + BINARY_ENCODING, + IExecuteFunctions, +} from 'n8n-core'; +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, +} from 'n8n-workflow'; + +import { getAuthenticationClient } from './GoogleApi'; + + +export class GoogleDrive implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google Drive', + name: 'googleDrive', + icon: 'file:googleDrive.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Access data on Google Drive', + defaults: { + name: 'Google Drive', + color: '#3f87f2', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'File', + value: 'file', + }, + { + name: 'Folder', + value: 'folder', + }, + ], + default: 'file', + description: 'The resource to operate on.', + }, + + + // ---------------------------------- + // operations + // ---------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'file', + ], + }, + }, + options: [ + { + name: 'Delete', + value: 'delete', + description: 'Delete a file', + }, + { + name: 'Download', + value: 'download', + description: 'Download a file', + }, + { + name: 'List', + value: 'list', + description: 'Returns files and folders', + }, + { + name: 'Upload', + value: 'upload', + description: 'Upload a file', + }, + ], + default: 'upload', + description: 'The operation to perform.', + }, + + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'folder', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a folder', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a folder', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, + + + // ---------------------------------- + // file + // ---------------------------------- + + // ---------------------------------- + // file/folder:delete + // ---------------------------------- + { + displayName: 'ID', + name: 'fileId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'delete' + ], + resource: [ + 'file', + 'folder', + ], + }, + }, + description: 'The ID of the file/folder to delete.', + }, + + + // ---------------------------------- + // file:download + // ---------------------------------- + { + displayName: 'File Id', + name: 'fileId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'download' + ], + resource: [ + 'file', + ], + }, + }, + description: 'The ID of the file to 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.', + }, + + + // ---------------------------------- + // file:list + // ---------------------------------- + { + displayName: 'Use Query String', + name: 'useQueryString', + type: 'boolean', + default: false, + displayOptions: { + show: { + operation: [ + 'list' + ], + resource: [ + 'file', + ], + }, + }, + description: 'If 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: 100, + description: 'How many files 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', + options: [ + { + name: 'Contains', + value: 'contains' + }, + { + name: 'Is', + value: 'is' + }, + { + name: 'Is Not', + value: 'isNot' + }, + + ], + default: 'contains', + description: 'Operation to perform.', + }, + { + 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: 'Custom Mime Type', + value: 'custom', + }, + { + name: ' 3rd party shortcut', + value: 'application/vnd.google-apps.drive-sdk', + }, + { + name: 'Audio', + value: 'application/vnd.google-apps.audio', + }, + { + 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', + ], + }, + }, + description: 'Custom Mime Type', + }, + ] + } + ], + }, + + + // ---------------------------------- + // 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: '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.', + }, + { + displayName: 'Binary Data', + name: 'binaryData', + type: 'boolean', + default: false, + displayOptions: { + show: { + operation: [ + 'upload' + ], + resource: [ + 'file', + ], + }, + }, + description: 'If 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.', + }, + + + // ---------------------------------- + // 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: {}, + options: [ + { + displayName: 'Fields', + name: 'fields', + type: 'multiOptions', + options: [ + { + name: '*', + value: '*', + description: 'All fields.', + }, + { + name: 'explicitlyTrashed', + value: 'explicitlyTrashed', + }, + { + name: 'exportLinks', + value: 'exportLinks', + }, + { + name: 'iconLink', + value: 'iconLink', + }, + { + name: 'hasThumbnail', + value: 'hasThumbnail', + }, + { + name: 'id', + value: 'id', + }, + { + name: 'kind', + value: 'kind', + }, + { + name: 'name', + value: 'name', + }, + { + name: 'mimeType', + value: 'mimeType', + }, + { + 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', + }, + ], + required: true, + default: [], + description: 'The fields to return.', + }, + { + displayName: 'Spaces', + name: 'spaces', + type: 'multiOptions', + displayOptions: { + show: { + '/operation': [ + 'list' + ], + '/resource': [ + 'file', + ], + }, + }, + options: [ + { + name: '*', + value: '*', + description: 'All spaces.', + }, + { + name: 'appDataFolder', + value: 'appDataFolder', + }, + { + name: 'drive', + value: 'drive', + }, + { + name: 'photos', + value: 'photos', + }, + ], + required: true, + default: [], + description: 'The spaces to operate on.', + }, + ], + }, + + ], + }; + + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + const credentials = this.getCredentials('googleApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const scopes = [ + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/drive.appdata', + 'https://www.googleapis.com/auth/drive.photos.readonly', + ]; + + const client = await getAuthenticationClient(credentials.email as string, credentials.privateKey as string, scopes); + + const drive = google.drive({ + version: 'v3', + auth: client, + }); + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < items.length; i++) { + const options = this.getNodeParameter('options', i) as IDataObject; + + let queryFields = 'id, name'; + if (options && options.fields) { + const fields = options.fields as string[]; + if (fields.includes('*')) { + queryFields = '*'; + } else { + queryFields = fields.join(', '); + } + } + + if (resource === 'file') { + if (operation === 'download') { + // ---------------------------------- + // download + // ---------------------------------- + + const fileId = this.getNodeParameter('fileId', i) as string; + + const response = await drive.files.get( + { + fileId, + alt: 'media', + }, + { + responseType: 'arraybuffer', + }, + ); + + let mimeType: string | undefined; + if (response.headers['content-type']) { + mimeType = response.headers['content-type']; + } + + 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, items[i].binary); + } + + items[i] = newItem; + + const dataPropertyNameDownload = this.getNodeParameter('binaryPropertyName', i) as string; + + const data = Buffer.from(response.data as string); + + items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData(data as unknown as Buffer, undefined, mimeType); + + } 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 queryString = ''; + const useQueryString = this.getNodeParameter('useQueryString', i) as boolean; + if (useQueryString === true) { + // 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 operation = nameFilter.operation; + if (operation === 'is') { + operation = '='; + } else if (operation === 'isNot') { + operation = '!='; + } + queryFilterFields.push(`name ${operation} '${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) as number; + + const res = await drive.files.list({ + pageSize, + orderBy: 'modifiedTime', + fields: `nextPageToken, files(${queryFields})`, + spaces: querySpaces, + q: queryString, + }); + + const files = res!.data.files; + + return [this.helpers.returnJsonArray(files as IDataObject[])]; + + } else if (operation === 'upload') { + // ---------------------------------- + // upload + // ---------------------------------- + + let mimeType = 'text/plain'; + let body; + let originalFilename: string | undefined; + if (this.getNodeParameter('binaryData', i) === true) { + // Is binary file to upload + const item = items[i]; + + if (item.binary === undefined) { + throw new Error('No binary data exists on item!'); + } + + const propertyNameUpload = this.getNodeParameter('binaryPropertyName', i) as string; + + if (item.binary[propertyNameUpload] === undefined) { + throw new Error(`No binary data property "${propertyNameUpload}" does not exists on item!`); + } + + if (item.binary[propertyNameUpload].mimeType) { + mimeType = item.binary[propertyNameUpload].mimeType; + } + + if (item.binary[propertyNameUpload].fileName) { + originalFilename = item.binary[propertyNameUpload].fileName; + } + + body = Buffer.from(item.binary[propertyNameUpload].data, BINARY_ENCODING); + } else { + // Is text file + body = Buffer.from(this.getNodeParameter('fileContent', i) as string, 'utf8'); + } + + const name = this.getNodeParameter('name', i) as string; + const parents = this.getNodeParameter('parents', i) as string[]; + + const response = await drive.files.create({ + requestBody: { + name, + originalFilename, + parents, + }, + fields: queryFields, + media: { + mimeType, + body: ((buffer: Buffer) => { + const readableInstanceStream = new Readable({ + read() { + this.push(buffer); + this.push(null); + } + }); + + return readableInstanceStream; + })(body), + }, + }); + + returnData.push(response.data as IDataObject); + } + + } else if (resource === 'folder') { + if (operation === 'create') { + // ---------------------------------- + // folder:create + // ---------------------------------- + + const name = this.getNodeParameter('name', i) as string; + + const fileMetadata = { + name, + mimeType: 'application/vnd.google-apps.folder' + }; + + const response = await drive.files.create({ + // @ts-ignore + resource: fileMetadata, + fields: queryFields, + }); + + returnData.push(response.data as IDataObject); + } + } + if (['file', 'folder'].includes(resource)) { + if (operation === 'delete') { + // ---------------------------------- + // delete + // ---------------------------------- + + const fileId = this.getNodeParameter('fileId', i) as string; + + await drive.files.delete({ + fileId, + }); + + // If we are still here it did succeed + returnData.push({ + fileId, + success: true, + }); + } + } else { + throw new Error(`The resource "${resource}" is not known!`); + } + } + + 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.helpers.returnJsonArray(returnData)]; + } + } +} diff --git a/packages/nodes-base/nodes/GoogleSheets/GoogleSheet.ts b/packages/nodes-base/nodes/Google/GoogleSheet.ts similarity index 97% rename from packages/nodes-base/nodes/GoogleSheets/GoogleSheet.ts rename to packages/nodes-base/nodes/Google/GoogleSheet.ts index 2d9c7c7ec5..678ffdbbd2 100644 --- a/packages/nodes-base/nodes/GoogleSheets/GoogleSheet.ts +++ b/packages/nodes-base/nodes/Google/GoogleSheet.ts @@ -1,6 +1,7 @@ import { IDataObject } from 'n8n-workflow'; import { google } from 'googleapis'; import { JWT } from 'google-auth-library'; +import { getAuthenticationClient } from './GoogleApi'; const Sheets = google.sheets('v4'); // tslint:disable-line:variable-name @@ -135,18 +136,7 @@ export class GoogleSheet { * Returns the authentication client needed to access spreadsheet */ async getAuthenticationClient(): Promise { - const client = new google.auth.JWT( - this.credentials.email, - undefined, - this.credentials.privateKey, - this.scopes, - undefined - ); - - // TODO: Check later if this or the above should be cached - await client.authorize(); - - return client; + return getAuthenticationClient(this.credentials.email, this.credentials.privateKey, this.scopes); } diff --git a/packages/nodes-base/nodes/GoogleSheets/GoogleSheets.node.ts b/packages/nodes-base/nodes/Google/GoogleSheets.node.ts similarity index 100% rename from packages/nodes-base/nodes/GoogleSheets/GoogleSheets.node.ts rename to packages/nodes-base/nodes/Google/GoogleSheets.node.ts diff --git a/packages/nodes-base/nodes/Google/googleDrive.png b/packages/nodes-base/nodes/Google/googleDrive.png new file mode 100644 index 0000000000000000000000000000000000000000..c5ad8f323de55fd7b9745d58ff4dd23bd3ab9969 GIT binary patch literal 3060 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