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
This commit is contained in:
Marcus 2022-11-29 17:11:49 +01:00 committed by GitHub
parent 47b9d22ed5
commit b319671fd0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 335 additions and 116 deletions

View file

@ -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 <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
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 <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
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',
},
],
},
],
},

View file

@ -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 <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
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 <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
'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 <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
'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',

View file

@ -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<INodeListSearchResult> {
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<INodeListSearchResult> {
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 };
}

View file

@ -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<INodePropertyOptions[]> {
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<INodePropertyOptions[]> {
@ -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<INodePropertyOptions[]> {
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);

View file

@ -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 <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>',
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<INodePropertyOptions[]> {
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<INodeExecutionData[][] | null> {
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;