Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/docs/content/docs/search/google-calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ In the add-source form, **More options** contains optional **Metadata tags**. Se

## What gets indexed

Sim indexes event titles, descriptions, times, locations, and the selected attendee information. All-day events and individual occurrences of recurring meetings are supported. Results link back to Google Calendar.
Sim indexes event titles, descriptions, times, locations, and the selected attendee information. All-day events and individual occurrences of recurring meetings are supported. An invitation you declined stays searchable and is marked `Response: declined`. Results link back to Google Calendar.

Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing).
Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Status entries such as working location, out of office, focus time, and birthdays are not indexed. A shared calendar where you can see only free or busy times contributes nothing, since those blocks have no title or description. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing).

Search schedules syncs hourly. Event edits, cancellations, access changes, and events moving outside the date window are reconciled during background sync. The first sync may take longer, and results appear as indexing progresses.

Expand Down
91 changes: 90 additions & 1 deletion apps/sim/connectors/google-calendar/google-calendar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ describe('Google Calendar Search isolation', () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
accessRole: 'reader',
items: [{ id: EVENT.id, updated: EVENT.updated, start: EVENT.start, end: EVENT.end }],
items: [{ ...EVENT, description: undefined, organizer: undefined, attendees: undefined }],
})
)
const restricted = await googleCalendarConnector.listDocuments('token', {}, undefined, alice)
Expand All @@ -189,6 +189,95 @@ describe('Google Calendar Search isolation', () => {
expect(restricted.documents[0].metadata?.organizer).toBe('')
})

it('keeps an untitled meeting that still names a room or its participants', async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
items: [
{ ...EVENT, id: 'room', summary: undefined, description: undefined },
{
...EVENT,
id: 'bare-location',
summary: undefined,
description: undefined,
organizer: undefined,
attendees: undefined,
},
],
})
)
const result = await googleCalendarConnector.listDocuments('token', {}, undefined, alice)
expect(result.documents.map((doc) => doc.externalId)).toEqual([
expect.stringContaining('room'),
expect.stringContaining('bare-location'),
])
expect(result.documents[0].content).toContain(ATTENDEE_NAME)
expect(result.documents[1].content).toContain(EVENT.location)
})

it('withdraws a free/busy time block that carries no title or description', async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
accessRole: 'freeBusyReader',
items: [{ id: EVENT.id, updated: EVENT.updated, start: EVENT.start, end: EVENT.end }],
})
)
const result = await googleCalendarConnector.listDocuments('token', {}, undefined, alice)
expect(result).toEqual({ documents: [], hasMore: false })
})

it('asks Google for meetings only and drops status entries it still returns', async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
items: [
{ ...EVENT, id: 'wfh', summary: 'Home', eventType: 'workingLocation' },
{ ...EVENT, id: 'ooo', summary: 'Out of office', eventType: 'outOfOffice' },
{ ...EVENT, id: 'focus', summary: 'Focus time', eventType: 'focusTime' },
{ ...EVENT, id: 'bday', summary: 'Birthday', eventType: 'birthday' },
{ ...EVENT, id: 'meeting', eventType: 'default' },
EVENT,
],
})
)
const result = await googleCalendarConnector.listDocuments('token', {}, undefined, alice)
expect(result.documents.map((doc) => doc.externalId)).toEqual([
expect.stringContaining('meeting'),
expect.stringContaining(EVENT.id),
])
const listUrl = new URL(String(fetchMock.mock.calls[0][0]))
expect(listUrl.searchParams.getAll('eventTypes')).toEqual(['default'])
})

it('returns null for a status entry fetched directly', async () => {
const listing = await googleCalendarConnector.listDocuments('token', {}, undefined, alice)
fetchMock.mockResolvedValueOnce(jsonResponse({ ...EVENT, eventType: 'outOfOffice' }))
expect(
await googleCalendarConnector.getDocument('token', {}, listing.documents[0].externalId, alice)
).toBeNull()
})

it('keeps a declined invitation and marks the response on it', async () => {
const declined = {
...EVENT,
attendees: [
...EVENT.attendees,
{ email: 'alice@example.com', self: true, responseStatus: 'declined' },
],
}
fetchMock.mockResolvedValueOnce(jsonResponse({ items: [declined] }))
const [doc] = (await googleCalendarConnector.listDocuments('token', {}, undefined, alice))
.documents
expect(doc.content).toContain('Response: declined')
expect(doc.metadata?.responseStatus).toBe('declined')

const accepted = await listOne({})
expect(accepted.content).not.toContain('Response:')
expect(accepted.metadata?.responseStatus).toBeUndefined()

fetchMock.mockResolvedValueOnce(jsonResponse({ items: [declined] }))
const workspaceDeclined = await listOne({})
expect(workspaceDeclined.contentHash).toBe(`${accepted.contentHash}:declined`)
})

