From b319671fd0f0488488788b472af140014bd7cc99 Mon Sep 17 00:00:00 2001 From: Marcus <56945030+maspio@users.noreply.github.com> Date: Tue, 29 Nov 2022 17:11:49 +0100 Subject: [PATCH] feat(Google Calendar Node): Use resource locator component for calendar parameters (#4410) * use calendar RLC for event resource * use calendar RLC for calendar resource * listSearch getCalendars support query filter * improve RLC parameter descriptions to match standards * stricter google calendar id email regex with optional trailing whitespace * calendarId RLC for Google Calendar Trigger node * Event -> Get : Timezone RLC option * Event -> Get Many : Timezone RLC option * Calendar -> Availability : Timezone RLC option * Removed unused loadOptions getTimezones; Removed unused imports * fix prettier linting errors --- .../Google/Calendar/CalendarDescription.ts | 89 +++++++++--- .../nodes/Google/Calendar/EventDescription.ts | 131 +++++++++++++++--- .../nodes/Google/Calendar/GenericFunctions.ts | 69 ++++++++- .../Google/Calendar/GoogleCalendar.node.ts | 90 ++++++------ .../Calendar/GoogleCalendarTrigger.node.ts | 72 +++++----- 5 files changed, 335 insertions(+), 116 deletions(-) diff --git a/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts b/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts index d5940a2820..e85168ba04 100644 --- a/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts +++ b/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts @@ -1,4 +1,5 @@ import { INodeProperties } from 'n8n-workflow'; +import { TIMEZONE_VALIDATION_REGEX } from './GenericFunctions'; export const calendarOperations: INodeProperties[] = [ { @@ -28,21 +29,50 @@ export const calendarFields: INodeProperties[] = [ /* calendar:availability */ /* -------------------------------------------------------------------------- */ { - displayName: 'Calendar Name or ID', + displayName: 'Calendar', name: 'calendar', - type: 'options', - description: - 'Choose from the list, or specify an ID using an expression', - typeOptions: { - loadOptionsMethod: 'getCalendars', - }, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, + description: 'Google Calendar to operate on', + modes: [ + { + displayName: 'Calendar', + name: 'list', + type: 'list', + placeholder: 'Select a Calendar...', + typeOptions: { + searchListMethod: 'getCalendars', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + // calendar ids are emails. W3C email regex with optional trailing whitespace. + regex: + '(^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*(?:[ \t]+)*$)', + errorMessage: 'Not a valid Google Calendar ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '(^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*)', + }, + placeholder: 'name@google.com', + }, + ], displayOptions: { show: { resource: ['calendar'], }, }, - default: '', }, { displayName: 'Start Time', @@ -110,15 +140,42 @@ export const calendarFields: INodeProperties[] = [ description: 'The format to return the data in', }, { - displayName: 'Timezone Name or ID', + displayName: 'Timezone', name: 'timezone', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getTimezones', - }, - default: '', - description: - 'Time zone used in the response. By default n8n timezone is used. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Time zone used in the response. By default n8n timezone is used.', + modes: [ + { + displayName: 'Timezone', + name: 'list', + type: 'list', + placeholder: 'Select a Timezone...', + typeOptions: { + searchListMethod: 'getTimezones', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: TIMEZONE_VALIDATION_REGEX, + errorMessage: 'Not a valid Timezone', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '([-+/_a-zA-Z0-9]*)', + }, + placeholder: 'Europe/Berlin', + }, + ], }, ], }, diff --git a/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts b/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts index 6e40bd6f75..d91f78501b 100644 --- a/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts +++ b/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts @@ -1,5 +1,7 @@ import { INodeProperties } from 'n8n-workflow'; +import { TIMEZONE_VALIDATION_REGEX } from './GenericFunctions'; + export const eventOperations: INodeProperties[] = [ { displayName: 'Operation', @@ -52,21 +54,50 @@ export const eventFields: INodeProperties[] = [ /* event:getAll */ /* -------------------------------------------------------------------------- */ { - displayName: 'Calendar Name or ID', + displayName: 'Calendar', name: 'calendar', - type: 'options', - description: - 'Choose from the list, or specify an ID using an expression', - typeOptions: { - loadOptionsMethod: 'getCalendars', - }, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, + description: 'Google Calendar to operate on', + modes: [ + { + displayName: 'Calendar', + name: 'list', + type: 'list', + placeholder: 'Select a Calendar...', + typeOptions: { + searchListMethod: 'getCalendars', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + // calendar ids are emails. W3C email regex with optional trailing whitespace. + regex: + '(^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*(?:[ \t]+)*$)', + errorMessage: 'Not a valid Google Calendar ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '(^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*)', + }, + placeholder: 'name@google.com', + }, + ], displayOptions: { show: { resource: ['event'], }, }, - default: '', }, /* -------------------------------------------------------------------------- */ @@ -526,15 +557,43 @@ export const eventFields: INodeProperties[] = [ 'The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned.', }, { - displayName: 'Timezone Name or ID', + displayName: 'Timezone', name: 'timeZone', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getTimezones', - }, - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, description: - 'Time zone used in the response. The default is the time zone of the calendar. Choose from the list, or specify an ID using an expression.', + 'Time zone used in the response. The default is the time zone of the calendar.', + modes: [ + { + displayName: 'Timezone', + name: 'list', + type: 'list', + placeholder: 'Select a Timezone...', + typeOptions: { + searchListMethod: 'getTimezones', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: TIMEZONE_VALIDATION_REGEX, + errorMessage: 'Not a valid Timezone', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '([-+/_a-zA-Z0-9]*)', + }, + placeholder: 'Europe/Berlin', + }, + ], }, ], }, @@ -667,15 +726,43 @@ export const eventFields: INodeProperties[] = [ description: "Lower bound (exclusive) for an event's end time to filter by", }, { - displayName: 'Timezone Name or ID', + displayName: 'Timezone', name: 'timeZone', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getTimezones', - }, - default: '', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, description: - 'Time zone used in the response. The default is the time zone of the calendar. Choose from the list, or specify an ID using an expression.', + 'Time zone used in the response. The default is the time zone of the calendar.', + modes: [ + { + displayName: 'Timezone', + name: 'list', + type: 'list', + placeholder: 'Select a Timezone...', + typeOptions: { + searchListMethod: 'getTimezones', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: TIMEZONE_VALIDATION_REGEX, + errorMessage: 'Not a valid Timezone', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '([-+/_a-zA-Z0-9]*)', + }, + placeholder: 'Europe/Berlin', + }, + ], }, { displayName: 'Updated Min', diff --git a/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts index 1350acf78c..de87087bcd 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts @@ -2,7 +2,15 @@ import { OptionsWithUri } from 'request'; import { IExecuteFunctions, IExecuteSingleFunctions, ILoadOptionsFunctions } from 'n8n-core'; -import { IDataObject, IPollFunctions, NodeApiError } from 'n8n-workflow'; +import { + IDataObject, + INodeListSearchItems, + INodeListSearchResult, + IPollFunctions, + NodeApiError, +} from 'n8n-workflow'; + +import moment from 'moment-timezone'; export async function googleApiRequest( this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IPollFunctions, @@ -62,3 +70,62 @@ export async function googleApiRequestAllItems( return returnData; } + +export function encodeURIComponentOnce(uri: string) { + // load options used to save encoded uri strings + return encodeURIComponent(decodeURIComponent(uri)); +} + +export async function getCalendars( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const calendars = (await googleApiRequestAllItems.call( + this, + 'items', + 'GET', + '/calendar/v3/users/me/calendarList', + )) as Array<{ id: string; summary: string }>; + + const results: INodeListSearchItems[] = calendars + .map((c) => ({ + name: c.summary, + value: c.id, + })) + .filter( + (c) => + !filter || + c.name.toLowerCase().includes(filter.toLowerCase()) || + c.value?.toString() === filter, + ) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + return { results }; +} + +export const TIMEZONE_VALIDATION_REGEX = `(${moment.tz + .names() + .map((t) => t.replace('+', '\\+')) + .join('|')})[ \t]*`; + +export async function getTimezones( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const results: INodeListSearchItems[] = moment.tz + .names() + .map((timezone) => ({ + name: timezone, + value: timezone, + })) + .filter( + (c) => + !filter || + c.name.toLowerCase().includes(filter.toLowerCase()) || + c.value?.toString() === filter, + ); + return { results }; +} diff --git a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts index 6a73a128b0..0b38aabe54 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts +++ b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts @@ -11,7 +11,13 @@ import { NodeOperationError, } from 'n8n-workflow'; -import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; +import { + encodeURIComponentOnce, + getCalendars, + getTimezones, + googleApiRequest, + googleApiRequestAllItems, +} from './GenericFunctions'; import { eventFields, eventOperations } from './EventDescription'; @@ -69,6 +75,10 @@ export class GoogleCalendar implements INodeType { }; methods = { + listSearch: { + getCalendars, + getTimezones, + }, loadOptions: { // Get all the calendars to display them to user so that he can // select them easily @@ -95,26 +105,6 @@ export class GoogleCalendar implements INodeType { } return returnData; }, - // Get all the calendars to display them to user so that he can - // select them easily - async getCalendars(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const calendars = await googleApiRequestAllItems.call( - this, - 'items', - 'GET', - '/calendar/v3/users/me/calendarList', - ); - for (const calendar of calendars) { - const calendarName = calendar.summary; - const calendarId = encodeURIComponent(calendar.id); - returnData.push({ - name: calendarName, - value: calendarId, - }); - } - return returnData; - }, // Get all the colors to display them to user so that he can // select them easily async getColors(this: ILoadOptionsFunctions): Promise { @@ -130,20 +120,6 @@ export class GoogleCalendar implements INodeType { } return returnData; }, - // Get all the timezones to display them to user so that he can - // select them easily - async getTimezones(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - for (const timezone of moment.tz.names()) { - const timezoneName = timezone; - const timezoneId = timezone; - returnData.push({ - name: timezoneName, - value: timezoneId, - }); - } - return returnData; - }, }, }; @@ -161,11 +137,17 @@ export class GoogleCalendar implements INodeType { if (resource === 'calendar') { //https://developers.google.com/calendar/v3/reference/freebusy/query if (operation === 'availability') { - const calendarId = this.getNodeParameter('calendar', i) as string; + // we need to decode once because calendar used to be saved encoded + const calendarId = decodeURIComponent( + this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, + ); const timeMin = this.getNodeParameter('timeMin', i) as string; const timeMax = this.getNodeParameter('timeMax', i) as string; const options = this.getNodeParameter('options', i); const outputFormat = options.outputFormat || 'availability'; + const tz = this.getNodeParameter('options.timezone', i, '', { + extractValue: true, + }) as string; const body: IDataObject = { timeMin: moment(timeMin).utc().format(), @@ -175,7 +157,7 @@ export class GoogleCalendar implements INodeType { id: calendarId, }, ], - timeZone: options.timezone || timezone, + timeZone: tz || timezone, }; responseData = await googleApiRequest.call( @@ -204,7 +186,9 @@ export class GoogleCalendar implements INodeType { if (resource === 'event') { //https://developers.google.com/calendar/v3/reference/events/insert if (operation === 'create') { - const calendarId = this.getNodeParameter('calendar', i) as string; + const calendarId = encodeURIComponentOnce( + this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, + ); const start = this.getNodeParameter('start', i) as string; const end = this.getNodeParameter('end', i) as string; const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean; @@ -351,7 +335,9 @@ export class GoogleCalendar implements INodeType { } //https://developers.google.com/calendar/v3/reference/events/delete if (operation === 'delete') { - const calendarId = this.getNodeParameter('calendar', i) as string; + const calendarId = encodeURIComponentOnce( + this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, + ); const eventId = this.getNodeParameter('eventId', i) as string; const options = this.getNodeParameter('options', i); if (options.sendUpdates) { @@ -367,14 +353,19 @@ export class GoogleCalendar implements INodeType { } //https://developers.google.com/calendar/v3/reference/events/get if (operation === 'get') { - const calendarId = this.getNodeParameter('calendar', i) as string; + const calendarId = encodeURIComponentOnce( + this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, + ); const eventId = this.getNodeParameter('eventId', i) as string; const options = this.getNodeParameter('options', i); + const tz = this.getNodeParameter('options.timeZone', i, '', { + extractValue: true, + }) as string; if (options.maxAttendees) { qs.maxAttendees = options.maxAttendees as number; } - if (options.timeZone) { - qs.timeZone = options.timeZone as string; + if (tz) { + qs.timeZone = tz; } responseData = await googleApiRequest.call( this, @@ -387,8 +378,13 @@ export class GoogleCalendar implements INodeType { //https://developers.google.com/calendar/v3/reference/events/list if (operation === 'getAll') { const returnAll = this.getNodeParameter('returnAll', i); - const calendarId = this.getNodeParameter('calendar', i) as string; + const calendarId = encodeURIComponentOnce( + this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, + ); const options = this.getNodeParameter('options', i); + const tz = this.getNodeParameter('options.timeZone', i, '', { + extractValue: true, + }) as string; if (options.iCalUID) { qs.iCalUID = options.iCalUID as string; } @@ -416,8 +412,8 @@ export class GoogleCalendar implements INodeType { if (options.timeMin) { qs.timeMin = options.timeMin as string; } - if (options.timeZone) { - qs.timeZone = options.timeZone as string; + if (tz) { + qs.timeZone = tz; } if (options.updatedMin) { qs.updatedMin = options.updatedMin as string; @@ -445,7 +441,9 @@ export class GoogleCalendar implements INodeType { } //https://developers.google.com/calendar/v3/reference/events/patch if (operation === 'update') { - const calendarId = this.getNodeParameter('calendar', i) as string; + const calendarId = encodeURIComponentOnce( + this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, + ); const eventId = this.getNodeParameter('eventId', i) as string; const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean; const updateFields = this.getNodeParameter('updateFields', i); diff --git a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendarTrigger.node.ts b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendarTrigger.node.ts index 0cbc66deba..2bd10861e4 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendarTrigger.node.ts +++ b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendarTrigger.node.ts @@ -1,8 +1,6 @@ import { IDataObject, - ILoadOptionsFunctions, INodeExecutionData, - INodePropertyOptions, INodeType, INodeTypeDescription, IPollFunctions, @@ -10,7 +8,7 @@ import { NodeOperationError, } from 'n8n-workflow'; -import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; +import { getCalendars, googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; import moment from 'moment'; @@ -37,16 +35,45 @@ export class GoogleCalendarTrigger implements INodeType { polling: true, properties: [ { - displayName: 'Calendar Name or ID', + displayName: 'Calendar', name: 'calendarId', - type: 'options', - description: - 'Choose from the list, or specify an ID using an expression', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, required: true, - typeOptions: { - loadOptionsMethod: 'getCalendars', - }, - default: '', + description: 'Google Calendar to operate on', + modes: [ + { + displayName: 'Calendar', + name: 'list', + type: 'list', + placeholder: 'Select a Calendar...', + typeOptions: { + searchListMethod: 'getCalendars', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + // calendar ids are emails. W3C email regex with optional trailing whitespace. + regex: + '(^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*(?:[ \t]+)*$)', + errorMessage: 'Not a valid Google Calendar ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '(^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*)', + }, + placeholder: 'name@google.com', + }, + ], }, { displayName: 'Trigger On', @@ -94,32 +121,15 @@ export class GoogleCalendarTrigger implements INodeType { }; methods = { - loadOptions: { - // Get all the calendars to display them to user so that he can - // select them easily - async getCalendars(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const calendars = await googleApiRequestAllItems.call( - this, - 'items', - 'GET', - '/calendar/v3/users/me/calendarList', - ); - for (const calendar of calendars) { - returnData.push({ - name: calendar.summary, - value: calendar.id, - }); - } - return returnData; - }, + listSearch: { + getCalendars, }, }; async poll(this: IPollFunctions): Promise { const poolTimes = this.getNodeParameter('pollTimes.item', []) as IDataObject[]; const triggerOn = this.getNodeParameter('triggerOn', '') as string; - const calendarId = this.getNodeParameter('calendarId') as string; + const calendarId = this.getNodeParameter('calendarId', '', { extractValue: true }) as string; const webhookData = this.getWorkflowStaticData('node'); const matchTerm = this.getNodeParameter('options.matchTerm', '') as string;