import type { IExecuteFunctions, IDataObject, ILoadOptionsFunctions, INodeExecutionData, INodePropertyOptions, INodeType, INodeTypeDescription, JsonObject, } from 'n8n-workflow'; import { NodeConnectionType, NodeApiError, NodeOperationError } from 'n8n-workflow'; import moment from 'moment-timezone'; import { v4 as uuid } from 'uuid'; import { addNextOccurrence, addTimezoneToDate, encodeURIComponentOnce, getCalendars, getTimezones, googleApiRequest, googleApiRequestAllItems, } from './GenericFunctions'; import { eventFields, eventOperations } from './EventDescription'; import { calendarFields, calendarOperations } from './CalendarDescription'; import type { IEvent } from './EventInterface'; export class GoogleCalendar implements INodeType { description: INodeTypeDescription = { displayName: 'Google Calendar', name: 'googleCalendar', icon: 'file:googleCalendar.svg', group: ['input'], version: [1, 1.1, 1.2], subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Consume Google Calendar API', defaults: { name: 'Google Calendar', }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], usableAsTool: true, credentials: [ { name: 'googleCalendarOAuth2Api', required: true, }, ], properties: [ { displayName: 'Resource', name: 'resource', type: 'options', noDataExpression: true, options: [ { name: 'Calendar', value: 'calendar', }, { name: 'Event', value: 'event', }, ], default: 'event', }, ...calendarOperations, ...calendarFields, ...eventOperations, ...eventFields, { displayName: 'This node will use the time zone set in n8n’s settings, but you can override this in the workflow settings', name: 'useN8nTimeZone', type: 'notice', default: '', }, ], }; methods = { listSearch: { getCalendars, getTimezones, }, loadOptions: { // Get all the calendars to display them to user so that they can // select them easily async getConferenceSolutions(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; const calendar = this.getCurrentNodeParameter('calendar', { extractValue: true }) as string; const possibleSolutions: IDataObject = { eventHangout: 'Google Hangout', eventNamedHangout: 'Google Hangout Classic', hangoutsMeet: 'Google Meet', }; const { conferenceProperties: { allowedConferenceSolutionTypes }, } = await googleApiRequest.call( this, 'GET', `/calendar/v3/users/me/calendarList/${calendar}`, ); for (const solution of allowedConferenceSolutionTypes) { returnData.push({ name: possibleSolutions[solution] as string, value: solution, }); } return returnData; }, // Get all the colors to display them to user so that they can // select them easily async getColors(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; const { event } = await googleApiRequest.call(this, 'GET', '/calendar/v3/colors'); for (const key of Object.keys(event as IDataObject)) { const colorName = `Background: ${event[key].background} - Foreground: ${event[key].foreground}`; const colorId = key; returnData.push({ name: `${colorName}`, value: colorId, }); } return returnData; }, }, }; async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; const length = items.length; const qs: IDataObject = {}; let responseData; const resource = this.getNodeParameter('resource', 0); const operation = this.getNodeParameter('operation', 0); const timezone = this.getTimezone(); const nodeVersion = this.getNode().typeVersion; for (let i = 0; i < length; i++) { try { if (resource === 'calendar') { //https://developers.google.com/calendar/v3/reference/freebusy/query if (operation === 'availability') { // 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(), timeMax: moment(timeMax).utc().format(), items: [ { id: calendarId, }, ], timeZone: tz || timezone, }; responseData = await googleApiRequest.call( this, 'POST', '/calendar/v3/freeBusy', body, {}, ); if (responseData.calendars[calendarId].errors) { throw new NodeApiError( this.getNode(), responseData.calendars[calendarId] as JsonObject, { itemIndex: i, }, ); } if (outputFormat === 'availability') { responseData = { available: !responseData.calendars[calendarId].busy.length, }; } else if (outputFormat === 'bookedSlots') { responseData = responseData.calendars[calendarId].busy; } } } if (resource === 'event') { //https://developers.google.com/calendar/v3/reference/events/insert if (operation === 'create') { 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; const additionalFields = this.getNodeParameter('additionalFields', i); if (additionalFields.maxAttendees) { qs.maxAttendees = additionalFields.maxAttendees as number; } if (additionalFields.sendNotifications) { qs.sendNotifications = additionalFields.sendNotifications as boolean; } if (additionalFields.sendUpdates) { qs.sendUpdates = additionalFields.sendUpdates as string; } const body: IEvent = { start: { dateTime: moment.tz(start, timezone).utc().format(), timeZone: timezone, }, end: { dateTime: moment.tz(end, timezone).utc().format(), timeZone: timezone, }, }; if (additionalFields.attendees) { body.attendees = []; (additionalFields.attendees as string[]).forEach((attendee) => { body.attendees!.push.apply( body.attendees, attendee .split(',') .map((a) => a.trim()) .map((email) => ({ email })), ); }); } if (additionalFields.color) { body.colorId = additionalFields.color as string; } if (additionalFields.description) { body.description = additionalFields.description as string; } if (additionalFields.guestsCanInviteOthers) { body.guestsCanInviteOthers = additionalFields.guestsCanInviteOthers as boolean; } if (additionalFields.guestsCanModify) { body.guestsCanModify = additionalFields.guestsCanModify as boolean; } if (additionalFields.guestsCanSeeOtherGuests) { body.guestsCanSeeOtherGuests = additionalFields.guestsCanSeeOtherGuests as boolean; } if (additionalFields.id) { body.id = additionalFields.id as string; } if (additionalFields.location) { body.location = additionalFields.location as string; } if (additionalFields.summary) { body.summary = additionalFields.summary as string; } if (additionalFields.showMeAs) { body.transparency = additionalFields.showMeAs as string; } if (additionalFields.visibility) { body.visibility = additionalFields.visibility as string; } if (!useDefaultReminders) { const reminders = (this.getNodeParameter('remindersUi', i) as IDataObject) .remindersValues as IDataObject[]; body.reminders = { useDefault: false, }; if (reminders) { body.reminders.overrides = reminders; } } if (additionalFields.allday === 'yes') { body.start = { date: timezone ? moment.tz(start, timezone).utc(true).format('YYYY-MM-DD') : moment.tz(start, moment.tz.guess()).utc(true).format('YYYY-MM-DD'), }; body.end = { date: timezone ? moment.tz(end, timezone).utc(true).format('YYYY-MM-DD') : moment.tz(end, moment.tz.guess()).utc(true).format('YYYY-MM-DD'), }; } //exampel: RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=10;UNTIL=20110701T170000Z //https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html body.recurrence = []; if (additionalFields.rrule) { body.recurrence = [`RRULE:${additionalFields.rrule}`]; } else { if (additionalFields.repeatHowManyTimes && additionalFields.repeatUntil) { throw new NodeOperationError( this.getNode(), "You can set either 'Repeat How Many Times' or 'Repeat Until' but not both", { itemIndex: i }, ); } if (additionalFields.repeatFrecuency) { body.recurrence?.push( `FREQ=${(additionalFields.repeatFrecuency as string).toUpperCase()};`, ); } if (additionalFields.repeatHowManyTimes) { body.recurrence?.push(`COUNT=${additionalFields.repeatHowManyTimes};`); } if (additionalFields.repeatUntil) { const repeatUntil = moment(additionalFields.repeatUntil as string) .utc() .format('YYYYMMDDTHHmmss'); body.recurrence?.push(`UNTIL=${repeatUntil}Z`); } if (body.recurrence.length !== 0) { body.recurrence = [`RRULE:${body.recurrence.join('')}`]; } } if (additionalFields.conferenceDataUi) { const conferenceData = (additionalFields.conferenceDataUi as IDataObject) .conferenceDataValues as IDataObject; if (conferenceData) { qs.conferenceDataVersion = 1; body.conferenceData = { createRequest: { requestId: uuid(), conferenceSolution: { type: conferenceData.conferenceSolution as string, }, }, }; } } responseData = await googleApiRequest.call( this, 'POST', `/calendar/v3/calendars/${calendarId}/events`, body, qs, ); } //https://developers.google.com/calendar/v3/reference/events/delete if (operation === 'delete') { 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) { qs.sendUpdates = options.sendUpdates as number; } responseData = await googleApiRequest.call( this, 'DELETE', `/calendar/v3/calendars/${calendarId}/events/${eventId}`, {}, ); responseData = { success: true }; } //https://developers.google.com/calendar/v3/reference/events/get if (operation === 'get') { 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 (tz) { qs.timeZone = tz; } responseData = await googleApiRequest.call( this, 'GET', `/calendar/v3/calendars/${calendarId}/events/${eventId}`, {}, qs, ); if (responseData) { responseData = addNextOccurrence([responseData]); } } //https://developers.google.com/calendar/v3/reference/events/list if (operation === 'getAll') { const returnAll = this.getNodeParameter('returnAll', i); 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; } if (options.maxAttendees) { qs.maxAttendees = options.maxAttendees as number; } if (options.orderBy) { qs.orderBy = options.orderBy as number; } if (options.query) { qs.q = options.query as number; } if (options.showDeleted) { qs.showDeleted = options.showDeleted as boolean; } if (options.showHiddenInvitations) { qs.showHiddenInvitations = options.showHiddenInvitations as boolean; } if (options.singleEvents) { qs.singleEvents = options.singleEvents as boolean; } if (options.timeMax) { qs.timeMax = addTimezoneToDate(options.timeMax as string, tz || timezone); } if (options.timeMin) { qs.timeMin = addTimezoneToDate(options.timeMin as string, tz || timezone); } if (tz) { qs.timeZone = tz; } if (options.updatedMin) { qs.updatedMin = addTimezoneToDate(options.updatedMin as string, tz || timezone); } if (options.fields) { qs.fields = options.fields as string; } if (returnAll) { responseData = await googleApiRequestAllItems.call( this, 'items', 'GET', `/calendar/v3/calendars/${calendarId}/events`, {}, qs, ); } else { qs.maxResults = this.getNodeParameter('limit', i); responseData = await googleApiRequest.call( this, 'GET', `/calendar/v3/calendars/${calendarId}/events`, {}, qs, ); responseData = responseData.items; } if (responseData) { responseData = addNextOccurrence(responseData); } } //https://developers.google.com/calendar/v3/reference/events/patch if (operation === 'update') { 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); let updateTimezone = updateFields.timezone as string; if (nodeVersion > 1 && updateTimezone === undefined) { updateTimezone = timezone; } if (updateFields.maxAttendees) { qs.maxAttendees = updateFields.maxAttendees as number; } if (updateFields.sendNotifications) { qs.sendNotifications = updateFields.sendNotifications as boolean; } if (updateFields.sendUpdates) { qs.sendUpdates = updateFields.sendUpdates as string; } const body: IEvent = {}; if (updateFields.start) { body.start = { dateTime: moment.tz(updateFields.start, updateTimezone).utc().format(), timeZone: updateTimezone, }; } if (updateFields.end) { body.end = { dateTime: moment.tz(updateFields.end, updateTimezone).utc().format(), timeZone: updateTimezone, }; } // nodeVersion < 1.2 if (updateFields.attendees) { body.attendees = []; (updateFields.attendees as string[]).forEach((attendee) => { body.attendees!.push.apply( body.attendees, attendee .split(',') .map((a) => a.trim()) .map((email) => ({ email })), ); }); } // nodeVersion >= 1.2 if (updateFields.attendeesUi) { const { mode, attendees } = ( updateFields.attendeesUi as { values: { mode: string; attendees: string[]; }; } ).values; body.attendees = []; if (mode === 'add') { const event = await googleApiRequest.call( this, 'GET', `/calendar/v3/calendars/${calendarId}/events/${eventId}`, ); ((event?.attendees as IDataObject[]) || []).forEach((attendee) => { body.attendees?.push(attendee); }); } (attendees as string[]).forEach((attendee) => { body.attendees!.push.apply( body.attendees, attendee .split(',') .map((a) => a.trim()) .map((email) => ({ email })), ); }); } if (updateFields.color) { body.colorId = updateFields.color as string; } if (updateFields.description) { body.description = updateFields.description as string; } if (updateFields.guestsCanInviteOthers) { body.guestsCanInviteOthers = updateFields.guestsCanInviteOthers as boolean; } if (updateFields.guestsCanModify) { body.guestsCanModify = updateFields.guestsCanModify as boolean; } if (updateFields.guestsCanSeeOtherGuests) { body.guestsCanSeeOtherGuests = updateFields.guestsCanSeeOtherGuests as boolean; } if (updateFields.id) { body.id = updateFields.id as string; } if (updateFields.location) { body.location = updateFields.location as string; } if (updateFields.summary) { body.summary = updateFields.summary as string; } if (updateFields.showMeAs) { body.transparency = updateFields.showMeAs as string; } if (updateFields.visibility) { body.visibility = updateFields.visibility as string; } if (!useDefaultReminders) { const reminders = (this.getNodeParameter('remindersUi', i) as IDataObject) .remindersValues as IDataObject[]; body.reminders = { useDefault: false, }; if (reminders) { body.reminders.overrides = reminders; } } if (updateFields.allday === 'yes' && updateFields.start && updateFields.end) { body.start = { date: updateTimezone ? moment.tz(updateFields.start, updateTimezone).utc(true).format('YYYY-MM-DD') : moment.tz(updateFields.start, moment.tz.guess()).utc(true).format('YYYY-MM-DD'), }; body.end = { date: updateTimezone ? moment.tz(updateFields.end, updateTimezone).utc(true).format('YYYY-MM-DD') : moment.tz(updateFields.end, moment.tz.guess()).utc(true).format('YYYY-MM-DD'), }; } //example: RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=10;UNTIL=20110701T170000Z //https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html body.recurrence = []; if (updateFields.rrule) { body.recurrence = [`RRULE:${updateFields.rrule}`]; } else { if (updateFields.repeatHowManyTimes && updateFields.repeatUntil) { throw new NodeOperationError( this.getNode(), "You can set either 'Repeat How Many Times' or 'Repeat Until' but not both", { itemIndex: i }, ); } if (updateFields.repeatFrecuency) { body.recurrence?.push( `FREQ=${(updateFields.repeatFrecuency as string).toUpperCase()};`, ); } if (updateFields.repeatHowManyTimes) { body.recurrence?.push(`COUNT=${updateFields.repeatHowManyTimes};`); } if (updateFields.repeatUntil) { const repeatUntil = moment(updateFields.repeatUntil as string) .utc() .format('YYYYMMDDTHHmmss'); body.recurrence?.push(`UNTIL=${repeatUntil}Z`); } if (body.recurrence.length !== 0) { body.recurrence = [`RRULE:${body.recurrence.join('')}`]; } else { delete body.recurrence; } } responseData = await googleApiRequest.call( this, 'PATCH', `/calendar/v3/calendars/${calendarId}/events/${eventId}`, body, qs, ); } } const executionData = this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray(responseData as IDataObject), { itemData: { item: i } }, ); returnData.push(...executionData); } catch (error) { if (!this.continueOnFail()) { throw error; } else { const executionErrorData = this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray({ error: error.message }), { itemData: { item: i } }, ); returnData.push(...executionErrorData); continue; } } } return [returnData]; } }