mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-14 00:24:07 -08:00
✨ Add Google Sheets Trigger node
This commit is contained in:
parent
7f0f8deb6d
commit
dbb085f975
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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 |
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue