Compare commits

...

9 commits

Author SHA1 Message Date
Michael Kret 22819862d2
Merge a8eced5e45 into 8fb31e8459 2024-09-19 16:26:36 +03:00
Tomi Turtiainen 8fb31e8459
fix(benchmark): Simplify binary data scenario setup and use larger binary file (#10879) 2024-09-19 16:21:55 +03:00
Ricardo Espinoza cee57b6504
refactor(editor): Migrate LogStreaming.store.ts to composition API (#10719)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run
2024-09-19 09:15:01 -04:00
Michael Kret a8eced5e45 display options update 2024-09-12 08:41:27 +03:00
Michael Kret 57ad8ddabc target selector for instance update 2024-09-12 08:16:01 +03:00
Michael Kret 5311caf8f7 Merge branch 'master' of https://github.com/n8n-io/n8n into node-1597-google-calendar-next-occurrence-of-recurring-event-is 2024-09-12 07:45:57 +03:00
Michael Kret fb4ae619aa get event instances 2024-09-12 07:45:53 +03:00
Michael Kret 3f27176059 Merge branch 'master' of https://github.com/n8n-io/n8n into node-1597-google-calendar-next-occurrence-of-recurring-event-is 2024-09-11 11:38:25 +03:00
Michael Kret 5fda7acc91 fix 2024-09-06 16:17:22 +03:00
8 changed files with 540 additions and 233 deletions

View file

@ -3,8 +3,9 @@ import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
const file = open(__ENV.SCRIPT_FILE_PATH, 'b');
const filename = String(__ENV.SCRIPT_FILE_PATH).split('/').pop();
// This creates a 2MB file (16 * 128 * 1024 = 2 * 1024 * 1024 = 2MB)
const file = Array.from({ length: 128 * 1024 }, () => Math.random().toString().slice(2)).join('');
const filename = 'test.bin';
export default function () {
const data = {

View file

@ -77,7 +77,6 @@ export function handleSummary(data) {
env: {
API_BASE_URL: this.opts.n8nApiBaseUrl,
K6_CLOUD_TOKEN: this.opts.k6ApiToken,
SCRIPT_FILE_PATH: augmentedTestScriptPath,
},
stdio: 'inherit',
})`${k6ExecutablePath} run ${flattedFlags} ${augmentedTestScriptPath}`;

View file

@ -7,8 +7,9 @@ import {
hasDestinationId,
saveDestinationToDb,
sendTestMessageToDestination,
} from '../api/eventbus.ee';
} from '@/api/eventbus.ee';
import { useRootStore } from './root.store';
import { ref } from 'vue';
export interface EventSelectionItem {
selected: boolean;
@ -32,221 +33,242 @@ export interface DestinationSettingsStore {
[key: string]: DestinationStoreItem;
}
export const useLogStreamingStore = defineStore('logStreaming', {
state: () => ({
items: {} as DestinationSettingsStore,
eventNames: new Set<string>(),
}),
getters: {},
actions: {
addDestination(destination: MessageEventBusDestinationOptions) {
if (destination.id && this.items[destination.id]) {
this.items[destination.id].destination = destination;
} else {
this.setSelectionAndBuildItems(destination);
export const useLogStreamingStore = defineStore('logStreaming', () => {
const items = ref<DestinationSettingsStore>({});
const eventNames = ref(new Set<string>());
const rootStore = useRootStore();
const addDestination = (destination: MessageEventBusDestinationOptions) => {
if (destination.id && items.value[destination.id]) {
items.value[destination.id].destination = destination;
} else {
setSelectionAndBuildItems(destination);
}
};
const setSelectionAndBuildItems = (destination: MessageEventBusDestinationOptions) => {
if (destination.id) {
if (!items.value[destination.id]) {
items.value[destination.id] = {
destination,
selectedEvents: new Set<string>(),
eventGroups: [],
isNew: false,
} as DestinationStoreItem;
}
},
getDestination(destinationId: string): MessageEventBusDestinationOptions | undefined {
if (this.items[destinationId]) {
return this.items[destinationId].destination;
} else {
return;
items.value[destination.id]?.selectedEvents?.clear();
if (destination.subscribedEvents) {
for (const eventName of destination.subscribedEvents) {
items.value[destination.id]?.selectedEvents?.add(eventName);
}
}
},
getAllDestinations(): MessageEventBusDestinationOptions[] {
const destinations: MessageEventBusDestinationOptions[] = [];
for (const key of Object.keys(this.items)) {
destinations.push(this.items[key].destination);
}
return destinations;
},
updateDestination(destination: MessageEventBusDestinationOptions) {
if (destination.id && this.items[destination.id]) {
this.$patch((state) => {
if (destination.id && this.items[destination.id]) {
state.items[destination.id].destination = destination;
}
// to trigger refresh
state.items = { ...state.items };
});
}
},
removeDestination(destinationId: string) {
if (!destinationId) return;
delete this.items[destinationId];
if (this.items[destinationId]) {
this.$patch({
items: {
...this.items,
},
});
}
},
clearDestinations() {
this.items = {};
},
addEventName(name: string) {
this.eventNames.add(name);
},
removeEventName(name: string) {
this.eventNames.delete(name);
},
clearEventNames() {
this.eventNames.clear();
},
addSelectedEvent(id: string, name: string) {
this.items[id]?.selectedEvents?.add(name);
this.setSelectedInGroup(id, name, true);
},
removeSelectedEvent(id: string, name: string) {
this.items[id]?.selectedEvents?.delete(name);
this.setSelectedInGroup(id, name, false);
},
getSelectedEvents(destinationId: string): string[] {
const selectedEvents: string[] = [];
if (this.items[destinationId]) {
for (const group of this.items[destinationId].eventGroups) {
if (group.selected) {
selectedEvents.push(group.name);
}
for (const event of group.children) {
if (event.selected) {
selectedEvents.push(event.name);
items.value[destination.id].eventGroups = eventGroupsFromStringList(
eventNames.value,
items.value[destination.id]?.selectedEvents,
);
}
};
const getDestination = (destinationId: string) => {
if (items.value[destinationId]) {
return items.value[destinationId].destination;
} else {
return;
}
};
const getAllDestinations = () => {
const destinations: MessageEventBusDestinationOptions[] = [];
for (const key of Object.keys(items)) {
destinations.push(items.value[key].destination);
}
return destinations;
};
const clearDestinations = () => {
items.value = {};
};
const addEventName = (name: string) => {
eventNames.value.add(name);
};
const removeEventName = (name: string) => {
eventNames.value.delete(name);
};
const clearEventNames = () => {
eventNames.value.clear();
};
const addSelectedEvent = (id: string, name: string) => {
items.value[id]?.selectedEvents?.add(name);
setSelectedInGroup(id, name, true);
};
const removeSelectedEvent = (id: string, name: string) => {
items.value[id]?.selectedEvents?.delete(name);
setSelectedInGroup(id, name, false);
};
const setSelectedInGroup = (destinationId: string, name: string, isSelected: boolean) => {
if (items.value[destinationId]) {
const groupName = eventGroupFromEventName(name);
const groupIndex = items.value[destinationId].eventGroups.findIndex(
(e) => e.name === groupName,
);
if (groupIndex > -1) {
if (groupName === name) {
items.value[destinationId].eventGroups[groupIndex].selected = isSelected;
} else {
const eventIndex = items.value[destinationId].eventGroups[groupIndex].children.findIndex(
(e) => e.name === name,
);
if (eventIndex > -1) {
items.value[destinationId].eventGroups[groupIndex].children[eventIndex].selected =
isSelected;
if (isSelected) {
items.value[destinationId].eventGroups[groupIndex].indeterminate = isSelected;
} else {
let anySelected = false;
for (
let i = 0;
i < items.value[destinationId].eventGroups[groupIndex].children.length;
i++
) {
anySelected =
anySelected ||
items.value[destinationId].eventGroups[groupIndex].children[i].selected;
}
items.value[destinationId].eventGroups[groupIndex].indeterminate = anySelected;
}
}
}
}
return selectedEvents;
},
setSelectedInGroup(destinationId: string, name: string, isSelected: boolean) {
if (this.items[destinationId]) {
const groupName = eventGroupFromEventName(name);
const groupIndex = this.items[destinationId].eventGroups.findIndex(
(e) => e.name === groupName,
);
if (groupIndex > -1) {
if (groupName === name) {
this.$patch((state) => {
state.items[destinationId].eventGroups[groupIndex].selected = isSelected;
});
} else {
const eventIndex = this.items[destinationId].eventGroups[groupIndex].children.findIndex(
(e) => e.name === name,
);
if (eventIndex > -1) {
this.$patch((state) => {
state.items[destinationId].eventGroups[groupIndex].children[eventIndex].selected =
isSelected;
if (isSelected) {
state.items[destinationId].eventGroups[groupIndex].indeterminate = isSelected;
} else {
let anySelected = false;
for (
let i = 0;
i < state.items[destinationId].eventGroups[groupIndex].children.length;
i++
) {
anySelected =
anySelected ||
state.items[destinationId].eventGroups[groupIndex].children[i].selected;
}
state.items[destinationId].eventGroups[groupIndex].indeterminate = anySelected;
}
});
}
}
};
const removeDestinationItemTree = (id: string) => {
delete items.value[id];
};
const updateDestination = (destination: MessageEventBusDestinationOptions) => {
if (destination.id && items.value[destination.id]) {
items.value[destination.id].destination = destination;
}
};
const removeDestination = (destinationId: string) => {
if (!destinationId) return;
delete items.value[destinationId];
};
const getSelectedEvents = (destinationId: string): string[] => {
const selectedEvents: string[] = [];
if (items.value[destinationId]) {
for (const group of items.value[destinationId].eventGroups) {
if (group.selected) {
selectedEvents.push(group.name);
}
for (const event of group.children) {
if (event.selected) {
selectedEvents.push(event.name);
}
}
}
},
removeDestinationItemTree(id: string) {
delete this.items[id];
},
clearDestinationItemTrees() {
this.items = {} as DestinationSettingsStore;
},
setSelectionAndBuildItems(destination: MessageEventBusDestinationOptions) {
if (destination.id) {
if (!this.items[destination.id]) {
this.items[destination.id] = {
destination,
selectedEvents: new Set<string>(),
eventGroups: [],
isNew: false,
} as DestinationStoreItem;
}
this.items[destination.id]?.selectedEvents?.clear();
if (destination.subscribedEvents) {
for (const eventName of destination.subscribedEvents) {
this.items[destination.id]?.selectedEvents?.add(eventName);
}
}
this.items[destination.id].eventGroups = eventGroupsFromStringList(
this.eventNames,
this.items[destination.id]?.selectedEvents,
);
}
},
async saveDestination(destination: MessageEventBusDestinationOptions): Promise<boolean> {
if (!hasDestinationId(destination)) {
return false;
}
}
return selectedEvents;
};
const rootStore = useRootStore();
const selectedEvents = this.getSelectedEvents(destination.id);
try {
await saveDestinationToDb(rootStore.restApiContext, destination, selectedEvents);
this.updateDestination(destination);
return true;
} catch (e) {
return false;
}
},
async sendTestMessage(destination: MessageEventBusDestinationOptions): Promise<boolean> {
if (!hasDestinationId(destination)) {
return false;
}
const saveDestination = async (
destination: MessageEventBusDestinationOptions,
): Promise<boolean> => {
if (!hasDestinationId(destination)) {
return false;
}
const rootStore = useRootStore();
const testResult = await sendTestMessageToDestination(rootStore.restApiContext, destination);
return testResult;
},
async fetchEventNames(): Promise<string[]> {
const rootStore = useRootStore();
return await getEventNamesFromBackend(rootStore.restApiContext);
},
async fetchDestinations(): Promise<MessageEventBusDestinationOptions[]> {
const rootStore = useRootStore();
return await getDestinationsFromBackend(rootStore.restApiContext);
},
async deleteDestination(destinationId: string) {
const rootStore = useRootStore();
await deleteDestinationFromDb(rootStore.restApiContext, destinationId);
this.removeDestination(destinationId);
},
},
const selectedEvents = getSelectedEvents(destination.id);
try {
await saveDestinationToDb(rootStore.restApiContext, destination, selectedEvents);
updateDestination(destination);
return true;
} catch (e) {
return false;
}
};
const sendTestMessage = async (
destination: MessageEventBusDestinationOptions,
): Promise<boolean> => {
if (!hasDestinationId(destination)) {
return false;
}
const testResult = await sendTestMessageToDestination(rootStore.restApiContext, destination);
return testResult;
};
const fetchEventNames = async () => {
return await getEventNamesFromBackend(rootStore.restApiContext);
};
const fetchDestinations = async (): Promise<MessageEventBusDestinationOptions[]> => {
return await getDestinationsFromBackend(rootStore.restApiContext);
};
const deleteDestination = async (destinationId: string) => {
await deleteDestinationFromDb(rootStore.restApiContext, destinationId);
removeDestination(destinationId);
};
return {
addDestination,
setSelectionAndBuildItems,
getDestination,
getAllDestinations,
clearDestinations,
addEventName,
removeEventName,
clearEventNames,
addSelectedEvent,
removeSelectedEvent,
setSelectedInGroup,
removeDestinationItemTree,
updateDestination,
removeDestination,
getSelectedEvents,
saveDestination,
sendTestMessage,
fetchEventNames,
fetchDestinations,
deleteDestination,
items,
};
});
export function eventGroupFromEventName(eventName: string): string | undefined {
export const eventGroupFromEventName = (eventName: string): string | undefined => {
const matches = eventName.match(/^[\w\s]+\.[\w\s]+/);
if (matches && matches?.length > 0) {
return matches[0];
}
return undefined;
}
};
function prettifyEventName(label: string, group = ''): string {
const prettifyEventName = (label: string, group = ''): string => {
label = label.replace(group + '.', '');
if (label.length > 0) {
label = label[0].toUpperCase() + label.substring(1);
label = label.replaceAll('.', ' ');
}
return label;
}
};
export function eventGroupsFromStringList(
export const eventGroupsFromStringList = (
dottedList: Set<string>,
selectionList: Set<string> = new Set(),
) {
) => {
const result = [] as EventSelectionGroup[];
const eventNameArray = Array.from(dottedList.values());
@ -287,4 +309,4 @@ export function eventGroupsFromStringList(
result.push(collection);
}
return result;
}
};

View file

@ -95,7 +95,7 @@ export default defineComponent({
},
async getDestinationDataFromBackend(): Promise<void> {
this.logStreamingStore.clearEventNames();
this.logStreamingStore.clearDestinationItemTrees();
this.logStreamingStore.clearDestinations();
this.allDestinations = [];
const eventNamesData = await this.logStreamingStore.fetchEventNames();
if (eventNamesData) {

View file

@ -553,6 +553,18 @@ 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: true,
description: 'Whether to return next instances of recurring event, instead of event itself',
displayOptions: {
show: {
'@version': [1.2],
},
},
},
{
displayName: 'Timezone',
name: 'timeZone',
@ -656,6 +668,14 @@ export const eventFields: INodeProperties[] = [
default: '',
description: 'At least some part of the event must be before this time',
},
{
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',
},
{
displayName: 'Fields',
name: 'fields',
@ -708,6 +728,18 @@ export const eventFields: INodeProperties[] = [
description:
'Free text search terms to find events that match these terms in any field, except for extended properties',
},
{
displayName: 'Return Next Instance of Recurring Event',
name: 'returnNextInstance',
type: 'boolean',
default: true,
description: 'Whether to return next instances of recurring event, instead of event itself',
displayOptions: {
show: {
'@version': [1.2],
},
},
},
{
displayName: 'Show Deleted',
name: 'showDeleted',
@ -723,14 +755,6 @@ 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 +821,30 @@ export const eventFields: INodeProperties[] = [
},
default: '',
},
{
displayName: 'Modify',
name: 'modifyTarget',
type: 'options',
options: [
{
name: 'Reccuring Event Instance',
value: 'instance',
},
{
name: 'Reccuring Event',
value: 'event',
},
],
default: 'instance',
displayOptions: {
show: {
'@version': [1.2],
resource: ['event'],
operation: ['update'],
eventId: [{ _cnd: { includes: '_' } }],
},
},
},
{
displayName: 'Use Default Reminders',
name: 'useDefaultReminders',

View file

@ -3,13 +3,14 @@ import type {
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 moment from 'moment-timezone';
import { RRule } from 'rrule';
@ -52,7 +53,6 @@ export async function googleApiRequestAllItems(
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: any = {},
query: IDataObject = {},
): Promise<any> {
@ -129,24 +129,26 @@ export async function getTimezones(
return { results };
}
type RecurentEvent = {
export type RecurentEvent = {
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;
};
};
};
@ -157,30 +159,45 @@ export function addNextOccurrence(items: RecurentEvent[]) {
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}`);
}
@ -195,3 +212,60 @@ 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);
} else {
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);
}

View file

@ -20,6 +20,8 @@ import {
getTimezones,
googleApiRequest,
googleApiRequestAllItems,
googleApiRequestWithRetries,
type RecurentEvent,
} from './GenericFunctions';
import { eventFields, eventOperations } from './EventDescription';
@ -34,7 +36,7 @@ export class GoogleCalendar implements INodeType {
name: 'googleCalendar',
icon: 'file:googleCalendar.svg',
group: ['input'],
version: [1, 1.1],
version: [1, 1.1, 1.2],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Calendar API',
defaults: {
@ -48,6 +50,16 @@ export class GoogleCalendar implements INodeType {
required: true,
},
],
hints: [
{
message:
"Turn off 'Return Next Instance of Recurring Event' option to improve efficiency and return reccuring events itself instead of their next event instance",
displayCondition:
'={{ $nodeVersion >= 1.2 && $parameter["operation"] === "getAll" && $parameter["options"]["returnNextInstance"] !== false}}',
whenToDisplay: 'beforeExecution',
location: 'outputPane',
},
],
properties: [
{
displayName: 'Resource',
@ -381,16 +393,33 @@ export class GoogleCalendar implements INodeType {
if (tz) {
qs.timeZone = tz;
}
responseData = await googleApiRequest.call(
const event = (await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
qs,
);
)) as IDataObject;
if (responseData) {
responseData = addNextOccurrence([responseData]);
if (event) {
if (nodeVersion >= 1.2 && options.returnNextInstance !== false && event.recurrence) {
const eventInstances =
((
(await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${event.id}/instances`,
{},
{
timeMin: new Date().toISOString(),
maxResults: 1,
},
)) as IDataObject
).items as IDataObject[]) || [];
responseData = eventInstances[0] ? [eventInstances[0]] : [responseData];
} else {
responseData = addNextOccurrence([responseData as RecurentEvent]);
}
}
}
//https://developers.google.com/calendar/v3/reference/events/list
@ -462,7 +491,38 @@ export class GoogleCalendar implements INodeType {
}
if (responseData) {
responseData = addNextOccurrence(responseData);
if (
nodeVersion >= 1.2 &&
!options.singleEvents &&
options.returnNextInstance !== false
) {
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 {
responseData = addNextOccurrence(responseData);
}
}
}
//https://developers.google.com/calendar/v3/reference/events/patch
@ -470,7 +530,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.2) {
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;

View file

@ -0,0 +1,88 @@
import moment from 'moment-timezone';
import type { RecurentEvent } 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: RecurentEvent[] = [
{
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: RecurentEvent[] = [
{
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: RecurentEvent[] = [
{
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 unchenged event', () => {
const event: RecurentEvent[] = [
{
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);
});
});