From 7227a29845fd178ced4d281597c62e7a03245456 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:16:29 +0200 Subject: [PATCH] fix(Google Calendar Node): Updates and fixes (#10715) Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> --- .../Google/Calendar/CalendarDescription.ts | 34 +++ .../nodes/Google/Calendar/EventDescription.ts | 167 ++++++++++++- .../nodes/Google/Calendar/EventInterface.ts | 5 + .../nodes/Google/Calendar/GenericFunctions.ts | 161 ++++++++++--- .../Google/Calendar/GoogleCalendar.node.json | 2 +- .../Google/Calendar/GoogleCalendar.node.ts | 188 +++++++++++++-- .../Calendar/test/GeneircFunctions.test.ts | 89 ++++++- .../Calendar/test/addNextOccurrence.test.ts | 89 +++++++ .../Calendar/test/node/event.getAll.test.ts | 221 ++++++++++++++++++ .../Calendar/test/node/event.update.test.ts | 2 +- .../nodes-base/test/utils/utilities.test.ts | 60 +++++ packages/nodes-base/utils/utilities.ts | 34 +++ packages/workflow/src/NodeHelpers.ts | 2 +- packages/workflow/test/NodeHelpers.test.ts | 53 +++++ 14 files changed, 1051 insertions(+), 56 deletions(-) create mode 100644 packages/nodes-base/nodes/Google/Calendar/test/addNextOccurrence.test.ts create mode 100644 packages/nodes-base/nodes/Google/Calendar/test/node/event.getAll.test.ts diff --git a/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts b/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts index d2d2950f17..59e4591dd5 100644 --- a/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts +++ b/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts @@ -84,6 +84,7 @@ export const calendarFields: INodeProperties[] = [ show: { operation: ['availability'], resource: ['calendar'], + '@version': [{ _cnd: { lt: 1.3 } }], }, }, default: '', @@ -98,11 +99,44 @@ export const calendarFields: INodeProperties[] = [ show: { operation: ['availability'], resource: ['calendar'], + '@version': [{ _cnd: { lt: 1.3 } }], }, }, default: '', 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 expression 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 expression to set a date, or switch to fixed mode to choose date from widget', + }, { displayName: 'Options', name: 'options', diff --git a/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts b/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts index 6892c88152..797445b893 100644 --- a/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts +++ b/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts @@ -112,6 +112,7 @@ export const eventFields: INodeProperties[] = [ show: { operation: ['create'], resource: ['event'], + '@version': [{ _cnd: { lt: 1.3 } }], }, }, default: '', @@ -126,11 +127,44 @@ export const eventFields: INodeProperties[] = [ show: { operation: ['create'], resource: ['event'], + '@version': [{ _cnd: { lt: 1.3 } }], }, }, default: '', 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 expression 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 expression to set a date, or switch to fixed mode to choose date from widget', + }, { displayName: 'Use Default Reminders', name: 'useDefaultReminders', @@ -553,6 +587,19 @@ export const eventFields: INodeProperties[] = [ 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.', }, + { + 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', name: 'timeZone', @@ -629,6 +676,36 @@ export const eventFields: INodeProperties[] = [ default: 50, 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 expression 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 expression 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', name: 'options', @@ -647,14 +724,39 @@ export const eventFields: INodeProperties[] = [ name: 'timeMin', type: 'dateTime', 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 expression to set a date, or switch to fixed mode to choose date from widget', + displayOptions: { + hide: { + '@version': [{ _cnd: { gte: 1.3 } }], + }, + }, }, { displayName: 'Before', name: 'timeMax', type: 'dateTime', 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 expression 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', @@ -708,6 +810,34 @@ export const eventFields: INodeProperties[] = [ description: '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', name: 'showDeleted', @@ -723,14 +853,7 @@ export const eventFields: INodeProperties[] = [ default: false, 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', name: 'timeZone', @@ -797,6 +920,30 @@ export const eventFields: INodeProperties[] = [ }, 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', name: 'useDefaultReminders', diff --git a/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts b/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts index d1907fbf2d..26212658d7 100644 --- a/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts +++ b/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts @@ -34,3 +34,8 @@ export interface IEvent { visibility?: string; conferenceData?: IConferenceData; } + +export type RecurringEventInstance = { + recurringEventId?: string; + start: { dateTime: string; date: string }; +}; diff --git a/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts index f440efaee9..100800c9f6 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts @@ -1,18 +1,22 @@ +import { DateTime } from 'luxon'; import moment from 'moment-timezone'; import type { IDataObject, IExecuteFunctions, IHttpRequestMethods, ILoadOptionsFunctions, + INode, INodeListSearchItems, INodeListSearchResult, IPollFunctions, IRequestOptions, JsonObject, } from 'n8n-workflow'; -import { NodeApiError } from 'n8n-workflow'; +import { NodeApiError, NodeOperationError, sleep } from 'n8n-workflow'; import { RRule } from 'rrule'; +import type { RecurringEventInstance } from './EventInterface'; + export async function googleApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, method: IHttpRequestMethods, @@ -50,7 +54,6 @@ export async function googleApiRequestAllItems( propertyName: string, method: IHttpRequestMethods, endpoint: string, - body: any = {}, query: IDataObject = {}, ): Promise { @@ -127,58 +130,75 @@ export async function getTimezones( return { results }; } -type RecurentEvent = { +export type RecurrentEvent = { start: { - dateTime: string; - timeZone: string; + date?: string; + dateTime?: string; + timeZone?: string; }; end: { - dateTime: string; - timeZone: string; + date?: string; + dateTime?: string; + timeZone?: string; }; recurrence: string[]; nextOccurrence?: { start: { dateTime: string; - timeZone: string; + timeZone?: string; }; end: { dateTime: string; - timeZone: string; + timeZone?: string; }; }; }; -export function addNextOccurrence(items: RecurentEvent[]) { +export function addNextOccurrence(items: RecurrentEvent[]) { for (const item of items) { if (item.recurrence) { let eventRecurrence; try { eventRecurrence = item.recurrence.find((r) => r.toUpperCase().startsWith('RRULE')); + 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 now = new Date(); - if (until && until < now) { + const now = moment().utc(); + + if (until && moment(until).isBefore(now)) { continue; } - const nextOccurrence = rrule.after(new Date()); + const nextDate = rrule.after(now.toDate(), false); - item.nextOccurrence = { - start: { - dateTime: moment(nextOccurrence).format(), - timeZone: item.start.timeZone, - }, - end: { - dateTime: moment(nextOccurrence) - .add(moment(item.end.dateTime).diff(moment(item.start.dateTime))) - .format(), - timeZone: item.end.timeZone, - }, - }; + if (nextDate) { + const nextStart = moment(nextDate); + + const duration = moment.duration(moment(end).diff(moment(start))); + const nextEnd = moment(nextStart).add(duration); + + item.nextOccurrence = { + start: { + dateTime: nextStart.format(), + timeZone: item.start.timeZone, + }, + end: { + dateTime: nextEnd.format(), + timeZone: item.end.timeZone, + }, + }; + } } catch (error) { console.log(`Error adding next occurrence ${eventRecurrence}`); } @@ -193,3 +213,92 @@ export function addTimezoneToDate(date: string, timezone: string) { if (hasTimezone(date)) return date; return moment.tz(date, timezone).utc().format(); } + +async function requestWithRetries( + node: INode, + requestFn: () => Promise, + retryCount: number = 0, + maxRetries: number = 10, + itemIndex: number = 0, +): Promise { + 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 => { + 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(date: T): string { + if (date instanceof DateTime) return date.toISO(); + if (date instanceof Date) return date.toISOString(); + return date as string; +} diff --git a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.json b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.json index 737faf8fa1..690ea4f243 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.json +++ b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.json @@ -36,7 +36,7 @@ "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": "🤖", "url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/" }, diff --git a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts index 189f737888..505b1f525d 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts +++ b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts @@ -8,22 +8,33 @@ import type { INodeType, INodeTypeDescription, JsonObject, + NodeExecutionHint, +} from 'n8n-workflow'; +import { + NodeConnectionType, + NodeApiError, + NodeOperationError, + NodeExecutionOutput, } from 'n8n-workflow'; -import { NodeConnectionType, NodeApiError, NodeOperationError } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { calendarFields, calendarOperations } from './CalendarDescription'; import { eventFields, eventOperations } from './EventDescription'; -import type { IEvent } from './EventInterface'; +import type { IEvent, RecurringEventInstance } from './EventInterface'; import { addNextOccurrence, addTimezoneToDate, + dateObjectToISO, encodeURIComponentOnce, + eventExtendYearIntoFuture, getCalendars, getTimezones, googleApiRequest, googleApiRequestAllItems, + googleApiRequestWithRetries, + type RecurrentEvent, } from './GenericFunctions'; +import { sortItemKeysByPriorityList } from '../../../utils/utilities'; export class GoogleCalendar implements INodeType { description: INodeTypeDescription = { @@ -31,7 +42,7 @@ export class GoogleCalendar implements INodeType { name: 'googleCalendar', icon: 'file:googleCalendar.svg', group: ['input'], - version: [1, 1.1, 1.2], + version: [1, 1.1, 1.2, 1.3], subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Consume Google Calendar API', defaults: { @@ -132,6 +143,7 @@ export class GoogleCalendar implements INodeType { const returnData: INodeExecutionData[] = []; const length = items.length; const qs: IDataObject = {}; + const hints: NodeExecutionHint[] = []; let responseData; const resource = this.getNodeParameter('resource', 0); @@ -148,8 +160,8 @@ export class GoogleCalendar implements INodeType { 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 timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i)); + const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i)); const options = this.getNodeParameter('options', i); const outputFormat = options.outputFormat || 'availability'; const tz = this.getNodeParameter('options.timezone', i, '', { @@ -200,8 +212,8 @@ export class GoogleCalendar implements INodeType { 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 start = dateObjectToISO(this.getNodeParameter('start', i)); + const end = dateObjectToISO(this.getNodeParameter('end', i)); const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean; const additionalFields = this.getNodeParameter('additionalFields', i); @@ -379,16 +391,33 @@ export class GoogleCalendar implements INodeType { if (tz) { qs.timeZone = tz; } - responseData = await googleApiRequest.call( + responseData = (await googleApiRequest.call( this, 'GET', `/calendar/v3/calendars/${calendarId}/events/${eventId}`, {}, qs, - ); + )) as IDataObject; 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 @@ -401,6 +430,22 @@ export class GoogleCalendar implements INodeType { const tz = this.getNodeParameter('options.timeZone', i, '', { extractValue: true, }) 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) { qs.iCalUID = options.iCalUID as string; } @@ -423,16 +468,19 @@ export class GoogleCalendar implements INodeType { qs.singleEvents = options.singleEvents as boolean; } if (options.timeMax) { - qs.timeMax = addTimezoneToDate(options.timeMax as string, tz || timezone); + qs.timeMax = addTimezoneToDate(dateObjectToISO(options.timeMax), tz || timezone); } if (options.timeMin) { - qs.timeMin = addTimezoneToDate(options.timeMin as string, tz || timezone); + qs.timeMin = addTimezoneToDate(dateObjectToISO(options.timeMin), tz || timezone); } if (tz) { qs.timeZone = tz; } if (options.updatedMin) { - qs.updatedMin = addTimezoneToDate(options.updatedMin as string, tz || timezone); + qs.updatedMin = addTimezoneToDate( + dateObjectToISO(options.updatedMin), + tz || timezone, + ); } if (options.fields) { qs.fields = options.fields as string; @@ -460,7 +508,76 @@ export class GoogleCalendar implements INodeType { } 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 @@ -468,7 +585,22 @@ export class GoogleCalendar implements INodeType { const calendarId = encodeURIComponentOnce( 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 updateFields = this.getNodeParameter('updateFields', i); 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]; } } diff --git a/packages/nodes-base/nodes/Google/Calendar/test/GeneircFunctions.test.ts b/packages/nodes-base/nodes/Google/Calendar/test/GeneircFunctions.test.ts index 1da9122e2c..6dab75ee0a 100644 --- a/packages/nodes-base/nodes/Google/Calendar/test/GeneircFunctions.test.ts +++ b/packages/nodes-base/nodes/Google/Calendar/test/GeneircFunctions.test.ts @@ -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', () => { it('should add timezone to date', () => { @@ -18,3 +21,87 @@ describe('addTimezoneToDate', () => { 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); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Calendar/test/addNextOccurrence.test.ts b/packages/nodes-base/nodes/Google/Calendar/test/addNextOccurrence.test.ts new file mode 100644 index 0000000000..5390500ca6 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Calendar/test/addNextOccurrence.test.ts @@ -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); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Calendar/test/node/event.getAll.test.ts b/packages/nodes-base/nodes/Google/Calendar/test/node/event.getAll.test.ts new file mode 100644 index 0000000000..5cdaf3e25b --- /dev/null +++ b/packages/nodes-base/nodes/Google/Calendar/test/node/event.getAll.test.ts @@ -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; + + beforeEach(() => { + googleCalendar = new GoogleCalendar(); + mockExecuteFunctions = mock({ + 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({ 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({ 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({ 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({ 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', + }, + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts b/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts index b242c73063..1cada0cf06 100644 --- a/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts +++ b/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts @@ -14,7 +14,7 @@ jest.mock('../../GenericFunctions', () => ({ encodeURIComponentOnce: jest.fn(), })); -describe('RespondToWebhook Node', () => { +describe('Google Calendar Node', () => { let googleCalendar: GoogleCalendar; let mockExecuteFunctions: MockProxy; diff --git a/packages/nodes-base/test/utils/utilities.test.ts b/packages/nodes-base/test/utils/utilities.test.ts index 4ffc0835bb..a30552f6f5 100644 --- a/packages/nodes-base/test/utils/utilities.test.ts +++ b/packages/nodes-base/test/utils/utilities.test.ts @@ -1,3 +1,5 @@ +import type { INodeExecutionData } from 'n8n-workflow'; + import { compareItems, flattenKeys, @@ -5,6 +7,7 @@ import { getResolvables, keysToLowercase, shuffleArray, + sortItemKeysByPriorityList, wrapData, } from '@utils/utilities'; @@ -252,3 +255,60 @@ describe('compareItems', () => { 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']); + }); +}); diff --git a/packages/nodes-base/utils/utilities.ts b/packages/nodes-base/utils/utilities.ts index cae18ae90b..74834b2aab 100644 --- a/packages/nodes-base/utils/utilities.ts +++ b/packages/nodes-base/utils/utilities.ts @@ -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; + }); +} diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 22883a73bb..bbeeca87c0 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -1404,7 +1404,7 @@ function addToIssuesIfMissing( if ( (nodeProperties.type === 'string' && (value === '' || value === undefined)) || (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 === 'resourceLocator' || nodeProperties.type === 'workflowSelector') && !isValidResourceLocatorParameterValue(value as INodeParameterResourceLocator)) diff --git a/packages/workflow/test/NodeHelpers.test.ts b/packages/workflow/test/NodeHelpers.test.ts index 8b3d9b3b02..964dedc6a9 100644 --- a/packages/workflow/test/NodeHelpers.test.ts +++ b/packages/workflow/test/NodeHelpers.test.ts @@ -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.'], + }, + }); + }); + }); });