Add Google Sheets Trigger node

This commit is contained in:
Iván Ovejero 2021-04-30 10:52:12 +02:00
parent 7f0f8deb6d
commit dbb085f975
7 changed files with 334 additions and 8 deletions

View file

@ -618,6 +618,9 @@ export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, addi
getNode: () => {
return getNode(node);
},
getNodeWebhookUrl: (name: string): string | undefined => {
return getNodeWebhookUrl(name, workflow, node, additionalData, mode);
},
getMode: (): WorkflowExecuteMode => {
return mode;
},

View file

@ -3,8 +3,10 @@ import {
NodePropertyTypes,
} from 'n8n-workflow';
// https://developers.google.com/drive/api/v3/about-auth
const scopes = [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/spreadsheets',
];

View file

@ -9,14 +9,19 @@ import {
} from 'n8n-core';
import {
IDataObject, NodeApiError, NodeOperationError,
IDataObject,
IHookFunctions,
ITriggerFunctions,
IWebhookFunctions,
NodeApiError,
NodeOperationError,
} from 'n8n-workflow';
import * as moment from 'moment-timezone';
import * as jwt from 'jsonwebtoken';
import uuid = require('uuid');
export async function googleApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, headers: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
export async function googleApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IHookFunctions | IWebhookFunctions | ITriggerFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, headers: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const authenticationMethod = this.getNodeParameter('authentication', 0, 'serviceAccount') as string;
const options: OptionsWithUri = {
headers: {
@ -76,7 +81,7 @@ export async function googleApiRequestAllItems(this: IExecuteFunctions | ILoadOp
return returnData;
}
function getAccessToken(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, credentials: IDataObject): Promise<IDataObject> {
function getAccessToken(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IHookFunctions | IWebhookFunctions | ITriggerFunctions, credentials: IDataObject): Promise<IDataObject> {
//https://developers.google.com/identity/protocols/oauth2/service-account#httprest
const scopes = [
@ -126,7 +131,7 @@ function getAccessToken(this: IExecuteFunctions | IExecuteSingleFunctions | ILoa
// Hex to RGB
export function hexToRgb(hex: string) {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
// Expand shorthand form (e.g. '03F') to full form (e.g. '0033FF')
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b;
@ -138,4 +143,81 @@ export function hexToRgb(hex: string) {
green: parseInt(result[2], 16),
blue: parseInt(result[3], 16),
} : null;
}
}
/**
* Create a Google Drive push notifications channel.
*/
export async function createGoogleDriveChannel(this: ITriggerFunctions): Promise<boolean> {
const body: IDataObject = {
id: uuid(),
type: 'web_hook',
address: this.getNodeWebhookUrl('default'),
};
// 'sync', 'update', 'add', 'remove', 'trash', 'untrash'
// https://developers.google.com/drive/api/v3/reference/files/watch
const fileId = this.getNodeParameter('fileId', 0);
const watchEndpoint = `https://www.googleapis.com/drive/v3/files/${fileId}/watch`;
body.expiration = moment().add(23, 'hours').add(59, 'minutes').valueOf();
const response = await googleApiRequest.call(this, 'POST', '', body, {}, watchEndpoint);
if (response?.id === undefined) {
return false;
}
const webhookData = this.getWorkflowStaticData('node');
webhookData.webhookId = response.id; // Google Drive channel ID
webhookData.resourceId = response.resourceId; // Google Drive file ID
return true;
}
/**
* Delete a Google Drive API push notifications channel.
*/
export async function deleteGoogleDriveChannel(this: ITriggerFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId === undefined) {
return false;
}
const stopEndpoint = 'https://www.googleapis.com/drive/v3/channels/stop';
const body = {
id: webhookData.webhookId,
resourceId: webhookData.resourceId,
};
try {
await googleApiRequest.call(this, 'POST', '', body, {}, stopEndpoint);
} catch (error) {
return false;
}
delete webhookData.webhookId;
delete webhookData.webhookEvents;
return true;
}
export type GoogleDriveNotificationHeader = {
'x-goog-channel-id': string;
'x-goog-channel-expiration': string;
'x-goog-resource-state': GoogleDriveFileEvent,
'x-goog-changed': GoogleSpreadsheetUpdateType,
'x-goog-resource-id': string;
'x-goog-resource-uri': string;
resolveData?: IDataObject; // added by GET request
};
export type GoogleDriveFileEvent = 'sync' | 'add' | 'remove' | 'trash' | 'untrash' | 'update';
export type GoogleSpreadsheetUpdateType = '*' | 'content' | 'permissions' | 'properties';
export type GoogleSheetEvent = Exclude<GoogleDriveFileEvent, 'sync' | 'add'>;
export async function resolveFileData(this: IWebhookFunctions, fileUri: string) {
return await googleApiRequest.call(this, 'GET', '', {}, { fields: '*' }, fileUri);
}

View file

@ -0,0 +1,237 @@
import {
IHookFunctions,
ITriggerFunctions,
IWebhookFunctions,
} from 'n8n-core';
import {
INodeType,
INodeTypeDescription,
ITriggerResponse,
IWebhookResponseData,
} from 'n8n-workflow';
import {
createGoogleDriveChannel,
deleteGoogleDriveChannel,
GoogleDriveNotificationHeader,
GoogleSheetEvent,
GoogleSpreadsheetUpdateType,
resolveFileData,
} from './GenericFunctions';
export class GoogleSheetsTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google Sheets Trigger',
name: 'googleSheetsTrigger',
icon: 'file:googleSheets.svg',
group: ['trigger'],
version: 1,
description: 'Starts the workflow when a Google Sheets file is changed.',
defaults: {
name: 'Google Sheets Trigger',
color: '#0aa55c',
},
inputs: [],
outputs: ['main'],
credentials: [
{
name: 'googleSheetsOAuth2Api',
required: true,
},
],
webhooks: [
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName: 'Spreadsheet ID',
name: 'fileId',
type: 'string',
required: true,
default: '',
description: 'ID of the spreadsheet to monitor. Obtainable via the List operation in the Google Drive node.',
placeholder: '1x44glCN7iByWuH7D9jvZM3f568Uk4Rqx',
},
{
displayName: 'Event',
name: 'event',
type: 'options',
default: 'update',
description: 'Spreadsheet event to monitor.',
options: [
// https://developers.google.com/drive/api/v3/push#understanding-drive-api-notification-events
{
name: 'Delete/Unshare',
value: 'remove',
description: 'Spreadsheet permanently deleted or unshared.',
},
{
name: 'Trash',
value: 'trash',
description: 'Spreadsheet moved to trash.',
},
{
name: 'Untrash',
value: 'untrash',
description: 'Spreadsheet restored from trash.',
},
{
name: 'Update',
value: 'update',
description: 'Spreadsheet properties (metadata) or content updated.',
},
],
},
// ----------------------------------
// event: update
// ----------------------------------
{
displayName: 'Resolve Data',
name: 'resolveData',
type: 'boolean',
default: false,
description: 'Return information on the updated spreadsheet.',
displayOptions: {
show: {
event: [
'update',
],
},
},
},
{
displayName: 'Update Type',
name: 'updateType',
description: 'Type of spreadsheet update to monitor.',
type: 'multiOptions',
default: [],
options: [
// https://developers.google.com/drive/api/v3/push#understanding-drive-api-notification-events
{
name: 'Any',
value: '*',
description: 'Any update to the spreadsheet.',
},
{
name: 'Content',
value: 'content',
description: 'Spreadsheet content updated.',
},
{
name: 'Permissions',
value: 'permissions',
description: 'Spreadsheet permissions updated.',
},
{
name: 'Properties',
value: 'properties',
description: 'Spreadsheet properties (metadata) updated.',
},
],
displayOptions: {
show: {
event: [
'update',
],
},
},
},
],
};
// @ts-ignore
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
return false;
},
create: createGoogleDriveChannel,
delete: deleteGoogleDriveChannel,
},
};
/**
* Receive a push notification about a Google spreadsheet
* from the Google Drive API and send it into the workflow.
*
* https://developers.google.com/drive/api/v3/push
*/
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const headerData = this.getHeaderData() as GoogleDriveNotificationHeader;
const resourceState = headerData['x-goog-resource-state'];
const event = this.getNodeParameter('event') as GoogleSheetEvent;
if (resourceState === 'sync' || resourceState !== event) {
return {};
}
if (event === 'update') {
const updateType = this.getNodeParameter('updateType', '*') as GoogleSpreadsheetUpdateType[];
if (!updateType.includes('*') && !updateType.includes(headerData['x-goog-changed'])) {
return {};
}
const resolveData = this.getNodeParameter('resolveData') as boolean;
if (resolveData) {
headerData.resolveData = await resolveFileData.call(this, headerData['x-goog-resource-uri']);
}
}
console.log(headerData['x-goog-resource-state'] + ' ' + headerData['x-goog-changed']);
return {
workflowData: [
this.helpers.returnJsonArray({
headers: headerData,
}),
],
};
}
/**
* Renew the Google Drive push notifications channel before
* the expiration deadline set by the Google Drive API.
*
* https://developers.google.com/drive/api/v3/push#renewing-notification-channels
*/
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
const executeTrigger = async () => {
await deleteGoogleDriveChannel.call(this);
await createGoogleDriveChannel.call(this);
};
// TODO: Uncomment after development
// const resource = this.getNodeParameter('resource', 0);
// const intervals = {
// changes: 6 * 24 * 60 * 60 * 1000, // 6 days 23 hours 59 minutes in ms
// files: 23 * 60 * 60 * 1000 + 59 * 60 * 1000, // 23 hours 59 minutes in ms
// };
// const intervalTime = intervals[resource];
const intervalTime = 60_000; // TODO: Delete after development
const intervalObject = setInterval(executeTrigger, intervalTime);
return {
manualTriggerFunction: async () => this.emit([
this.helpers.returnJsonArray([
{
event: 'Manual execution',
timestamp: (new Date()).toISOString(),
workflow_id: this.getWorkflow().id,
},
]),
]),
closeFunction: async () => clearInterval(intervalObject),
};
}
}

View file

@ -1 +1 @@
<svg width="60" height="60" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"><path d="M35.69 1L52 17.225v39.087a3.67 3.67 0 01-1.084 2.61A3.71 3.71 0 0148.293 60H12.707a3.71 3.71 0 01-2.623-1.078A3.67 3.67 0 019 56.312V4.688a3.67 3.67 0 011.084-2.61A3.71 3.71 0 0112.707 1H35.69z" fill="#28B446"/><path d="M35.69 1L52 17.225H39.397c-2.054 0-3.707-1.829-3.707-3.872V1z" fill="#6ACE7C"/><path fill="#219B38" d="M39.211 17.225L52 22.48v-5.255z"/><path d="M20.12 31.975c0-.817.662-1.475 1.483-1.475h17.794c.821 0 1.482.658 1.482 1.475v15.487c0 .818-.661 1.475-1.482 1.475H21.603a1.476 1.476 0 01-1.482-1.474V31.974zm2.225 1.475h6.672v2.212h-6.672V33.45zm0 5.162h6.672v2.213h-6.672v-2.213zm0 5.163h6.672v2.212h-6.672v-2.212zm9.638-10.325h6.672v2.212h-6.672V33.45zm0 5.162h6.672v2.213h-6.672v-2.213zm0 5.163h6.672v2.212h-6.672v-2.212z" fill="#FFF"/><path d="M34.69 0L51 16.225v39.087a3.67 3.67 0 01-1.084 2.61A3.71 3.71 0 0147.293 59H11.707a3.71 3.71 0 01-2.623-1.078A3.67 3.67 0 018 55.312V3.688a3.67 3.67 0 011.084-2.61A3.71 3.71 0 0111.707 0H34.69z" fill="#28B446"/><path d="M34.69 0L51 16.225H38.397c-2.054 0-3.707-1.829-3.707-3.872V0z" fill="#6ACE7C"/><path fill="#219B38" d="M38.211 16.225L51 21.48v-5.255z"/><path d="M19.12 30.975c0-.817.662-1.475 1.483-1.475h17.794c.821 0 1.482.658 1.482 1.475v15.487c0 .818-.661 1.475-1.482 1.475H20.603a1.476 1.476 0 01-1.482-1.474V30.974zm2.225 1.475h6.672v2.212h-6.672V32.45zm0 5.162h6.672v2.213h-6.672v-2.213zm0 5.163h6.672v2.212h-6.672v-2.212zm9.638-10.325h6.672v2.212h-6.672V32.45zm0 5.162h6.672v2.213h-6.672v-2.213zm0 5.163h6.672v2.212h-6.672v-2.212z" fill="#FFF"/></g></svg>
<svg viewBox="-5 -5 70 70" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"><path d="M35.69 1L52 17.225v39.087a3.67 3.67 0 01-1.084 2.61A3.71 3.71 0 0148.293 60H12.707a3.71 3.71 0 01-2.623-1.078A3.67 3.67 0 019 56.312V4.688a3.67 3.67 0 011.084-2.61A3.71 3.71 0 0112.707 1H35.69z" fill="#28B446"/><path d="M35.69 1L52 17.225H39.397c-2.054 0-3.707-1.829-3.707-3.872V1z" fill="#6ACE7C"/><path fill="#219B38" d="M39.211 17.225L52 22.48v-5.255z"/><path d="M20.12 31.975c0-.817.662-1.475 1.483-1.475h17.794c.821 0 1.482.658 1.482 1.475v15.487c0 .818-.661 1.475-1.482 1.475H21.603a1.476 1.476 0 01-1.482-1.474V31.974zm2.225 1.475h6.672v2.212h-6.672V33.45zm0 5.162h6.672v2.213h-6.672v-2.213zm0 5.163h6.672v2.212h-6.672v-2.212zm9.638-10.325h6.672v2.212h-6.672V33.45zm0 5.162h6.672v2.213h-6.672v-2.213zm0 5.163h6.672v2.212h-6.672v-2.212z" fill="#FFF"/><path d="M34.69 0L51 16.225v39.087a3.67 3.67 0 01-1.084 2.61A3.71 3.71 0 0147.293 59H11.707a3.71 3.71 0 01-2.623-1.078A3.67 3.67 0 018 55.312V3.688a3.67 3.67 0 011.084-2.61A3.71 3.71 0 0111.707 0H34.69z" fill="#28B446"/><path d="M34.69 0L51 16.225H38.397c-2.054 0-3.707-1.829-3.707-3.872V0z" fill="#6ACE7C"/><path fill="#219B38" d="M38.211 16.225L51 21.48v-5.255z"/><path d="M19.12 30.975c0-.817.662-1.475 1.483-1.475h17.794c.821 0 1.482.658 1.482 1.475v15.487c0 .818-.661 1.475-1.482 1.475H20.603a1.476 1.476 0 01-1.482-1.474V30.974zm2.225 1.475h6.672v2.212h-6.672V32.45zm0 5.162h6.672v2.213h-6.672v-2.213zm0 5.163h6.672v2.212h-6.672v-2.212zm9.638-10.325h6.672v2.212h-6.672V32.45zm0 5.162h6.672v2.213h-6.672v-2.213zm0 5.163h6.672v2.212h-6.672v-2.212z" fill="#FFF"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -375,6 +375,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/Sheet/GoogleSheetsTrigger.node.js",
"dist/nodes/Google/Slides/GoogleSlides.node.js",
"dist/nodes/Google/Task/GoogleTasks.node.js",
"dist/nodes/Google/Translate/GoogleTranslate.node.js",

View file

@ -305,6 +305,7 @@ export interface ITriggerFunctions {
getMode(): WorkflowExecuteMode;
getActivationMode(): WorkflowActivateMode;
getNode(): INode;
getNodeWebhookUrl: (name: string) => string | undefined;
getNodeParameter(parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object; //tslint:disable-line:no-any
getRestApiUrl(): string;
getTimezone(): string;