From 6de424bc325571e26280828d865dc063f53ad5b2 Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:17:16 +0530 Subject: [PATCH] handle localization and property parameters in CalDAV iCal parsing (#16519) Co-authored-by: guillim --- .../drivers/caldav/lib/caldav.client.ts | 17 +++- .../icalDataExtractPropertyValue.spec.ts | 97 +++++++++++++++++++ .../lib/utils/icalDataExtractPropertyValue.ts | 50 ++++++++++ 3 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/__tests__/icalDataExtractPropertyValue.spec.ts create mode 100644 packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue.ts diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts index 3aff9b2197..f948026484 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client.ts @@ -13,6 +13,7 @@ import { syncCollection, } from 'tsdav'; +import { icalDataExtractPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue'; import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service'; import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; import { @@ -269,18 +270,26 @@ export class CalDAVClient { const event = events[0] as ical.VEvent; const participants = this.extractParticipantsFromEvent(event); + const title = icalDataExtractPropertyValue( + event.summary, + 'Untitled Event', + ); + const description = icalDataExtractPropertyValue(event.description); + const location = icalDataExtractPropertyValue(event.location); + const conferenceLinkUrl = icalDataExtractPropertyValue(event.url); + return { id: objectUrl, - title: event.summary || 'Untitled Event', + title, iCalUid: event.uid || '', - description: event.description || '', + description, startsAt: event.start.toISOString(), endsAt: event.end.toISOString(), - location: event.location || '', + location, isFullDay: this.isFullDayEvent(rawData), isCanceled: event.status === 'CANCELLED', conferenceLinkLabel: '', - conferenceLinkUrl: event.url, + conferenceLinkUrl, externalCreatedAt: event.created?.toISOString() || new Date().toISOString(), externalUpdatedAt: diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/__tests__/icalDataExtractPropertyValue.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/__tests__/icalDataExtractPropertyValue.spec.ts new file mode 100644 index 0000000000..9a3b40e2d7 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/__tests__/icalDataExtractPropertyValue.spec.ts @@ -0,0 +1,97 @@ +import { icalDataExtractPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue'; + +describe('icalDataExtractPropertyValue', () => { + describe('properties with parameters (RFC 5545 Section 3.2)', () => { + it('should extract value from property object with val and params', () => { + const property = { + val: 'Meeting Title', + params: { LANGUAGE: 'en-US' }, + }; + + const result = icalDataExtractPropertyValue(property); + + expect(result).toBe('Meeting Title'); + }); + + it('should handle property with val but no params', () => { + const property = { + val: 'Conference Room A', + }; + + const result = icalDataExtractPropertyValue(property); + + expect(result).toBe('Conference Room A'); + }); + + it('should convert non-string val to string', () => { + const property = { + val: 12345, + params: { TYPE: 'INTEGER' }, + } as any; + + const result = icalDataExtractPropertyValue(property); + + expect(result).toBe('12345'); + }); + + it('should handle property with empty string val', () => { + const property = { + val: '', + params: { LANGUAGE: 'de-DE' }, + }; + + const result = icalDataExtractPropertyValue(property, 'default'); + + expect(result).toBe(''); + }); + }); + + describe('multiple values in a single property (RFC 5545 Section 3.1.2)', () => { + it('should join multiple string values with comma and space', () => { + const property = ['Value 1', 'Value 2', 'Value 3'] as any; + + const result = icalDataExtractPropertyValue(property); + + expect(result).toBe('Value 1, Value 2, Value 3'); + }); + + it('should handle array of property objects with val', () => { + const property = [ + { val: 'First Value', params: { LANGUAGE: 'en' } }, + { val: 'Second Value', params: { LANGUAGE: 'fr' } }, + ] as any; + + const result = icalDataExtractPropertyValue(property); + + expect(result).toBe('First Value, Second Value'); + }); + + it('should filter out empty values from array', () => { + const property = ['Value 1', '', 'Value 3', { val: '' }] as any; + + const result = icalDataExtractPropertyValue(property); + + expect(result).toBe('Value 1, Value 3'); + }); + + it('should return default value when array contains only empty values', () => { + const property = ['', { val: '' }, null] as any; + + const result = icalDataExtractPropertyValue(property, 'No values'); + + expect(result).toBe('No values'); + }); + + it('should handle mixed array of strings and objects', () => { + const property = [ + 'Plain String', + { val: 'Object Value', params: {} }, + 'Another String', + ] as any; + + const result = icalDataExtractPropertyValue(property); + + expect(result).toBe('Plain String, Object Value, Another String'); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue.ts new file mode 100644 index 0000000000..3387ddde92 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue.ts @@ -0,0 +1,50 @@ +import { isNonEmptyString, isString } from '@sniptt/guards'; +import { isDefined } from 'class-validator'; + +/** + * Extracts the string value from an iCal property that may have parameters. + * Per RFC 5545, properties can have parameters like LANGUAGE=de-DE, which causes + * node-ical to return an object with `val` and `params` instead of a plain string. + * + * RFC 5545 Section 3.1.2 also allows multiple values in a single property. + * + * @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2 (Property Parameters) + * @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.1.2 (Multiple Values) + */ +export const icalDataExtractPropertyValue = ( + property: + | string + | { val?: string; params?: Record } + | undefined, + defaultValue = '', +): string => { + if (!isDefined(property)) { + return defaultValue; + } + + if (isNonEmptyString(property)) { + return property; + } + + if (isDefined(property) && typeof property === 'object') { + if ('val' in property && isDefined(property.val)) { + return isString(property.val) ? property.val : String(property.val); + } + + if (Array.isArray(property)) { + const values = property + .map((item) => { + if (isNonEmptyString(item)) return item; + if (isDefined(item) && typeof item === 'object' && item?.val) + return String(item.val); + + return ''; + }) + .filter(Boolean); + + return values.length > 0 ? values.join(', ') : defaultValue; + } + } + + return defaultValue; +};