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;