mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57:29 -08:00
fix(Google Calendar Node): Updates and fixes (#10715)
Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
parent
91277c44f1
commit
7227a29845
|
@ -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 <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',
|
||||
name: 'options',
|
||||
|
|
|
@ -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 <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',
|
||||
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 <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',
|
||||
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 <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',
|
||||
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 <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',
|
||||
|
@ -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',
|
||||
|
|
|
@ -34,3 +34,8 @@ export interface IEvent {
|
|||
visibility?: string;
|
||||
conferenceData?: IConferenceData;
|
||||
}
|
||||
|
||||
export type RecurringEventInstance = {
|
||||
recurringEventId?: string;
|
||||
start: { dateTime: string; date: string };
|
||||
};
|
||||
|
|
|
@ -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<any> {
|
||||
|
@ -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<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;
|
||||
}
|
||||
|
|
|
@ -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/"
|
||||
},
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -14,7 +14,7 @@ jest.mock('../../GenericFunctions', () => ({
|
|||
encodeURIComponentOnce: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('RespondToWebhook Node', () => {
|
||||
describe('Google Calendar Node', () => {
|
||||
let googleCalendar: GoogleCalendar;
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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.'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue