diff --git a/packages/nodes-base/credentials/GoogleSlidesOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleSlidesOAuth2Api.credentials.ts new file mode 100644 index 0000000000..7f5fb14b42 --- /dev/null +++ b/packages/nodes-base/credentials/GoogleSlidesOAuth2Api.credentials.ts @@ -0,0 +1,26 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'https://www.googleapis.com/auth/drive.file', + 'https://www.googleapis.com/auth/presentations', +]; + +export class GoogleSlidesOAuth2Api implements ICredentialType { + name = 'googleSlidesOAuth2Api'; + extends = [ + 'googleOAuth2Api', + ]; + displayName = 'Google Slides OAuth2 API'; + documentationUrl = 'google'; + properties = [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + ]; +} diff --git a/packages/nodes-base/nodes/Google/Slides/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Slides/GenericFunctions.ts new file mode 100644 index 0000000000..e25ec49e48 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Slides/GenericFunctions.ts @@ -0,0 +1,111 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +import * as moment from 'moment-timezone'; + +import * as jwt from 'jsonwebtoken'; + +export async function googleApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: string, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, +) { + const authenticationMethod = this.getNodeParameter('authentication', 0, 'serviceAccount') as string; + const options: OptionsWithUri & { headers: IDataObject } = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body, + qs, + uri: `https://slides.googleapis.com/v1${resource}`, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + if (!Object.keys(qs).length) { + delete options.qs; + } + + try { + if (authenticationMethod === 'serviceAccount') { + const credentials = this.getCredentials('googleApi') as { access_token: string, email: string, privateKey: string }; + const { access_token } = await getAccessToken.call(this, credentials); + options.headers.Authorization = `Bearer ${access_token}`; + return await this.helpers.request!(options); + + } else { + return await this.helpers.requestOAuth2!.call(this, 'googleSlidesOAuth2Api', options); + } + } catch (error) { + + if (error?.response?.body?.message) { + throw new Error(`Google Slides error response [${error.statusCode}]: ${error.response.body.message}`); + } + throw error; + } +} + +function getAccessToken( + this: IExecuteFunctions | ILoadOptionsFunctions, + { email, privateKey }: { email: string, privateKey: string }, +) { + // https://developers.google.com/identity/protocols/oauth2/service-account#httprest + + const scopes = [ + 'https://www.googleapis.com/auth/drive.file', + 'https://www.googleapis.com/auth/presentations', + ]; + + const now = moment().unix(); + + const signature = jwt.sign( + { + iss: email, + sub: email, + scope: scopes.join(' '), + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: now + 3600, + }, + privateKey, + { + algorithm: 'RS256', + header: { + kid: privateKey, + typ: 'JWT', + alg: 'RS256', + }, + }, + ); + + const options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + form: { + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: signature, + }, + uri: 'https://oauth2.googleapis.com/token', + json: true, + }; + + return this.helpers.request!(options); +} diff --git a/packages/nodes-base/nodes/Google/Slides/GoogleSlides.node.ts b/packages/nodes-base/nodes/Google/Slides/GoogleSlides.node.ts new file mode 100644 index 0000000000..48f7b2c34e --- /dev/null +++ b/packages/nodes-base/nodes/Google/Slides/GoogleSlides.node.ts @@ -0,0 +1,554 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + googleApiRequest, +} from './GenericFunctions'; + +export class GoogleSlides implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google Slides', + name: 'googleSlides', + icon: 'file:googleslides.svg', + group: ['input', 'output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the Google Slides API', + defaults: { + name: 'Google Slides', + color: '#edba25', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleApi', + required: true, + displayOptions: { + show: { + authentication: [ + 'serviceAccount', + ], + }, + }, + }, + { + name: 'googleSlidesOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'OAuth2', + value: 'oAuth2', + }, + { + name: 'Service Account', + value: 'serviceAccount', + }, + ], + default: 'serviceAccount', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Page', + value: 'page', + }, + { + name: 'Presentation', + value: 'presentation', + }, + ], + default: 'presentation', + description: 'Resource to operate on', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a presentation', + }, + { + name: 'Get', + value: 'get', + description: 'Get a presentation', + }, + { + name: 'Get Slides', + value: 'getSlides', + description: 'Get presentation slides', + }, + { + name: 'Replace Text', + value: 'replaceText', + description: 'Replace text in a presentation', + }, + ], + displayOptions: { + show: { + resource: [ + 'presentation', + ], + }, + }, + default: 'create', + description: 'Operation to perform', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a page', + }, + { + name: 'Get Thumbnail', + value: 'getThumbnail', + description: 'Get a thumbnail', + }, + ], + displayOptions: { + show: { + resource: [ + 'page', + ], + }, + }, + default: 'get', + description: 'Operation to perform', + }, + { + displayName: 'Title', + name: 'title', + description: 'Title of the presentation to create.', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'presentation', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Presentation ID', + name: 'presentationId', + description: 'ID of the presentation to retrieve. Found in the presentation URL:
https://docs.google.com/presentation/d/PRESENTATION_ID/edit', + placeholder: '1wZtNFZ8MO-WKrxhYrOLMvyiqSgFwdSz5vn8_l_7eNqw', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'presentation', + 'page', + ], + operation: [ + 'get', + 'getThumbnail', + 'getSlides', + 'replaceText', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getSlides', + ], + resource: [ + 'presentation', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getSlides', + ], + resource: [ + 'presentation', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Page Object ID', + name: 'pageObjectId', + description: 'ID of the page object to retrieve.', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'page', + ], + operation: [ + 'get', + 'getThumbnail', + ], + }, + }, + }, + { + displayName: 'Texts To Replace', + name: 'textUi', + placeholder: 'Add Text', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'presentation', + ], + operation: [ + 'replaceText', + ], + }, + }, + default: {}, + options: [ + { + name: 'textValues', + displayName: 'Text', + values: [ + { + displayName: 'Match Case', + name: 'matchCase', + type: 'boolean', + default: false, + description: 'Indicates whether the search should respect case. True : the search is case sensitive. False : the search is case insensitive.', + }, + { + displayName: 'Page IDs', + name: 'pageObjectIds', + type: 'multiOptions', + default: [], + typeOptions: { + loadOptionsMethod: 'getPages', + loadOptionsDependsOn: [ + 'presentationId', + ], + }, + description: 'If non-empty, limits the matches to page elements only on the given pages.', + }, + { + displayName: 'Replace Text', + name: 'replaceText', + type: 'string', + default: '', + description: 'The text that will replace the matched text.', + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + default: '', + description: 'The text to search for in the shape or table.', + }, + ], + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + displayOptions: { + show: { + operation: [ + 'replaceText', + ], + resource: [ + 'presentation', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Revision ID', + name: 'revisionId', + type: 'string', + default: '', + description: `The revision ID of the presentation required for the write request.
+ If specified and the requiredRevisionId doesn't exactly match the presentation's
+ current revisionId, the request will not be processed and will return a 400 bad request error.`, + }, + ], + }, + + { + displayName: 'Download', + name: 'download', + type: 'boolean', + default: false, + displayOptions: { + show: { + resource: [ + 'page', + ], + operation: [ + 'getThumbnail', + ], + }, + }, + description: 'Name of the binary property to which to
write the data of the read page.', + }, + { + displayName: 'Binary Property', + name: 'binaryProperty', + type: 'string', + required: true, + default: 'data', + description: 'Name of the binary property to which to write to.', + displayOptions: { + show: { + resource: [ + 'page', + ], + operation: [ + 'getThumbnail', + ], + download: [ + true, + ], + }, + }, + }, + ], + }; + + methods = { + loadOptions: { + // Get all the pages to display them to user so that he can + // select them easily + async getPages( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + const presentationId = this.getCurrentNodeParameter('presentationId') as string; + const { slides } = await googleApiRequest.call(this, 'GET', `/presentations/${presentationId}`, {}, { fields: 'slides' }); + for (const slide of slides) { + returnData.push({ + name: slide.objectId, + value: slide.objectId, + }); + } + return returnData; + }, + }, + }; + + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + let responseData; + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + + if (resource === 'page') { + + // ********************************************************************* + // page + // ********************************************************************* + + if (operation === 'get') { + + // ---------------------------------- + // page: get + // ---------------------------------- + + const presentationId = this.getNodeParameter('presentationId', i) as string; + const pageObjectId = this.getNodeParameter('pageObjectId', i) as string; + responseData = await googleApiRequest.call(this, 'GET', `/presentations/${presentationId}/pages/${pageObjectId}`); + returnData.push({ json: responseData }); + + } else if (operation === 'getThumbnail') { + + // ---------------------------------- + // page: getThumbnail + // ---------------------------------- + + const presentationId = this.getNodeParameter('presentationId', i) as string; + const pageObjectId = this.getNodeParameter('pageObjectId', i) as string; + responseData = await googleApiRequest.call(this, 'GET', `/presentations/${presentationId}/pages/${pageObjectId}/thumbnail`); + + const download = this.getNodeParameter('download', 0) as boolean; + if (download === true) { + const binaryProperty = this.getNodeParameter('binaryProperty', i) as string; + + const data = await this.helpers.request({ + uri: responseData.contentUrl, + method: 'GET', + json: false, + encoding: null, + }); + + const fileName = pageObjectId + '.png'; + const binaryData = await this.helpers.prepareBinaryData(data, fileName || fileName); + returnData.push({ + json: responseData, + binary: { + [binaryProperty]: binaryData, + }, + }); + } else { + returnData.push({ json: responseData }); + } + } + + } else if (resource === 'presentation') { + + // ********************************************************************* + // presentation + // ********************************************************************* + + if (operation === 'create') { + + // ---------------------------------- + // presentation: create + // ---------------------------------- + + const body = { + title: this.getNodeParameter('title', i) as string, + }; + + responseData = await googleApiRequest.call(this, 'POST', '/presentations', body); + returnData.push({ json: responseData }); + + } else if (operation === 'get') { + + // ---------------------------------- + // presentation: get + // ---------------------------------- + + const presentationId = this.getNodeParameter('presentationId', i) as string; + responseData = await googleApiRequest.call(this, 'GET', `/presentations/${presentationId}`); + returnData.push({ json: responseData }); + + } else if (operation === 'getSlides') { + + // ---------------------------------- + // presentation: getSlides + // ---------------------------------- + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const presentationId = this.getNodeParameter('presentationId', i) as string; + responseData = await googleApiRequest.call(this, 'GET', `/presentations/${presentationId}`, {}, { fields: 'slides' }); + responseData = responseData.slides; + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + responseData = responseData.slice(0, limit); + } + returnData.push(...this.helpers.returnJsonArray(responseData)) + + } else if (operation === 'replaceText') { + + // ---------------------------------- + // presentation: replaceText + // ---------------------------------- + const presentationId = this.getNodeParameter('presentationId', i) as string; + const texts = this.getNodeParameter('textUi.textValues', i, []) as IDataObject[]; + const options = this.getNodeParameter('options', i) as IDataObject; + const requests = texts.map((text => { + return { + replaceAllText: { + replaceText: text.replaceText, + pageObjectIds: text.pageObjectIds || [], + containsText: { + text: text.text, + matchCase: text.matchCase, + }, + }, + }; + })); + + const body: IDataObject = { + requests, + }; + + if (options.revisionId) { + body['writeControl'] = { + requiredRevisionId: options.revisionId as string, + }; + } + + responseData = await googleApiRequest.call(this, 'POST', `/presentations/${presentationId}:batchUpdate`, { requests }); + returnData.push({ json: responseData }); + + } + } + } + + return [returnData]; + } +} diff --git a/packages/nodes-base/nodes/Google/Slides/googleslides.svg b/packages/nodes-base/nodes/Google/Slides/googleslides.svg new file mode 100644 index 0000000000..d1fcb9c20b --- /dev/null +++ b/packages/nodes-base/nodes/Google/Slides/googleslides.svg @@ -0,0 +1 @@ + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index f7a15339d7..cb7e05155c 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -104,6 +104,7 @@ "dist/credentials/GoogleFirebaseRealtimeDatabaseOAuth2Api.credentials.js", "dist/credentials/GoogleOAuth2Api.credentials.js", "dist/credentials/GoogleSheetsOAuth2Api.credentials.js", + "dist/credentials/GoogleSlidesOAuth2Api.credentials.js", "dist/credentials/GSuiteAdminOAuth2Api.credentials.js", "dist/credentials/GoogleTasksOAuth2Api.credentials.js", "dist/credentials/GoogleTranslateOAuth2Api.credentials.js", @@ -371,6 +372,7 @@ "dist/nodes/Google/Gmail/Gmail.node.js", "dist/nodes/Google/GSuiteAdmin/GSuiteAdmin.node.js", "dist/nodes/Google/Sheet/GoogleSheets.node.js", + "dist/nodes/Google/Slides/GoogleSlides.node.js", "dist/nodes/Google/Task/GoogleTasks.node.js", "dist/nodes/Google/Translate/GoogleTranslate.node.js", "dist/nodes/Google/YouTube/YouTube.node.js",