it('withdraws cancelled events, including instances of recurring events', async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ items: [{ ...EVENT, status: 'cancelled', recurringEventId: 'series' }] })
Expand Down
50 changes: 46 additions & 4 deletions apps/sim/connectors/google-calendar/google-calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,41 @@ function readIncludeAttendees(sourceConfig: Record<string, unknown>): boolean {
*/
const NO_ATTENDEES_HASH_SUFFIX = ':noattendees'

/**
* Appended to the metadata-only content hash of an invitation the connected
* account declined, so an event indexed before the response was recorded picks
* up the response line without waiting for the organizer to edit it.
*/
const DECLINED_HASH_SUFFIX = ':declined'

/** Only `default` events describe meetings; the listing asks Google for these alone. */
const INDEXED_EVENT_TYPE = 'default'

/** The connected account's own response to an invitation, when Google reports it. */
function memberResponseStatus(event: CalendarEvent): string | undefined {
return event.attendees?.find((attendee) => attendee.self)?.responseStatus
}

/**
* Whether the event carries something to search. Status entries (working
* location, out of office, focus time, birthdays) describe availability rather
* than a meeting. A reader with free/busy access alone receives a bare time
* block: Google strips the title, description, location, organizer and
* attendees, so an event with none of those is that placeholder. An untitled
* meeting that still names a room or its participants stays indexed.
*/
function isSearchableEvent(event: CalendarEvent): boolean {
if (event.status === 'cancelled') return false
if (event.eventType && event.eventType !== INDEXED_EVENT_TYPE) return false
return Boolean(
event.summary?.trim() ||
event.description?.trim() ||
event.location?.trim() ||
event.organizer ||
(event.attendees && event.attendees.length > 0)
)
}

/**
* Counts attendees excluding rooms/equipment, matching what the content renderer lists.
*/
Expand Down Expand Up @@ -182,6 +217,10 @@ function eventToContent(event: CalendarEvent, includeAttendees: boolean): string
parts.push(`Location: ${event.location}`)
}

if (memberResponseStatus(event) === 'declined') {
parts.push('Response: declined')
}

if (includeAttendees) {
const organizer = formatOrganizer(event.organizer)
if (organizer) {
Expand Down Expand Up @@ -271,13 +310,14 @@ async function eventToDocument(
includeAttendees: boolean,
syncContext?: Record<string, unknown>
): Promise<ExternalDocument | null> {
if (event.status === 'cancelled') return null
if (!isSearchableEvent(event)) return null

const content = eventToContent(event, includeAttendees)
if (!content.trim()) return null

const startTime = event.start?.dateTime || event.start?.date || ''
const attendeeCount = countAttendees(event.attendees)
const responseStatus = memberResponseStatus(event)

const memberScoped = isPerMemberListing(syncContext)
const externalId = memberDocumentId(
Expand All @@ -287,7 +327,9 @@ async function eventToDocument(
const baseHash = isMultiCalendar
? `gcal:${calendarId}:${event.id}:${event.updated ?? ''}`
: `gcal:${event.id}:${event.updated ?? ''}`
const contentHash = includeAttendees ? baseHash : `${baseHash}${NO_ATTENDEES_HASH_SUFFIX}`
const attendeeHash = includeAttendees ? baseHash : `${baseHash}${NO_ATTENDEES_HASH_SUFFIX}`
const contentHash =
responseStatus === 'declined' ? `${attendeeHash}${DECLINED_HASH_SUFFIX}` : attendeeHash

const metadata = {
calendarId,
Expand All @@ -296,6 +338,7 @@ async function eventToDocument(
location: event.location || '',
organizer: includeAttendees ? formatOrganizer(event.organizer) : '',
attendeeCount,
...(responseStatus ? { responseStatus } : {}),
isAllDay: isAllDayEvent(event),
eventDate: startTime,
updatedTime: event.updated,
Expand Down Expand Up @@ -393,6 +436,7 @@ export const googleCalendarConnector: ConnectorConfig = {
const queryParams = new URLSearchParams({
singleEvents: 'true',
orderBy: 'startTime',
eventTypes: INDEXED_EVENT_TYPE,
maxResults: String(pageSize),
timeMin,
timeMax,
Expand Down Expand Up @@ -594,8 +638,6 @@ export const googleCalendarConnector: ConnectorConfig = {

const event = (await response.json()) as CalendarEvent

if (event.status === 'cancelled') return null

return eventToDocument(
event,
calendarId,
Expand Down
Loading