fix(Google Calendar Node): Updates and fixes (#10715)

Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
Michael Kret
2025-01-10 11:16:29 +02:00
committed by GitHub
parent 91277c44f1
commit 7227a29845
14 changed files with 1051 additions and 56 deletions

View File

@@ -84,6 +84,7 @@ export const calendarFields: INodeProperties[] = [
show: { show: {
operation: ['availability'], operation: ['availability'],
resource: ['calendar'], resource: ['calendar'],
'@version': [{ _cnd: { lt: 1.3 } }],
}, },
}, },
default: '', default: '',
@@ -98,11 +99,44 @@ export const calendarFields: INodeProperties[] = [
show: { show: {
operation: ['availability'], operation: ['availability'],
resource: ['calendar'], resource: ['calendar'],
'@version': [{ _cnd: { lt: 1.3 } }],
}, },
}, },
default: '', default: '',
description: 'End of the interval', description: 'End of the interval',
}, },
{
displayName: 'Start Time',
name: 'timeMin',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: '={{ $now }}',
description:
'Start of the interval, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'End Time',
name: 'timeMax',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: "={{ $now.plus(1, 'hour') }}",
description:
'End of the interval, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{ {
displayName: 'Options', displayName: 'Options',
name: 'options', name: 'options',

View File

@@ -112,6 +112,7 @@ export const eventFields: INodeProperties[] = [
show: { show: {
operation: ['create'], operation: ['create'],
resource: ['event'], resource: ['event'],
'@version': [{ _cnd: { lt: 1.3 } }],
}, },
}, },
default: '', default: '',
@@ -126,11 +127,44 @@ export const eventFields: INodeProperties[] = [
show: { show: {
operation: ['create'], operation: ['create'],
resource: ['event'], resource: ['event'],
'@version': [{ _cnd: { lt: 1.3 } }],
}, },
}, },
default: '', default: '',
description: 'End time of the event', description: 'End time of the event',
}, },
{
displayName: 'Start',
name: 'start',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['create'],
resource: ['event'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: '={{ $now }}',
description:
'Start time of the event, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'End',
name: 'end',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['create'],
resource: ['event'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: "={{ $now.plus(1, 'hour') }}",
description:
'End time of the event, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{ {
displayName: 'Use Default Reminders', displayName: 'Use Default Reminders',
name: 'useDefaultReminders', name: 'useDefaultReminders',
@@ -553,6 +587,19 @@ export const eventFields: INodeProperties[] = [
description: description:
'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.', '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: 'Return Next Instance of Recurring Event',
name: 'returnNextInstance',
type: 'boolean',
default: false,
description:
'Whether to return the next instance of a recurring event instead of the event itself',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{ {
displayName: 'Timezone', displayName: 'Timezone',
name: 'timeZone', name: 'timeZone',
@@ -629,6 +676,36 @@ export const eventFields: INodeProperties[] = [
default: 50, default: 50,
description: 'Max number of results to return', description: 'Max number of results to return',
}, },
{
displayName: 'After',
name: 'timeMin',
type: 'dateTime',
default: '={{ $now }}',
description:
'At least some part of the event must be after this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
operation: ['getAll'],
resource: ['event'],
},
},
},
{
displayName: 'Before',
name: 'timeMax',
type: 'dateTime',
default: '={{ $now.plus({ week: 1 }) }}',
description:
'At least some part of the event must be before this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
operation: ['getAll'],
resource: ['event'],
},
},
},
{ {
displayName: 'Options', displayName: 'Options',
name: 'options', name: 'options',
@@ -647,14 +724,39 @@ export const eventFields: INodeProperties[] = [
name: 'timeMin', name: 'timeMin',
type: 'dateTime', type: 'dateTime',
default: '', default: '',
description: 'At least some part of the event must be after this time', description:
'At least some part of the event must be after this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
}, },
{ {
displayName: 'Before', displayName: 'Before',
name: 'timeMax', name: 'timeMax',
type: 'dateTime', type: 'dateTime',
default: '', default: '',
description: 'At least some part of the event must be before this time', description:
'At least some part of the event must be before this time, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{
displayName: 'Expand Events',
name: 'singleEvents',
type: 'boolean',
default: false,
description:
'Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
}, },
{ {
displayName: 'Fields', displayName: 'Fields',
@@ -708,6 +810,34 @@ export const eventFields: INodeProperties[] = [
description: description:
'Free text search terms to find events that match these terms in any field, except for extended properties', 'Free text search terms to find events that match these terms in any field, except for extended properties',
}, },
{
displayName: 'Recurring Event Handling',
name: 'recurringEventHandling',
type: 'options',
default: 'expand',
options: [
{
name: 'All Occurrences',
value: 'expand',
description: 'Return all instances of recurring event for specified time range',
},
{
name: 'First Occurrence',
value: 'first',
description: 'Return event with specified recurrence rule',
},
{
name: 'Next Occurrence',
value: 'next',
description: 'Return next instance of recurring event',
},
],
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
{ {
displayName: 'Show Deleted', displayName: 'Show Deleted',
name: 'showDeleted', name: 'showDeleted',
@@ -723,14 +853,7 @@ export const eventFields: INodeProperties[] = [
default: false, default: false,
description: 'Whether to include hidden invitations in the result', description: 'Whether to include hidden invitations in the result',
}, },
{
displayName: 'Single Events',
name: 'singleEvents',
type: 'boolean',
default: false,
description:
'Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves',
},
{ {
displayName: 'Timezone', displayName: 'Timezone',
name: 'timeZone', name: 'timeZone',
@@ -797,6 +920,30 @@ export const eventFields: INodeProperties[] = [
}, },
default: '', default: '',
}, },
{
displayName: 'Modify',
name: 'modifyTarget',
type: 'options',
options: [
{
name: 'Recurring Event Instance',
value: 'instance',
},
{
name: 'Recurring Event',
value: 'event',
},
],
default: 'instance',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
resource: ['event'],
operation: ['update'],
eventId: [{ _cnd: { includes: '_' } }],
},
},
},
{ {
displayName: 'Use Default Reminders', displayName: 'Use Default Reminders',
name: 'useDefaultReminders', name: 'useDefaultReminders',

View File

@@ -34,3 +34,8 @@ export interface IEvent {
visibility?: string; visibility?: string;
conferenceData?: IConferenceData; conferenceData?: IConferenceData;
} }
export type RecurringEventInstance = {
recurringEventId?: string;
start: { dateTime: string; date: string };
};

View File

@@ -1,18 +1,22 @@
import { DateTime } from 'luxon';
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import type { import type {
IDataObject, IDataObject,
IExecuteFunctions, IExecuteFunctions,
IHttpRequestMethods, IHttpRequestMethods,
ILoadOptionsFunctions, ILoadOptionsFunctions,
INode,
INodeListSearchItems, INodeListSearchItems,
INodeListSearchResult, INodeListSearchResult,
IPollFunctions, IPollFunctions,
IRequestOptions, IRequestOptions,
JsonObject, JsonObject,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow'; import { NodeApiError, NodeOperationError, sleep } from 'n8n-workflow';
import { RRule } from 'rrule'; import { RRule } from 'rrule';
import type { RecurringEventInstance } from './EventInterface';
export async function googleApiRequest( export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods, method: IHttpRequestMethods,
@@ -50,7 +54,6 @@ export async function googleApiRequestAllItems(
propertyName: string, propertyName: string,
method: IHttpRequestMethods, method: IHttpRequestMethods,
endpoint: string, endpoint: string,
body: any = {}, body: any = {},
query: IDataObject = {}, query: IDataObject = {},
): Promise<any> { ): Promise<any> {
@@ -127,58 +130,75 @@ export async function getTimezones(
return { results }; return { results };
} }
type RecurentEvent = { export type RecurrentEvent = {
start: { start: {
dateTime: string; date?: string;
timeZone: string; dateTime?: string;
timeZone?: string;
}; };
end: { end: {
dateTime: string; date?: string;
timeZone: string; dateTime?: string;
timeZone?: string;
}; };
recurrence: string[]; recurrence: string[];
nextOccurrence?: { nextOccurrence?: {
start: { start: {
dateTime: string; dateTime: string;
timeZone: string; timeZone?: string;
}; };
end: { end: {
dateTime: string; dateTime: string;
timeZone: string; timeZone?: string;
}; };
}; };
}; };
export function addNextOccurrence(items: RecurentEvent[]) { export function addNextOccurrence(items: RecurrentEvent[]) {
for (const item of items) { for (const item of items) {
if (item.recurrence) { if (item.recurrence) {
let eventRecurrence; let eventRecurrence;
try { try {
eventRecurrence = item.recurrence.find((r) => r.toUpperCase().startsWith('RRULE')); eventRecurrence = item.recurrence.find((r) => r.toUpperCase().startsWith('RRULE'));
if (!eventRecurrence) continue; if (!eventRecurrence) continue;
const rrule = RRule.fromString(eventRecurrence); const start = moment(item.start.dateTime || item.end.date).utc();
const end = moment(item.end.dateTime || item.end.date).utc();
const rruleWithStartDate = `DTSTART:${start.format(
'YYYYMMDDTHHmmss',
)}Z\n${eventRecurrence}`;
const rrule = RRule.fromString(rruleWithStartDate);
const until = rrule.options?.until; const until = rrule.options?.until;
const now = new Date(); const now = moment().utc();
if (until && until < now) {
if (until && moment(until).isBefore(now)) {
continue; continue;
} }
const nextOccurrence = rrule.after(new Date()); const nextDate = rrule.after(now.toDate(), false);
item.nextOccurrence = { if (nextDate) {
start: { const nextStart = moment(nextDate);
dateTime: moment(nextOccurrence).format(),
timeZone: item.start.timeZone, const duration = moment.duration(moment(end).diff(moment(start)));
}, const nextEnd = moment(nextStart).add(duration);
end: {
dateTime: moment(nextOccurrence) item.nextOccurrence = {
.add(moment(item.end.dateTime).diff(moment(item.start.dateTime))) start: {
.format(), dateTime: nextStart.format(),
timeZone: item.end.timeZone, timeZone: item.start.timeZone,
}, },
}; end: {
dateTime: nextEnd.format(),
timeZone: item.end.timeZone,
},
};
}
} catch (error) { } catch (error) {
console.log(`Error adding next occurrence ${eventRecurrence}`); console.log(`Error adding next occurrence ${eventRecurrence}`);
} }
@@ -193,3 +213,92 @@ export function addTimezoneToDate(date: string, timezone: string) {
if (hasTimezone(date)) return date; if (hasTimezone(date)) return date;
return moment.tz(date, timezone).utc().format(); return moment.tz(date, timezone).utc().format();
} }
async function requestWithRetries(
node: INode,
requestFn: () => Promise<any>,
retryCount: number = 0,
maxRetries: number = 10,
itemIndex: number = 0,
): Promise<any> {
try {
return await requestFn();
} catch (error) {
if (!(error instanceof NodeApiError)) {
throw new NodeOperationError(node, error.message, { itemIndex });
}
if (retryCount >= maxRetries) throw error;
if (error.httpCode === '403' || error.httpCode === '429') {
const delay = 1000 * Math.pow(2, retryCount);
console.log(`Rate limit hit. Retrying in ${delay}ms... (Attempt ${retryCount + 1})`);
await sleep(delay);
return await requestWithRetries(node, requestFn, retryCount + 1, maxRetries, itemIndex);
}
throw error;
}
}
export async function googleApiRequestWithRetries({
context,
method,
resource,
body = {},
qs = {},
uri,
headers = {},
itemIndex = 0,
}: {
context: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions;
method: IHttpRequestMethods;
resource: string;
body?: any;
qs?: IDataObject;
uri?: string;
headers?: IDataObject;
itemIndex?: number;
}) {
const requestFn = async (): Promise<any> => {
return await googleApiRequest.call(context, method, resource, body, qs, uri, headers);
};
const retryCount = 0;
const maxRetries = 10;
return await requestWithRetries(context.getNode(), requestFn, retryCount, maxRetries, itemIndex);
}
export const eventExtendYearIntoFuture = (
data: RecurringEventInstance[],
timezone: string,
currentYear?: number, // for testing purposes
) => {
const thisYear = currentYear || moment().tz(timezone).year();
return data.some((event) => {
if (!event.recurringEventId) return false;
const eventStart = event.start.dateTime || event.start.date;
const eventDateTime = moment(eventStart).tz(timezone);
if (!eventDateTime.isValid()) return false;
const targetYear = eventDateTime.year();
if (targetYear - thisYear >= 1) {
return true;
} else {
return false;
}
});
};
export function dateObjectToISO<T>(date: T): string {
if (date instanceof DateTime) return date.toISO();
if (date instanceof Date) return date.toISOString();
return date as string;
}

View File

@@ -36,7 +36,7 @@
"url": "https://n8n.io/blog/your-business-doesnt-need-you-to-operate/" "url": "https://n8n.io/blog/your-business-doesnt-need-you-to-operate/"
}, },
{ {
"label": "5 workflow automations for Mattermost that we love at n8n", "label": "5 workflow automation for Mattermost that we love at n8n",
"icon": "🤖", "icon": "🤖",
"url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/" "url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/"
}, },

View File

@@ -8,22 +8,33 @@ import type {
INodeType, INodeType,
INodeTypeDescription, INodeTypeDescription,
JsonObject, JsonObject,
NodeExecutionHint,
} from 'n8n-workflow';
import {
NodeConnectionType,
NodeApiError,
NodeOperationError,
NodeExecutionOutput,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionType, NodeApiError, NodeOperationError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { calendarFields, calendarOperations } from './CalendarDescription'; import { calendarFields, calendarOperations } from './CalendarDescription';
import { eventFields, eventOperations } from './EventDescription'; import { eventFields, eventOperations } from './EventDescription';
import type { IEvent } from './EventInterface'; import type { IEvent, RecurringEventInstance } from './EventInterface';
import { import {
addNextOccurrence, addNextOccurrence,
addTimezoneToDate, addTimezoneToDate,
dateObjectToISO,
encodeURIComponentOnce, encodeURIComponentOnce,
eventExtendYearIntoFuture,
getCalendars, getCalendars,
getTimezones, getTimezones,
googleApiRequest, googleApiRequest,
googleApiRequestAllItems, googleApiRequestAllItems,
googleApiRequestWithRetries,
type RecurrentEvent,
} from './GenericFunctions'; } from './GenericFunctions';
import { sortItemKeysByPriorityList } from '../../../utils/utilities';
export class GoogleCalendar implements INodeType { export class GoogleCalendar implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@@ -31,7 +42,7 @@ export class GoogleCalendar implements INodeType {
name: 'googleCalendar', name: 'googleCalendar',
icon: 'file:googleCalendar.svg', icon: 'file:googleCalendar.svg',
group: ['input'], group: ['input'],
version: [1, 1.1, 1.2], version: [1, 1.1, 1.2, 1.3],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Calendar API', description: 'Consume Google Calendar API',
defaults: { defaults: {
@@ -132,6 +143,7 @@ export class GoogleCalendar implements INodeType {
const returnData: INodeExecutionData[] = []; const returnData: INodeExecutionData[] = [];
const length = items.length; const length = items.length;
const qs: IDataObject = {}; const qs: IDataObject = {};
const hints: NodeExecutionHint[] = [];
let responseData; let responseData;
const resource = this.getNodeParameter('resource', 0); const resource = this.getNodeParameter('resource', 0);
@@ -148,8 +160,8 @@ export class GoogleCalendar implements INodeType {
const calendarId = decodeURIComponent( const calendarId = decodeURIComponent(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
); );
const timeMin = this.getNodeParameter('timeMin', i) as string; const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
const timeMax = this.getNodeParameter('timeMax', i) as string; const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
const options = this.getNodeParameter('options', i); const options = this.getNodeParameter('options', i);
const outputFormat = options.outputFormat || 'availability'; const outputFormat = options.outputFormat || 'availability';
const tz = this.getNodeParameter('options.timezone', i, '', { const tz = this.getNodeParameter('options.timezone', i, '', {
@@ -200,8 +212,8 @@ export class GoogleCalendar implements INodeType {
const calendarId = encodeURIComponentOnce( const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
); );
const start = this.getNodeParameter('start', i) as string; const start = dateObjectToISO(this.getNodeParameter('start', i));
const end = this.getNodeParameter('end', i) as string; const end = dateObjectToISO(this.getNodeParameter('end', i));
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean; const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const additionalFields = this.getNodeParameter('additionalFields', i); const additionalFields = this.getNodeParameter('additionalFields', i);
@@ -379,16 +391,33 @@ export class GoogleCalendar implements INodeType {
if (tz) { if (tz) {
qs.timeZone = tz; qs.timeZone = tz;
} }
responseData = await googleApiRequest.call( responseData = (await googleApiRequest.call(
this, this,
'GET', 'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`, `/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{}, {},
qs, qs,
); )) as IDataObject;
if (responseData) { if (responseData) {
responseData = addNextOccurrence([responseData]); if (nodeVersion >= 1.3 && options.returnNextInstance && responseData.recurrence) {
const eventInstances =
((
(await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${responseData.id}/instances`,
{},
{
timeMin: new Date().toISOString(),
maxResults: 1,
},
)) as IDataObject
).items as IDataObject[]) || [];
responseData = eventInstances[0] ? [eventInstances[0]] : [responseData];
} else {
responseData = addNextOccurrence([responseData as RecurrentEvent]);
}
} }
} }
//https://developers.google.com/calendar/v3/reference/events/list //https://developers.google.com/calendar/v3/reference/events/list
@@ -401,6 +430,22 @@ export class GoogleCalendar implements INodeType {
const tz = this.getNodeParameter('options.timeZone', i, '', { const tz = this.getNodeParameter('options.timeZone', i, '', {
extractValue: true, extractValue: true,
}) as string; }) as string;
if (nodeVersion >= 1.3) {
const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
if (timeMin) {
qs.timeMin = addTimezoneToDate(timeMin as string, tz || timezone);
}
if (timeMax) {
qs.timeMax = addTimezoneToDate(timeMax as string, tz || timezone);
}
if (!options.recurringEventHandling || options.recurringEventHandling === 'expand') {
qs.singleEvents = true;
}
}
if (options.iCalUID) { if (options.iCalUID) {
qs.iCalUID = options.iCalUID as string; qs.iCalUID = options.iCalUID as string;
} }
@@ -423,16 +468,19 @@ export class GoogleCalendar implements INodeType {
qs.singleEvents = options.singleEvents as boolean; qs.singleEvents = options.singleEvents as boolean;
} }
if (options.timeMax) { if (options.timeMax) {
qs.timeMax = addTimezoneToDate(options.timeMax as string, tz || timezone); qs.timeMax = addTimezoneToDate(dateObjectToISO(options.timeMax), tz || timezone);
} }
if (options.timeMin) { if (options.timeMin) {
qs.timeMin = addTimezoneToDate(options.timeMin as string, tz || timezone); qs.timeMin = addTimezoneToDate(dateObjectToISO(options.timeMin), tz || timezone);
} }
if (tz) { if (tz) {
qs.timeZone = tz; qs.timeZone = tz;
} }
if (options.updatedMin) { if (options.updatedMin) {
qs.updatedMin = addTimezoneToDate(options.updatedMin as string, tz || timezone); qs.updatedMin = addTimezoneToDate(
dateObjectToISO(options.updatedMin),
tz || timezone,
);
} }
if (options.fields) { if (options.fields) {
qs.fields = options.fields as string; qs.fields = options.fields as string;
@@ -460,7 +508,76 @@ export class GoogleCalendar implements INodeType {
} }
if (responseData) { if (responseData) {
responseData = addNextOccurrence(responseData); if (nodeVersion >= 1.3 && options.recurringEventHandling === 'next') {
const updatedEvents: IDataObject[] = [];
for (const event of responseData) {
if (event.recurrence) {
const eventInstances =
((
(await googleApiRequestWithRetries({
context: this,
method: 'GET',
resource: `/calendar/v3/calendars/${calendarId}/events/${event.id}/instances`,
qs: {
timeMin: new Date().toISOString(),
maxResults: 1,
},
itemIndex: i,
})) as IDataObject
).items as IDataObject[]) || [];
updatedEvents.push(eventInstances[0] || event);
continue;
}
updatedEvents.push(event);
}
responseData = updatedEvents;
} else if (nodeVersion >= 1.3 && options.recurringEventHandling === 'first') {
responseData = responseData.filter((event: IDataObject) => {
if (
qs.timeMin &&
event.recurrence &&
event.created &&
event.created < qs.timeMin
) {
return false;
}
if (
qs.timeMax &&
event.recurrence &&
event.created &&
event.created > qs.timeMax
) {
return false;
}
return true;
});
} else if (nodeVersion < 1.3) {
// in node version above or equal to 1.3, this would correspond to the 'expand' option,
// so no need to add the next occurrence as event instances returned by the API
responseData = addNextOccurrence(responseData);
}
if (
!qs.timeMax &&
(!options.recurringEventHandling || options.recurringEventHandling === 'expand')
) {
const suggestTrim = eventExtendYearIntoFuture(
responseData as RecurringEventInstance[],
timezone,
);
if (suggestTrim) {
hints.push({
message:
"Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
location: 'outputPane',
});
}
}
} }
} }
//https://developers.google.com/calendar/v3/reference/events/patch //https://developers.google.com/calendar/v3/reference/events/patch
@@ -468,7 +585,22 @@ export class GoogleCalendar implements INodeType {
const calendarId = encodeURIComponentOnce( const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string, this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
); );
const eventId = this.getNodeParameter('eventId', i) as string; let eventId = this.getNodeParameter('eventId', i) as string;
if (nodeVersion >= 1.3) {
const modifyTarget = this.getNodeParameter('modifyTarget', i, 'instance') as string;
if (modifyTarget === 'event') {
const instance = (await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
qs,
)) as IDataObject;
eventId = instance.recurringEventId as string;
}
}
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean; const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const updateFields = this.getNodeParameter('updateFields', i); const updateFields = this.getNodeParameter('updateFields', i);
let updateTimezone = updateFields.timezone as string; let updateTimezone = updateFields.timezone as string;
@@ -658,6 +790,30 @@ export class GoogleCalendar implements INodeType {
} }
} }
} }
return [returnData];
const keysPriorityList = [
'id',
'summary',
'start',
'end',
'attendees',
'creator',
'organizer',
'description',
'location',
'created',
'updated',
];
let nodeExecutionData = returnData;
if (nodeVersion >= 1.3) {
nodeExecutionData = sortItemKeysByPriorityList(returnData, keysPriorityList);
}
if (hints.length) {
return new NodeExecutionOutput([nodeExecutionData], hints);
}
return [nodeExecutionData];
} }
} }

View File

@@ -1,4 +1,7 @@
import { addTimezoneToDate } from '../GenericFunctions'; import { DateTime } from 'luxon';
import type { RecurringEventInstance } from '../EventInterface';
import { addTimezoneToDate, dateObjectToISO, eventExtendYearIntoFuture } from '../GenericFunctions';
describe('addTimezoneToDate', () => { describe('addTimezoneToDate', () => {
it('should add timezone to date', () => { it('should add timezone to date', () => {
@@ -18,3 +21,87 @@ describe('addTimezoneToDate', () => {
expect(result4).toBe('2021-09-01T12:00:00.000+08:00'); expect(result4).toBe('2021-09-01T12:00:00.000+08:00');
}); });
}); });
describe('dateObjectToISO', () => {
test('should return ISO string for DateTime instance', () => {
const mockDateTime = DateTime.fromISO('2025-01-07T12:00:00');
const result = dateObjectToISO(mockDateTime);
expect(result).toBe('2025-01-07T12:00:00.000+00:00');
});
test('should return ISO string for Date instance', () => {
const mockDate = new Date('2025-01-07T12:00:00Z');
const result = dateObjectToISO(mockDate);
expect(result).toBe('2025-01-07T12:00:00.000Z');
});
test('should return string when input is not a DateTime or Date instance', () => {
const inputString = '2025-01-07T12:00:00';
const result = dateObjectToISO(inputString);
expect(result).toBe(inputString);
});
});
describe('eventExtendYearIntoFuture', () => {
const timezone = 'UTC';
it('should return true if any event extends into the next year', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: '2026-01-01T00:00:00Z', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(true);
});
it('should return false if no event extends into the next year', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: '2025-12-31T23:59:59Z', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(false);
});
it('should return false for invalid event start dates', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: 'invalid-date', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(false);
});
it('should return false for events without a recurringEventId', () => {
const events = [
{
recurringEventId: null,
start: { dateTime: '2025-01-01T00:00:00Z', date: null },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(false);
});
it('should handle events with only a date and no time', () => {
const events = [
{
recurringEventId: '123',
start: { dateTime: null, date: '2026-01-01' },
},
] as unknown as RecurringEventInstance[];
const result = eventExtendYearIntoFuture(events, timezone, 2025);
expect(result).toBe(true);
});
});

View File

@@ -0,0 +1,89 @@
import moment from 'moment-timezone';
import type { RecurrentEvent } from '../GenericFunctions';
import { addNextOccurrence } from '../GenericFunctions';
const mockNow = '2024-09-06T16:30:00+03:00';
jest.spyOn(global.Date, 'now').mockImplementation(() => moment(mockNow).valueOf());
describe('addNextOccurrence', () => {
it('should not modify event if no recurrence exists', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-09-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: [],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should handle event with no RRULE correctly', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-09-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: ['FREQ=WEEKLY;COUNT=2'],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should ignore recurrence if until date is in the past', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-08-01T08:00:00Z',
timeZone: 'UTC',
},
end: {
dateTime: '2024-08-01T09:00:00Z',
timeZone: 'UTC',
},
recurrence: ['RRULE:FREQ=DAILY;UNTIL=20240805T000000Z'],
},
];
const result = addNextOccurrence(event);
expect(result[0].nextOccurrence).toBeUndefined();
});
it('should handle errors gracefully without breaking and return unchanged event', () => {
const event: RecurrentEvent[] = [
{
start: {
dateTime: '2024-09-06T17:30:00+03:00',
timeZone: 'Europe/Berlin',
},
end: {
dateTime: '2024-09-06T18:00:00+03:00',
timeZone: 'Europe/Berlin',
},
recurrence: ['xxxxx'],
},
];
const result = addNextOccurrence(event);
expect(result).toEqual(event);
});
});

View File

@@ -0,0 +1,221 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { INode, IExecuteFunctions, IDataObject, NodeExecutionOutput } from 'n8n-workflow';
import * as genericFunctions from '../../GenericFunctions';
import { GoogleCalendar } from '../../GoogleCalendar.node';
let response: IDataObject[] | undefined = [];
let responseWithRetries: IDataObject | undefined = {};
jest.mock('../../GenericFunctions', () => {
const originalModule = jest.requireActual('../../GenericFunctions');
return {
...originalModule,
getTimezones: jest.fn(),
googleApiRequest: jest.fn(),
googleApiRequestAllItems: jest.fn(async function () {
return (() => response)();
}),
googleApiRequestWithRetries: jest.fn(async function () {
return (() => responseWithRetries)();
}),
addNextOccurrence: jest.fn(function (data: IDataObject[]) {
return data;
}),
};
});
describe('Google Calendar Node', () => {
let googleCalendar: GoogleCalendar;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
googleCalendar = new GoogleCalendar();
mockExecuteFunctions = mock<IExecuteFunctions>({
getInputData: jest.fn(),
getNode: jest.fn(),
getNodeParameter: jest.fn(),
getTimezone: jest.fn(),
helpers: {
constructExecutionMetaData: jest.fn().mockReturnValue([]),
},
});
response = undefined;
responseWithRetries = undefined;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Google Calendar > Event > Get Many', () => {
it('should configure get all request parameters in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
iCalUID: 'uid',
maxAttendees: 25,
orderBy: 'startTime',
query: 'test query',
recurringEventHandling: 'expand',
showDeleted: true,
showHiddenInvitations: true,
updatedMin: '2024-12-21T00:00:00',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequestAllItems).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/myCalendar/events',
{},
{
iCalUID: 'uid',
maxAttendees: 25,
orderBy: 'startTime',
q: 'test query',
showDeleted: true,
showHiddenInvitations: true,
singleEvents: true,
timeMax: '2024-12-25T23:00:00Z',
timeMin: '2024-12-19T23:00:00Z',
timeZone: 'Europe/Berlin',
updatedMin: '2024-12-20T23:00:00Z',
},
);
});
it('should configure get all recurringEventHandling equals next in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
recurringEventHandling: 'next',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
},
];
responseWithRetries = { items: [] };
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect(genericFunctions.googleApiRequestAllItems).toHaveBeenCalledWith(
'items',
'GET',
'/calendar/v3/calendars/myCalendar/events',
{},
{
timeMax: '2024-12-25T23:00:00Z',
timeMin: '2024-12-19T23:00:00Z',
timeZone: 'Europe/Berlin',
},
);
expect(genericFunctions.googleApiRequestWithRetries).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
itemIndex: 0,
resource: '/calendar/v3/calendars/myCalendar/events/undefined/instances',
}),
);
expect(result).toEqual([[]]);
});
it('should configure get all recurringEventHandling equals first in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
recurringEventHandling: 'first',
}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-19T00:00:00',
},
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-27T00:00:00',
},
];
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect(result).toEqual([[]]);
});
it('should configure get all should have hint in version 1.3', async () => {
// pre loop setup
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.3 }));
//operation setup
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); //options
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(''); //timeMax
response = [
{
recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
created: '2024-12-25T00:00:00',
recurringEventId: '1',
start: { dateTime: '2027-12-25T00:00:00' },
},
];
const result = await googleCalendar.execute.call(mockExecuteFunctions);
expect((result as NodeExecutionOutput).getHints()).toEqual([
{
message:
"Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
location: 'outputPane',
},
]);
});
});
});

View File

@@ -14,7 +14,7 @@ jest.mock('../../GenericFunctions', () => ({
encodeURIComponentOnce: jest.fn(), encodeURIComponentOnce: jest.fn(),
})); }));
describe('RespondToWebhook Node', () => { describe('Google Calendar Node', () => {
let googleCalendar: GoogleCalendar; let googleCalendar: GoogleCalendar;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>; let mockExecuteFunctions: MockProxy<IExecuteFunctions>;

View File

@@ -1,3 +1,5 @@
import type { INodeExecutionData } from 'n8n-workflow';
import { import {
compareItems, compareItems,
flattenKeys, flattenKeys,
@@ -5,6 +7,7 @@ import {
getResolvables, getResolvables,
keysToLowercase, keysToLowercase,
shuffleArray, shuffleArray,
sortItemKeysByPriorityList,
wrapData, wrapData,
} from '@utils/utilities'; } from '@utils/utilities';
@@ -252,3 +255,60 @@ describe('compareItems', () => {
expect(result).toBe(true); expect(result).toBe(true);
}); });
}); });
describe('sortItemKeysByPriorityList', () => {
it('should reorder keys based on priority list', () => {
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2 } }];
const priorityList = ['b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['b', 'a', 'c']);
});
it('should sort keys not in the priority list alphabetically', () => {
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2, d: 4 } }];
const priorityList = ['b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['b', 'a', 'c', 'd']);
});
it('should sort all keys alphabetically when priority list is empty', () => {
const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2 } }];
const priorityList: string[] = [];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'c']);
});
it('should handle an empty data array', () => {
const data: INodeExecutionData[] = [];
const priorityList = ['b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
// Expect an empty array since there is no data
expect(result).toEqual([]);
});
it('should handle a single object in the data array', () => {
const data: INodeExecutionData[] = [{ json: { d: 4, b: 2, a: 1 } }];
const priorityList = ['a', 'b', 'c'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'd']);
});
it('should handle duplicate keys in the priority list gracefully', () => {
const data: INodeExecutionData[] = [{ json: { d: 4, b: 2, a: 1 } }];
const priorityList = ['a', 'b', 'a'];
const result = sortItemKeysByPriorityList(data, priorityList);
expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'd']);
});
});

View File

@@ -427,3 +427,37 @@ export function escapeHtml(text: string): string {
} }
}); });
} }
/**
* Sorts each item json's keys by a priority list
*
* @param {INodeExecutionData[]} data The array of items which keys will be sorted
* @param {string[]} priorityList The priority list, keys of item.json will be sorted in this order first then alphabetically
*/
export function sortItemKeysByPriorityList(data: INodeExecutionData[], priorityList: string[]) {
return data.map((item) => {
const itemKeys = Object.keys(item.json);
const updatedKeysOrder = itemKeys.sort((a, b) => {
const indexA = priorityList.indexOf(a);
const indexB = priorityList.indexOf(b);
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
} else if (indexA !== -1) {
return -1;
} else if (indexB !== -1) {
return 1;
}
return a.localeCompare(b);
});
const updatedItem: IDataObject = {};
for (const key of updatedKeysOrder) {
updatedItem[key] = item.json[key];
}
item.json = updatedItem;
return item;
});
}

View File

@@ -1404,7 +1404,7 @@ function addToIssuesIfMissing(
if ( if (
(nodeProperties.type === 'string' && (value === '' || value === undefined)) || (nodeProperties.type === 'string' && (value === '' || value === undefined)) ||
(nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) || (nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) ||
(nodeProperties.type === 'dateTime' && value === undefined) || (nodeProperties.type === 'dateTime' && (value === '' || value === undefined)) ||
(nodeProperties.type === 'options' && (value === '' || value === undefined)) || (nodeProperties.type === 'options' && (value === '' || value === undefined)) ||
((nodeProperties.type === 'resourceLocator' || nodeProperties.type === 'workflowSelector') && ((nodeProperties.type === 'resourceLocator' || nodeProperties.type === 'workflowSelector') &&
!isValidResourceLocatorParameterValue(value as INodeParameterResourceLocator)) !isValidResourceLocatorParameterValue(value as INodeParameterResourceLocator))

View File

@@ -4195,4 +4195,57 @@ describe('NodeHelpers', () => {
}); });
} }
}); });
describe('getParameterIssues, required parameters validation', () => {
const testNode: INode = {
id: '12345',
name: 'Test Node',
typeVersion: 1,
type: 'n8n-nodes-base.testNode',
position: [1, 1],
parameters: {},
};
it('Should validate required dateTime parameters if empty string', () => {
const nodeProperties: INodeProperties = {
displayName: 'Date Time',
name: 'testDateTime',
type: 'dateTime',
default: '',
required: true,
};
const nodeValues: INodeParameters = {
testDateTime: '',
};
const result = getParameterIssues(nodeProperties, nodeValues, '', testNode);
expect(result).toEqual({
parameters: {
testDateTime: ['Parameter "Date Time" is required.'],
},
});
});
it('Should validate required dateTime parameters if empty undefined', () => {
const nodeProperties: INodeProperties = {
displayName: 'Date Time',
name: 'testDateTime',
type: 'dateTime',
default: '',
required: true,
};
const nodeValues: INodeParameters = {
testDateTime: undefined,
};
const result = getParameterIssues(nodeProperties, nodeValues, '', testNode);
expect(result).toEqual({
parameters: {
testDateTime: ['Parameter "Date Time" is required.'],
},
});
});
});
}); });