From f8d56e73f86b67d3ceb167ad2face9ad1e8ea700 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sun, 18 Oct 2026 05:30:37 +0000 Subject: [PATCH] fix(calendar): import Google Meet links of synced Google events Incoming Google Calendar sync only copied event.location into the Huly event and ignored hangoutLink / conferenceData, so the Meet link of a Google event was lost in Huly. When a Google event has no location, the video conference link (hangoutLink, or the video entry point of conferenceData) is now stored as the Huly event location, where the location editor already offers a button to open links. An explicit Google location still takes precedence. The mapping is deterministic, so repeated syncs do not duplicate anything. On the Huly to Google direction the location diff now compares against the same derived value, so pushing an unrelated Huly edit does not copy the Meet link into the Google location field. Fixes #6848 Signed-off-by: Michael Uray --- .../src/__tests__/conference.test.ts | 67 +++++++++++++++++++ .../calendar/pod-calendar/src/calendar.ts | 3 +- .../calendar/pod-calendar/src/conference.ts | 40 +++++++++++ .../pod-calendar/src/outcomingClient.ts | 3 +- services/calendar/pod-calendar/src/sync.ts | 8 ++- 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 services/calendar/pod-calendar/src/__tests__/conference.test.ts create mode 100644 services/calendar/pod-calendar/src/conference.ts diff --git a/services/calendar/pod-calendar/src/__tests__/conference.test.ts b/services/calendar/pod-calendar/src/__tests__/conference.test.ts new file mode 100644 index 00000000000..063d947d89d --- /dev/null +++ b/services/calendar/pod-calendar/src/__tests__/conference.test.ts @@ -0,0 +1,67 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { getConferenceLink, getEventLocation } from '../conference' + +const meet = 'https://meet.google.com/abc-defg-hij' + +describe('getConferenceLink', () => { + it('returns hangoutLink', () => { + expect(getConferenceLink({ hangoutLink: meet })).toBe(meet) + }) + + it('falls back to the video entry point of conferenceData', () => { + expect( + getConferenceLink({ + conferenceData: { + entryPoints: [ + { entryPointType: 'phone', uri: 'tel:+1-555-0100' }, + { entryPointType: 'video', uri: meet } + ] + } + }) + ).toBe(meet) + }) + + it('returns undefined without conference', () => { + expect(getConferenceLink({})).toBeUndefined() + expect(getConferenceLink({ conferenceData: { entryPoints: [{ entryPointType: 'phone', uri: 'tel:1' }] } })).toBe( + undefined + ) + }) +}) + +describe('getEventLocation', () => { + it('keeps an explicit location', () => { + expect(getEventLocation({ location: 'Room 1', hangoutLink: meet })).toBe('Room 1') + }) + + it('uses the conference link when location is missing or empty', () => { + expect(getEventLocation({ hangoutLink: meet })).toBe(meet) + expect(getEventLocation({ location: '', hangoutLink: meet })).toBe(meet) + expect(getEventLocation({ location: ' ', hangoutLink: meet })).toBe(meet) + }) + + it('is stable on repeated calls', () => { + const event = { hangoutLink: meet } + expect(getEventLocation(event)).toBe(getEventLocation(event)) + }) + + it('keeps previous behaviour without conference', () => { + expect(getEventLocation({})).toBeUndefined() + expect(getEventLocation({ location: null })).toBeUndefined() + expect(getEventLocation({ location: '' })).toBe('') + }) +}) diff --git a/services/calendar/pod-calendar/src/calendar.ts b/services/calendar/pod-calendar/src/calendar.ts index af7647b68c7..c07e50800ac 100644 --- a/services/calendar/pod-calendar/src/calendar.ts +++ b/services/calendar/pod-calendar/src/calendar.ts @@ -26,6 +26,7 @@ import { areEqualMarkups, htmlToMarkup, isEmptyMarkup, jsonToHTML, markupToJSON import { deepEqual } from 'fast-equals' import { OAuth2Client } from 'google-auth-library' import { calendar_v3 } from 'googleapis' +import { getEventLocation } from './conference' import { getRateLimitter, RateLimiter } from './rateLimiter' import { type Token } from './types' import { @@ -358,7 +359,7 @@ export class CalendarClient { }) event.description = description } - if (current.location !== event.location) { + if (current.location !== getEventLocation(event)) { res = true this.ctx.info('Update event diff: location', { calendarId: current.calendar, diff --git a/services/calendar/pod-calendar/src/conference.ts b/services/calendar/pod-calendar/src/conference.ts new file mode 100644 index 00000000000..c493f6fc6f1 --- /dev/null +++ b/services/calendar/pod-calendar/src/conference.ts @@ -0,0 +1,40 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { calendar_v3 } from 'googleapis' + +/** + * Returns the video conference link (e.g. Google Meet) attached to a Google event, if any. + */ +export function getConferenceLink (event: calendar_v3.Schema$Event): string | undefined { + if (event.hangoutLink != null && event.hangoutLink !== '') { + return event.hangoutLink + } + const entryPoint = event.conferenceData?.entryPoints?.find( + (p) => p.entryPointType === 'video' && p.uri != null && p.uri !== '' + ) + return entryPoint?.uri ?? undefined +} + +/** + * Returns the location to store in Huly for a Google event. + * An explicit location always wins; the conference link is used only when the location is empty. + */ +export function getEventLocation (event: calendar_v3.Schema$Event): string | undefined { + if (event.location != null && event.location.trim() !== '') { + return event.location + } + return getConferenceLink(event) ?? event.location ?? undefined +} diff --git a/services/calendar/pod-calendar/src/outcomingClient.ts b/services/calendar/pod-calendar/src/outcomingClient.ts index a88e957cd74..2afe1da5faf 100644 --- a/services/calendar/pod-calendar/src/outcomingClient.ts +++ b/services/calendar/pod-calendar/src/outcomingClient.ts @@ -28,6 +28,7 @@ import { deepEqual } from 'fast-equals' import { OAuth2Client } from 'google-auth-library' import { calendar_v3 } from 'googleapis' import { getClient } from './client' +import { getEventLocation } from './conference' import { setSyncHistory } from './kvsUtils' import { lock, synced } from './mutex' import { getRateLimitter, RateLimiter } from './rateLimiter' @@ -276,7 +277,7 @@ export class OutcomingClient { }) event.description = description } - if (current.location !== event.location) { + if (current.location !== getEventLocation(event)) { res = true this.ctx.info('Update event diff: location', { calendarId: current.calendar, diff --git a/services/calendar/pod-calendar/src/sync.ts b/services/calendar/pod-calendar/src/sync.ts index ec42c5d2580..2b89f8077d5 100644 --- a/services/calendar/pod-calendar/src/sync.ts +++ b/services/calendar/pod-calendar/src/sync.ts @@ -44,6 +44,7 @@ import { htmlToMarkup } from '@hcengineering/text' import { deepEqual } from 'fast-equals' import { calendar_v3 } from 'googleapis' import { getClient } from './client' +import { getEventLocation } from './conference' import { getCalendarsSyncHistory, getEventHistory, setCalendarsSyncHistory, setEventHistory } from './kvsUtils' import { lock } from './mutex' import { getRateLimitter, RateLimiter } from './rateLimiter' @@ -401,7 +402,7 @@ export class IncomingSyncManager { allDay: event.start?.date != null, description: htmlToMarkup(event.description ?? ''), title: event.summary ?? '', - location: event.location ?? undefined, + location: getEventLocation(event), participants: participants[0], eventId: event.id ?? '', calendar: _calendar, @@ -486,8 +487,9 @@ export class IncomingSyncManager { res.externalParticipants = participants[1] } } - if (event.location != null) { - res.location = event.location + const location = getEventLocation(event) + if (location != null) { + res.location = location } if (event.description != null) { res.description = htmlToMarkup(event.description)