From 7edb738fb65527aeff825c54ed7b679b8da59f36 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 17:36:18 -0700 Subject: [PATCH 1/6] improvement(kb-connectors): multi-select fields + Slack bot/app message extraction Adds multi-value support to KB connector configuration fields and applies it across 8 connectors: Jira (projects), Confluence (spaces), Slack (channels), Microsoft Teams (channels), Google Calendar (calendars), Gmail (labels), Notion (databases), and Linear (teams + projects). Each connector emits byte-identical externalId for legacy single-value configs so existing rows reconcile in place via the sync engine's externalId-keyed matching. Framework changes: - ConnectorConfigField gains `multi?: boolean` - New `parseMultiValue` helper in @/connectors/utils - useConnectorConfigFields state model upgraded to string|string[] - ConnectorSelectorField renders Combobox in multi-select mode when `field.multi` - Add/edit connector modals handle array values end-to-end Per-connector specifics: - Jira: JQL `project in (...)` for 2+ keys, `project = X` for one - Confluence: routes through CQL `space in (...)` when multi; v2 fast path stays for single+no-label; also fixes selector returning space.id instead of space.key - Slack: loops per channel emitting one document each; extracts text from attachments and Block Kit blocks (incl. nested attachment.blocks where GitHub embeds PR bodies); contentHash bumped to slack-v2: to force one-time re-embed - Microsoft Teams: loops per channel within a single team - Google Calendar: compound cursor across calendars; single-calendar keeps legacy externalId/contentHash for zero-churn - Gmail: (label:A OR label:B) with quoted-form for labels with spaces - Notion: sequential walk via JSON compound cursor; single-DB keeps bare cursor - Linear: GraphQL IdComparator.in for multi, eq for single --- .../add-connector-modal.tsx | 30 +- .../connector-selector-field.tsx | 51 +++- .../edit-connector-modal.tsx | 79 ++++- .../[id]/hooks/use-connector-config-fields.ts | 92 ++++-- apps/sim/connectors/confluence/confluence.ts | 67 ++-- apps/sim/connectors/gmail/gmail.ts | 69 +++-- .../google-calendar/google-calendar.ts | 186 +++++++++--- apps/sim/connectors/jira/jira.ts | 60 ++-- apps/sim/connectors/linear/linear.ts | 66 ++-- .../microsoft-teams/microsoft-teams.ts | 133 ++++---- apps/sim/connectors/notion/notion.ts | 195 ++++++++---- apps/sim/connectors/slack/slack.ts | 286 +++++++++++++----- apps/sim/connectors/types.ts | 8 + apps/sim/connectors/utils.ts | 35 +++ .../providers/confluence/selectors.ts | 8 +- 15 files changed, 1012 insertions(+), 353 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index ad215ab4a19..55760bb818d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -29,6 +29,7 @@ import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' +import type { ConfigFieldValue } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { CONNECTOR_REGISTRY } from '@/connectors/registry' @@ -108,6 +109,7 @@ export function AddConnectorModal({ setCanonicalModes, canonicalGroups, isFieldVisible, + isFieldPopulated, handleFieldChange, toggleCanonicalMode, resolveSourceConfig, @@ -150,7 +152,7 @@ export function AddConnectorModal({ for (const field of connectorConfig.configFields) { if (!field.required) continue if (!isFieldVisible(field)) continue - if (!sourceConfig[field.id]?.trim()) return false + if (!isFieldPopulated(field)) return false } return true }, [ @@ -158,8 +160,8 @@ export function AddConnectorModal({ isApiKeyMode, apiKeyValue, effectiveCredentialId, - sourceConfig, isFieldVisible, + isFieldPopulated, ]) const handleSubmit = () => { @@ -169,7 +171,13 @@ export function AddConnectorModal({ const resolvedConfig: Record = {} for (const [key, value] of Object.entries(resolveSourceConfig())) { - if (value) resolvedConfig[key] = value + if (Array.isArray(value)) { + if (value.length > 0) resolvedConfig[key] = value + } else if (typeof value === 'string') { + if (value) resolvedConfig[key] = value + } else if (value !== undefined && value !== null) { + resolvedConfig[key] = value + } } if (disabledTagIds.size > 0) { resolvedConfig.disabledTagIds = Array.from(disabledTagIds) @@ -370,8 +378,8 @@ export function AddConnectorModal({ {field.type === 'selector' && field.selectorKey ? ( handleFieldChange(field.id, value)} + value={sourceConfig[field.id] ?? (field.multi ? [] : '')} + onChange={(value: ConfigFieldValue) => handleFieldChange(field.id, value)} credentialId={effectiveCredentialId} sourceConfig={sourceConfig} configFields={connectorConfig.configFields} @@ -385,13 +393,21 @@ export function AddConnectorModal({ label: opt.label, value: opt.id, }))} - value={sourceConfig[field.id] || undefined} + value={ + typeof sourceConfig[field.id] === 'string' + ? (sourceConfig[field.id] as string) || undefined + : undefined + } onChange={(value) => handleFieldChange(field.id, value)} placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`} /> ) : ( handleFieldChange(field.id, e.target.value)} placeholder={field.placeholder} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index 527971dfc28..9fb05d90aef 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -3,6 +3,10 @@ import { useMemo } from 'react' import { Combobox, type ComboboxOption, Loader } from '@/components/emcn' import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' +import type { + ConfigFieldMap, + ConfigFieldValue, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { getDependsOnFields } from '@/blocks/utils' import type { ConnectorConfigField } from '@/connectors/types' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' @@ -10,10 +14,10 @@ import { useSelectorOptions } from '@/hooks/selectors/use-selector-query' interface ConnectorSelectorFieldProps { field: ConnectorConfigField & { selectorKey: SelectorKey } - value: string - onChange: (value: string) => void + value: ConfigFieldValue + onChange: (value: ConfigFieldValue) => void credentialId: string | null - sourceConfig: Record + sourceConfig: ConfigFieldMap configFields: ConnectorConfigField[] canonicalModes: Record disabled?: boolean @@ -29,6 +33,8 @@ export function ConnectorSelectorField({ canonicalModes, disabled, }: ConnectorSelectorFieldProps) { + const isMulti = Boolean(field.multi) + const context = useMemo(() => { const ctx: SelectorContext = {} if (credentialId) ctx.oauthCredential = credentialId @@ -73,11 +79,34 @@ export function ConnectorSelectorField({ ) } + if (isMulti) { + const multiValues = Array.isArray(value) ? value : value ? [value] : [] + return ( + onChange(values)} + searchable + searchPlaceholder={`Search ${field.title.toLowerCase()}...`} + placeholder={ + !credentialId + ? 'Connect an account first' + : !depsResolved + ? `Select ${getDependencyLabel(field, configFields)} first` + : field.placeholder || `Select ${field.title.toLowerCase()}` + } + disabled={disabled || !credentialId || !depsResolved} + /> + ) + } + + const singleValue = Array.isArray(value) ? value[0] : value return ( onChange(next)} searchable searchPlaceholder={`Search ${field.title.toLowerCase()}...`} placeholder={ @@ -96,18 +125,22 @@ function resolveDepValue( depFieldId: string, configFields: ConnectorConfigField[], canonicalModes: Record, - sourceConfig: Record + sourceConfig: ConfigFieldMap ): string { const depField = configFields.find((f) => f.id === depFieldId) - if (!depField?.canonicalParamId) return sourceConfig[depFieldId] ?? '' + const readFirst = (raw: ConfigFieldValue | undefined): string => { + if (Array.isArray(raw)) return raw[0] ?? '' + return raw ?? '' + } + if (!depField?.canonicalParamId) return readFirst(sourceConfig[depFieldId]) const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic' - if (depField.mode === activeMode) return sourceConfig[depFieldId] ?? '' + if (depField.mode === activeMode) return readFirst(sourceConfig[depFieldId]) const activeField = configFields.find( (f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode ) - return activeField ? (sourceConfig[activeField.id] ?? '') : (sourceConfig[depFieldId] ?? '') + return activeField ? readFirst(sourceConfig[activeField.id]) : readFirst(sourceConfig[depFieldId]) } function getDependencyLabel( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 01fdbf39fc0..af0440be9a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -28,6 +28,10 @@ import { getSubscriptionAccessState } from '@/lib/billing/client' import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' +import type { + ConfigFieldMap, + ConfigFieldValue, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { CONNECTOR_REGISTRY } from '@/connectors/registry' @@ -61,6 +65,28 @@ function readPersistedCanonicalModes( return result } +/** + * Deep equality for sourceConfig values (string, string[], or undefined/null). + * Empty string and empty array are treated as equivalent to absence. + */ +function valuesEqual(a: unknown, b: unknown): boolean { + const isEmpty = (v: unknown): boolean => { + if (v == null) return true + if (Array.isArray(v)) return v.length === 0 + if (typeof v === 'string') return v === '' + return false + } + if (isEmpty(a) && isEmpty(b)) return true + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true + } + return a === b +} + function didCanonicalModesChange( current: Record, persisted: Record @@ -96,11 +122,16 @@ export function EditConnectorModal({ * manual input), both field IDs get the same value so toggling preserves it. * Captured once on mount; editing state is owned by the hook afterward. */ - const [initialSourceConfig] = useState>(() => { - const config: Record = {} + const [initialSourceConfig] = useState(() => { + const config: ConfigFieldMap = {} if (!connectorConfig) { for (const [key, value] of Object.entries(connector.sourceConfig)) { - if (!INTERNAL_CONFIG_KEYS.has(key)) config[key] = String(value ?? '') + if (INTERNAL_CONFIG_KEYS.has(key)) continue + if (Array.isArray(value)) { + config[key] = value.filter((v): v is string => typeof v === 'string') + } else { + config[key] = String(value ?? '') + } } return config } @@ -108,7 +139,21 @@ export function EditConnectorModal({ const canonicalId = field.canonicalParamId ?? field.id if (INTERNAL_CONFIG_KEYS.has(canonicalId)) continue const rawValue = connector.sourceConfig[canonicalId] - if (rawValue !== undefined) config[field.id] = String(rawValue ?? '') + if (rawValue === undefined) continue + if (field.multi) { + if (Array.isArray(rawValue)) { + config[field.id] = rawValue.filter((v): v is string => typeof v === 'string') + } else if (typeof rawValue === 'string') { + config[field.id] = rawValue + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } else { + config[field.id] = [] + } + } else { + config[field.id] = String(rawValue ?? '') + } } return config }) @@ -147,7 +192,7 @@ export function EditConnectorModal({ if (didCanonicalModesChange(canonicalModes, persistedCanonicalModes)) return true const resolved = resolveSourceConfig() for (const [key, value] of Object.entries(resolved)) { - if (String(connector.sourceConfig[key] ?? '') !== value) return true + if (!valuesEqual(connector.sourceConfig[key], value)) return true } return false }, [ @@ -169,9 +214,9 @@ export function EditConnectorModal({ } const resolved = resolveSourceConfig() - const changedEntries: Record = {} + const changedEntries: Record = {} for (const [key, value] of Object.entries(resolved)) { - if (String(connector.sourceConfig[key] ?? '') !== value) changedEntries[key] = value + if (!valuesEqual(connector.sourceConfig[key], value)) changedEntries[key] = value } const modesChanged = didCanonicalModesChange(canonicalModes, persistedCanonicalModes) @@ -276,12 +321,12 @@ export function EditConnectorModal({ interface SettingsTabProps { connectorConfig: ConnectorConfig | null - sourceConfig: Record + sourceConfig: ConfigFieldMap credentialId: string | null canonicalGroups: Map canonicalModes: Record onToggleCanonicalMode: (canonicalId: string) => void - onFieldChange: (fieldId: string, value: string) => void + onFieldChange: (fieldId: string, value: ConfigFieldValue) => void isFieldVisible: (field: ConnectorConfigField) => boolean syncInterval: number setSyncInterval: (v: number) => void @@ -344,8 +389,8 @@ function SettingsTab({ {field.type === 'selector' && field.selectorKey ? ( onFieldChange(field.id, value)} + value={sourceConfig[field.id] ?? (field.multi ? [] : '')} + onChange={(value: ConfigFieldValue) => onFieldChange(field.id, value)} credentialId={credentialId} sourceConfig={sourceConfig} configFields={connectorConfig.configFields} @@ -359,13 +404,21 @@ function SettingsTab({ label: opt.label, value: opt.id, }))} - value={sourceConfig[field.id] || undefined} + value={ + typeof sourceConfig[field.id] === 'string' + ? (sourceConfig[field.id] as string) || undefined + : undefined + } onChange={(value) => onFieldChange(field.id, value)} placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`} /> ) : ( onFieldChange(field.id, e.target.value)} placeholder={field.placeholder} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts index 8419b749602..13558506ddb 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts @@ -4,42 +4,75 @@ import { useCallback, useMemo, useState } from 'react' import { getDependsOnFields } from '@/blocks/utils' import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types' +export type ConfigFieldValue = string | string[] +export type ConfigFieldMap = Record + export interface UseConnectorConfigFieldsOptions { connectorConfig: ConnectorConfig | null - initialSourceConfig?: Record + initialSourceConfig?: ConfigFieldMap initialCanonicalModes?: Record } export interface UseConnectorConfigFieldsResult { - sourceConfig: Record - setSourceConfig: React.Dispatch>> + sourceConfig: ConfigFieldMap + setSourceConfig: React.Dispatch> canonicalModes: Record setCanonicalModes: React.Dispatch>> canonicalGroups: Map isFieldVisible: (field: ConnectorConfigField) => boolean - handleFieldChange: (fieldId: string, value: string) => void + isFieldPopulated: (field: ConnectorConfigField) => boolean + handleFieldChange: (fieldId: string, value: ConfigFieldValue) => void toggleCanonicalMode: (canonicalId: string) => void - resolveSourceConfig: () => Record + resolveSourceConfig: () => Record +} + +function isMultiField(field: ConnectorConfigField | undefined): boolean { + return Boolean(field?.multi) +} + +function emptyValue(field: ConnectorConfigField | undefined): ConfigFieldValue { + return isMultiField(field) ? [] : '' +} + +/** + * Coerces a stored value to the shape expected by the field (string vs string[]). + * Multi fields accept either a string[] or a CSV string from advanced mode. + */ +function coerceForField(field: ConnectorConfigField, raw: unknown): ConfigFieldValue { + if (isMultiField(field)) { + if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === 'string') + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (!trimmed) return [] + return trimmed + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + return [] + } + if (Array.isArray(raw)) { + return raw.filter((v): v is string => typeof v === 'string').join(',') + } + return raw == null ? '' : String(raw) +} + +function isValuePopulated(value: ConfigFieldValue): boolean { + if (Array.isArray(value)) return value.length > 0 + return value.trim().length > 0 } /** * Shared state and helpers for connector configuration fields that support - * canonical pairs (selector + manual input sharing a `canonicalParamId`). - * - * - Tracks current field values and active mode (basic/advanced) per canonical group. - * - Computes the dependency graph including canonical-sibling expansion so that - * changing a dependency clears both siblings of any dependent canonical pair. - * - Returns `resolveSourceConfig` which collapses the per-field map back to a - * canonical-keyed object ready to submit. + * canonical pairs (selector + manual input sharing a `canonicalParamId`) and + * multi-value fields (selector or short-input with `multi: true`). */ export function useConnectorConfigFields({ connectorConfig, initialSourceConfig, initialCanonicalModes, }: UseConnectorConfigFieldsOptions): UseConnectorConfigFieldsResult { - const [sourceConfig, setSourceConfig] = useState>( - () => initialSourceConfig ?? {} - ) + const [sourceConfig, setSourceConfig] = useState(() => initialSourceConfig ?? {}) const [canonicalModes, setCanonicalModes] = useState>( () => initialCanonicalModes ?? {} ) @@ -56,6 +89,13 @@ export function useConnectorConfigFields({ return groups }, [connectorConfig]) + const fieldsById = useMemo(() => { + const map = new Map() + if (!connectorConfig) return map + for (const field of connectorConfig.configFields) map.set(field.id, field) + return map + }, [connectorConfig]) + const dependentFieldIds = useMemo(() => { const result = new Map() if (!connectorConfig) return result @@ -104,12 +144,17 @@ export function useConnectorConfigFields({ [canonicalModes] ) - const handleFieldChange = (fieldId: string, value: string) => { + const isFieldPopulated = useCallback( + (field: ConnectorConfigField): boolean => isValuePopulated(sourceConfig[field.id] ?? ''), + [sourceConfig] + ) + + const handleFieldChange = (fieldId: string, value: ConfigFieldValue) => { setSourceConfig((prev) => { - const next = { ...prev, [fieldId]: value } + const next: ConfigFieldMap = { ...prev, [fieldId]: value } const toClear = dependentFieldIds.get(fieldId) if (toClear) { - for (const depId of toClear) next[depId] = '' + for (const depId of toClear) next[depId] = emptyValue(fieldsById.get(depId)) } return next }) @@ -122,8 +167,8 @@ export function useConnectorConfigFields({ })) } - const resolveSourceConfig = useCallback((): Record => { - const resolved: Record = {} + const resolveSourceConfig = useCallback((): Record => { + const resolved: Record = {} const processed = new Set() if (!connectorConfig) return resolved @@ -135,9 +180,11 @@ export function useConnectorConfigFields({ if (!group) continue const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' const activeField = group.find((f) => f.mode === activeMode) ?? group[0] - resolved[field.canonicalParamId] = sourceConfig[activeField.id] ?? '' + const raw = sourceConfig[activeField.id] ?? emptyValue(activeField) + resolved[field.canonicalParamId] = coerceForField(activeField, raw) } else { - resolved[field.id] = sourceConfig[field.id] ?? '' + const raw = sourceConfig[field.id] ?? emptyValue(field) + resolved[field.id] = coerceForField(field, raw) } } return resolved @@ -150,6 +197,7 @@ export function useConnectorConfigFields({ setCanonicalModes, canonicalGroups, isFieldVisible, + isFieldPopulated, handleFieldChange, toggleCanonicalMode, resolveSourceConfig, diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 2180f932f08..8a4fa32ef39 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import { ConfluenceIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { htmlToPlainText, joinTagArray, parseTagDate } from '@/connectors/utils' +import { htmlToPlainText, joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceConnector') @@ -15,6 +15,18 @@ export function escapeCql(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') } +/** + * Builds a CQL clause restricting content to the given space keys. + * Single key uses `space = "X"`; multiple keys use `space in ("X","Y")`. + */ +function buildSpaceClause(spaceKeys: string[]): string { + if (spaceKeys.length === 1) { + return `space="${escapeCql(spaceKeys[0])}"` + } + const list = spaceKeys.map((k) => `"${escapeCql(k)}"`).join(',') + return `space in (${list})` +} + /** * Fetches labels for a batch of page IDs using the v2 labels endpoint. */ @@ -162,22 +174,24 @@ export const confluenceConnector: ConnectorConfig = { }, { id: 'spaceSelector', - title: 'Space', + title: 'Spaces', type: 'selector', selectorKey: 'confluence.spaces', canonicalParamId: 'spaceKey', mode: 'basic', + multi: true, dependsOn: ['domain'], - placeholder: 'Select a space', + placeholder: 'Select one or more spaces', required: true, }, { id: 'spaceKey', - title: 'Space Key', + title: 'Space Keys', type: 'short-input', canonicalParamId: 'spaceKey', mode: 'advanced', - placeholder: 'e.g. ENG, PRODUCT', + multi: true, + placeholder: 'e.g. ENG, PRODUCT (comma-separated for multiple)', required: true, }, { @@ -214,23 +228,32 @@ export const confluenceConnector: ConnectorConfig = { syncContext?: Record ): Promise => { const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string) - const spaceKey = sourceConfig.spaceKey as string + const spaceKeys = parseMultiValue(sourceConfig.spaceKey) const contentType = (sourceConfig.contentType as string) || 'page' const labelFilter = (sourceConfig.labelFilter as string) || '' const maxPages = sourceConfig.maxPages ? Number(sourceConfig.maxPages) : 0 + if (spaceKeys.length === 0) { + throw new Error('At least one space key is required') + } + let cloudId = syncContext?.cloudId as string | undefined if (!cloudId) { cloudId = await getConfluenceCloudId(domain, accessToken) if (syncContext) syncContext.cloudId = cloudId } - if (labelFilter.trim()) { + /** + * Route through CQL when a label filter is set or when multiple spaces are + * selected — the v2 `/spaces/{spaceId}/pages` endpoint is single-space only, + * but CQL natively supports `space in (...)`. + */ + if (labelFilter.trim() || spaceKeys.length > 1) { return listDocumentsViaCql( cloudId, accessToken, domain, - spaceKey, + spaceKeys, contentType, labelFilter, maxPages, @@ -239,6 +262,7 @@ export const confluenceConnector: ConnectorConfig = { ) } + const spaceKey = spaceKeys[0] let spaceId = syncContext?.spaceId as string | undefined if (!spaceId) { spaceId = await resolveSpaceId(cloudId, accessToken, spaceKey) @@ -333,10 +357,10 @@ export const confluenceConnector: ConnectorConfig = { sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const domain = sourceConfig.domain as string - const spaceKey = sourceConfig.spaceKey as string + const spaceKeys = parseMultiValue(sourceConfig.spaceKey) - if (!domain || !spaceKey) { - return { valid: false, error: 'Domain and space key are required' } + if (!domain || spaceKeys.length === 0) { + return { valid: false, error: 'Domain and at least one space key are required' } } const maxPages = sourceConfig.maxPages as string | undefined @@ -346,7 +370,10 @@ export const confluenceConnector: ConnectorConfig = { try { const cloudId = await getConfluenceCloudId(domain, accessToken, VALIDATE_RETRY_OPTIONS) - const spaceUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?keys=${encodeURIComponent(spaceKey)}&limit=1` + const params = new URLSearchParams() + for (const key of spaceKeys) params.append('keys', key) + params.append('limit', String(Math.max(spaceKeys.length, 1))) + const spaceUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?${params.toString()}` const response = await fetchWithRetry( spaceUrl, { @@ -359,11 +386,17 @@ export const confluenceConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) if (!response.ok) { - return { valid: false, error: `Failed to validate space: ${response.status}` } + return { valid: false, error: `Failed to validate spaces: ${response.status}` } } const data = await response.json() - if (!data.results?.length) { - return { valid: false, error: `Space "${spaceKey}" not found` } + const results = (data.results as Array> | undefined) ?? [] + const foundKeys = new Set(results.map((r) => String(r.key))) + const missing = spaceKeys.filter((k) => !foundKeys.has(k)) + if (missing.length > 0) { + return { + valid: false, + error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')}`, + } } return { valid: true } } catch (error) { @@ -562,7 +595,7 @@ async function listDocumentsViaCql( cloudId: string, accessToken: string, domain: string, - spaceKey: string, + spaceKeys: string[], contentType: string, labelFilter: string, maxPages: number, @@ -575,7 +608,7 @@ async function listDocumentsViaCql( .filter(Boolean) // Build CQL query - let cql = `space="${escapeCql(spaceKey)}"` + let cql = buildSpaceClause(spaceKeys) if (contentType === 'blogpost') { cql += ' AND type="blogpost"' diff --git a/apps/sim/connectors/gmail/gmail.ts b/apps/sim/connectors/gmail/gmail.ts index bff9a86e915..e0408073823 100644 --- a/apps/sim/connectors/gmail/gmail.ts +++ b/apps/sim/connectors/gmail/gmail.ts @@ -3,7 +3,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { GmailIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { htmlToPlainText, joinTagArray, parseTagDate } from '@/connectors/utils' +import { htmlToPlainText, joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('GmailConnector') @@ -45,16 +45,40 @@ interface GmailLabel { type?: string } +/** + * Formats a single Gmail label name for use in a `label:` operator. + * Gmail search syntax accepts quoted strings for labels containing spaces; + * unquoted label tokens have spaces replaced with hyphens. + */ +function formatLabelToken(name: string): string { + const trimmed = name.trim() + if (!trimmed) return '' + if (/\s/.test(trimmed)) { + const escaped = trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + return `label:"${escaped}"` + } + return `label:${trimmed}` +} + /** * Builds a Gmail search query string from the source config. * Combines the user's custom query with the label and date range filters. + * When multiple labels are provided, they are OR-joined: `(label:A OR label:B)`. */ function buildSearchQuery(sourceConfig: Record): string { const parts: string[] = [] - const labelName = sourceConfig.label as string | undefined - if (labelName?.trim()) { - parts.push(`label:${labelName.trim().replace(/\s+/g, '-')}`) + const labelNames = parseMultiValue(sourceConfig.label) + if (labelNames.length === 1) { + const token = formatLabelToken(labelNames[0]) + if (token) parts.push(token) + } else if (labelNames.length > 1) { + const tokens = labelNames.map(formatLabelToken).filter(Boolean) + if (tokens.length === 1) { + parts.push(tokens[0]) + } else if (tokens.length > 1) { + parts.push(`(${tokens.join(' OR ')})`) + } } const dateRange = (sourceConfig.dateRange as string) || 'all' @@ -88,8 +112,13 @@ function buildSearchQuery(sourceConfig: Record): string { } const customQuery = sourceConfig.query as string | undefined - if (customQuery?.trim()) { - parts.push(customQuery.trim()) + const trimmedCustom = customQuery?.trim() + if (trimmedCustom) { + // Wrap the user-supplied query in parentheses so any internal OR operators + // bind only within the custom expression and don't combine with preceding + // label / category / date filters. + const needsGroup = /\bOR\b/i.test(trimmedCustom) && !/^\(.*\)$/.test(trimmedCustom) + parts.push(needsGroup ? `(${trimmedCustom})` : trimmedCustom) } return parts.join(' ') @@ -318,24 +347,26 @@ export const gmailConnector: ConnectorConfig = { configFields: [ { id: 'labelSelector', - title: 'Label', + title: 'Labels', type: 'selector', selectorKey: 'gmail.labels', canonicalParamId: 'label', mode: 'basic', - placeholder: 'Select a label', + multi: true, + placeholder: 'Select one or more labels', required: false, - description: 'Only sync emails with this label. Leave empty for all mail.', + description: 'Only sync emails matching any of these labels. Leave empty for all mail.', }, { id: 'label', - title: 'Label', + title: 'Labels', type: 'short-input', canonicalParamId: 'label', mode: 'advanced', - placeholder: 'e.g. INBOX, IMPORTANT, or a custom label name', + multi: true, + placeholder: 'e.g. INBOX, IMPORTANT (comma-separated; commas in label names not supported)', required: false, - description: 'Only sync emails with this label. Leave empty for all mail.', + description: 'Only sync emails matching any of these labels. Leave empty for all mail.', }, { id: 'dateRange', @@ -529,9 +560,9 @@ export const gmailConnector: ConnectorConfig = { return { valid: false, error: `Failed to access Gmail: ${profileResponse.status}` } } - // If a label is specified, verify it exists - const labelName = sourceConfig.label as string | undefined - if (labelName?.trim()) { + // If labels are specified, verify each one exists + const labelNames = parseMultiValue(sourceConfig.label) + if (labelNames.length > 0) { const labelsUrl = `${GMAIL_API_BASE}/labels` const labelsResponse = await fetchWithRetry( labelsUrl, @@ -551,13 +582,13 @@ export const gmailConnector: ConnectorConfig = { const labelsData = await labelsResponse.json() const labels = (labelsData.labels || []) as GmailLabel[] - const normalized = labelName.trim().toLowerCase() - const match = labels.find((l) => l.name.toLowerCase() === normalized) + const labelNameSet = new Set(labels.map((l) => l.name.toLowerCase())) + const missing = labelNames.filter((name) => !labelNameSet.has(name.toLowerCase())) - if (!match) { + if (missing.length > 0) { return { valid: false, - error: `Label "${labelName}" not found. Available labels: ${labels + error: `Label(s) not found: ${missing.join(', ')}. Available labels: ${labels .filter( (l) => l.type !== 'system' || diff --git a/apps/sim/connectors/google-calendar/google-calendar.ts b/apps/sim/connectors/google-calendar/google-calendar.ts index 83b1e1fd7b9..f8946e8b97d 100644 --- a/apps/sim/connectors/google-calendar/google-calendar.ts +++ b/apps/sim/connectors/google-calendar/google-calendar.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { GoogleCalendarIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { parseTagDate } from '@/connectors/utils' +import { parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('GoogleCalendarConnector') @@ -195,8 +195,19 @@ function getTimeRange(sourceConfig: Record): { timeMin: string; /** * Converts a CalendarEvent to an ExternalDocument. + * + * Backward compatibility: when only a single calendar is configured (the only + * code path that existed before multi-calendar support), externalId and + * contentHash use the legacy non-namespaced format so existing connectors see + * zero churn on re-sync. When 2+ calendars are configured, we namespace by + * calendarId because Google Calendar event IDs are only unique within a + * single calendar. */ -function eventToDocument(event: CalendarEvent): ExternalDocument | null { +function eventToDocument( + event: CalendarEvent, + calendarId: string, + isMultiCalendar: boolean +): ExternalDocument | null { if (event.status === 'cancelled') return null const content = eventToContent(event) @@ -205,14 +216,20 @@ function eventToDocument(event: CalendarEvent): ExternalDocument | null { const startTime = event.start?.dateTime || event.start?.date || '' const attendeeCount = event.attendees?.filter((a) => !a.resource).length || 0 + const externalId = isMultiCalendar ? `${calendarId}:${event.id}` : event.id + const contentHash = isMultiCalendar + ? `gcal:${calendarId}:${event.id}:${event.updated ?? ''}` + : `gcal:${event.id}:${event.updated ?? ''}` + return { - externalId: event.id, + externalId, title: event.summary || 'Untitled Event', content, mimeType: 'text/plain', sourceUrl: event.htmlLink || `https://calendar.google.com/calendar/event?eid=${event.id}`, - contentHash: `gcal:${event.id}:${event.updated ?? ''}`, + contentHash, metadata: { + calendarId, startTime, endTime: event.end?.dateTime || event.end?.date || '', location: event.location || '', @@ -242,24 +259,27 @@ export const googleCalendarConnector: ConnectorConfig = { configFields: [ { id: 'calendarSelector', - title: 'Calendar', + title: 'Calendars', type: 'selector', selectorKey: 'google.calendar', canonicalParamId: 'calendarId', mode: 'basic', - placeholder: 'Select a calendar', + multi: true, + placeholder: 'Select one or more calendars', required: false, - description: 'The calendar to sync from. Defaults to your primary calendar.', + description: 'Calendars to sync from. Defaults to your primary calendar.', }, { id: 'calendarId', - title: 'Calendar ID', + title: 'Calendar IDs', type: 'short-input', canonicalParamId: 'calendarId', mode: 'advanced', - placeholder: 'e.g. primary (default: primary)', + multi: true, + placeholder: 'e.g. primary, team@group.calendar.google.com (comma-separated for multiple)', required: false, - description: 'The calendar to sync from. Use "primary" for your main calendar.', + description: + 'Calendars to sync from. Use "primary" for your main calendar. Defaults to "primary".', }, { id: 'dateRange', @@ -296,10 +316,39 @@ export const googleCalendarConnector: ConnectorConfig = { cursor?: string, syncContext?: Record ): Promise => { - const calendarId = ((sourceConfig.calendarId as string) || 'primary').trim() + const parsedCalendarIds = parseMultiValue(sourceConfig.calendarId) + const calendarIds = parsedCalendarIds.length > 0 ? parsedCalendarIds : ['primary'] const { timeMin, timeMax } = getTimeRange(sourceConfig) const searchQuery = (sourceConfig.searchQuery as string) || '' + /** + * Cursor format: + * - For a single calendar with legacy cursors: the raw pageToken string + * - For multi-calendar walking: JSON-encoded { calendarIndex, pageToken } + */ + let calendarIndex = 0 + let pageToken: string | undefined + + if (cursor) { + try { + const parsed = JSON.parse(cursor) as { calendarIndex: number; pageToken?: string } + if (typeof parsed.calendarIndex === 'number') { + calendarIndex = parsed.calendarIndex + pageToken = parsed.pageToken + } else { + pageToken = cursor + } + } catch { + pageToken = cursor + } + } + + if (calendarIndex >= calendarIds.length) { + return { documents: [], hasMore: false } + } + + const calendarId = calendarIds[calendarIndex] + const queryParams = new URLSearchParams({ singleEvents: 'true', orderBy: 'startTime', @@ -312,17 +361,19 @@ export const googleCalendarConnector: ConnectorConfig = { queryParams.set('q', searchQuery.trim()) } - if (cursor) { - queryParams.set('pageToken', cursor) + if (pageToken) { + queryParams.set('pageToken', pageToken) } const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${queryParams.toString()}` logger.info('Listing Google Calendar events', { calendarId, + calendarIndex, + calendarCount: calendarIds.length, timeMin, timeMax, - cursor: cursor ?? 'initial', + hasPageToken: Boolean(pageToken), }) const response = await fetchWithRetry(url, { @@ -337,6 +388,7 @@ export const googleCalendarConnector: ConnectorConfig = { const errorText = await response.text() logger.error('Failed to list Google Calendar events', { status: response.status, + calendarId, error: errorText, }) throw new Error(`Failed to list Google Calendar events: ${response.status}`) @@ -345,9 +397,10 @@ export const googleCalendarConnector: ConnectorConfig = { const data = await response.json() const events = (data.items || []) as CalendarEvent[] + const isMultiCalendar = calendarIds.length > 1 const documents: ExternalDocument[] = [] for (const event of events) { - const doc = eventToDocument(event) + const doc = eventToDocument(event, calendarId, isMultiCalendar) if (doc) documents.push(doc) } @@ -359,11 +412,28 @@ export const googleCalendarConnector: ConnectorConfig = { const nextPageToken = data.nextPageToken as string | undefined - return { - documents, - nextCursor: hitLimit ? undefined : nextPageToken, - hasMore: hitLimit ? false : Boolean(nextPageToken), + if (hitLimit) { + return { documents, hasMore: false } } + + if (nextPageToken) { + return { + documents, + nextCursor: JSON.stringify({ calendarIndex, pageToken: nextPageToken }), + hasMore: true, + } + } + + const nextCalendarIndex = calendarIndex + 1 + if (nextCalendarIndex < calendarIds.length) { + return { + documents, + nextCursor: JSON.stringify({ calendarIndex: nextCalendarIndex }), + hasMore: true, + } + } + + return { documents, hasMore: false } }, getDocument: async ( @@ -371,8 +441,34 @@ export const googleCalendarConnector: ConnectorConfig = { sourceConfig: Record, externalId: string ): Promise => { - const calendarId = ((sourceConfig.calendarId as string) || 'primary').trim() - const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(externalId)}` + /** + * externalId format depends on connector configuration: + * - Single-calendar (1 calendar configured): externalId = eventId (legacy + * and current single-calendar format). + * - Multi-calendar (2+ calendars configured): externalId = + * `calendarId:eventId`. The first `:` is the separator — event IDs never + * contain `:` while calendar IDs (e.g. `user@group.calendar.google.com`) + * may include URL-safe chars but not `:`. + * + * Legacy in-flight rows that lack a separator fall back to the configured + * calendar (or `primary`). + */ + const parsedCalendarIds = parseMultiValue(sourceConfig.calendarId) + const calendarIds = parsedCalendarIds.length > 0 ? parsedCalendarIds : ['primary'] + const isMultiCalendar = calendarIds.length > 1 + + const separatorIndex = externalId.indexOf(':') + let calendarId: string + let eventId: string + if (separatorIndex === -1) { + calendarId = calendarIds[0] ?? 'primary' + eventId = externalId + } else { + calendarId = externalId.slice(0, separatorIndex) + eventId = externalId.slice(separatorIndex + 1) + } + + const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}` const response = await fetchWithRetry(url, { method: 'GET', @@ -391,7 +487,7 @@ export const googleCalendarConnector: ConnectorConfig = { if (event.status === 'cancelled') return null - return eventToDocument(event) ?? null + return eventToDocument(event, calendarId, isMultiCalendar) ?? null }, validateConfig: async ( @@ -403,27 +499,37 @@ export const googleCalendarConnector: ConnectorConfig = { return { valid: false, error: 'Max events must be a positive number' } } + const parsedCalendarIds = parseMultiValue(sourceConfig.calendarId) + const calendarIds = parsedCalendarIds.length > 0 ? parsedCalendarIds : ['primary'] + try { - const calendarId = ((sourceConfig.calendarId as string) || 'primary').trim() - const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?maxResults=1&singleEvents=true&orderBy=startTime&timeMin=${encodeURIComponent(new Date().toISOString())}` - - const response = await fetchWithRetry( - url, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', + for (const calendarId of calendarIds) { + const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?maxResults=1&singleEvents=true&orderBy=startTime&timeMin=${encodeURIComponent(new Date().toISOString())}` + + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, }, - }, - VALIDATE_RETRY_OPTIONS - ) - - if (!response.ok) { - if (response.status === 404) { - return { valid: false, error: 'Calendar not found. Check the calendar ID.' } + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (response.status === 404) { + return { + valid: false, + error: `Calendar not found: ${calendarId}. Check the calendar ID.`, + } + } + return { + valid: false, + error: `Failed to access Google Calendar "${calendarId}": ${response.status}`, + } } - return { valid: false, error: `Failed to access Google Calendar: ${response.status}` } } return { valid: true } diff --git a/apps/sim/connectors/jira/jira.ts b/apps/sim/connectors/jira/jira.ts index 0341d15b81d..409dca78020 100644 --- a/apps/sim/connectors/jira/jira.ts +++ b/apps/sim/connectors/jira/jira.ts @@ -3,13 +3,27 @@ import { toError } from '@sim/utils/errors' import { JiraIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { joinTagArray, parseTagDate } from '@/connectors/utils' +import { joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' import { extractAdfText, getJiraCloudId } from '@/tools/jira/utils' const logger = createLogger('JiraConnector') const PAGE_SIZE = 50 +/** + * Builds a JQL clause restricting issues to the given project keys. + * Single key uses `project = "X"`; multiple keys use `project in ("X","Y")`. + * Each key is escaped for inclusion in a JQL double-quoted string. + */ +function buildProjectClause(projectKeys: string[]): string { + const escapeKey = (key: string) => key.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + if (projectKeys.length === 1) { + return `project = "${escapeKey(projectKeys[0])}"` + } + const list = projectKeys.map((k) => `"${escapeKey(k)}"`).join(',') + return `project in (${list})` +} + /** * Builds a plain-text representation of a Jira issue for knowledge base indexing. */ @@ -108,22 +122,24 @@ export const jiraConnector: ConnectorConfig = { }, { id: 'projectSelector', - title: 'Project', + title: 'Projects', type: 'selector', selectorKey: 'jira.projects', canonicalParamId: 'projectKey', mode: 'basic', + multi: true, dependsOn: ['domain'], - placeholder: 'Select a project', + placeholder: 'Select one or more projects', required: true, }, { id: 'projectKey', - title: 'Project Key', + title: 'Project Keys', type: 'short-input', canonicalParamId: 'projectKey', mode: 'advanced', - placeholder: 'e.g. ENG, PROJ', + multi: true, + placeholder: 'e.g. ENG, PROJ (comma-separated for multiple)', required: true, }, { @@ -149,20 +165,24 @@ export const jiraConnector: ConnectorConfig = { syncContext?: Record ): Promise => { const domain = sourceConfig.domain as string - const projectKey = sourceConfig.projectKey as string + const projectKeys = parseMultiValue(sourceConfig.projectKey) const jqlFilter = (sourceConfig.jql as string) || '' const maxIssues = sourceConfig.maxIssues ? Number(sourceConfig.maxIssues) : 0 + if (projectKeys.length === 0) { + throw new Error('At least one project key is required') + } + let cloudId = syncContext?.cloudId as string | undefined if (!cloudId) { cloudId = await getJiraCloudId(domain, accessToken) if (syncContext) syncContext.cloudId = cloudId } - const safeKey = projectKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"') - let jql = `project = "${safeKey}" ORDER BY updated DESC` + const projectClause = buildProjectClause(projectKeys) + let jql = `${projectClause} ORDER BY updated DESC` if (jqlFilter.trim()) { - jql = `project = "${safeKey}" AND (${jqlFilter.trim()}) ORDER BY updated DESC` + jql = `${projectClause} AND (${jqlFilter.trim()}) ORDER BY updated DESC` } /** @@ -200,7 +220,10 @@ export const jiraConnector: ConnectorConfig = { const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` - logger.info(`Listing Jira issues for project ${projectKey}`, { hasCursor: Boolean(cursor) }) + logger.info(`Listing Jira issues for ${projectKeys.length} project(s)`, { + projectKeys, + hasCursor: Boolean(cursor), + }) const response = await fetchWithRetry(url, { method: 'GET', @@ -292,10 +315,10 @@ export const jiraConnector: ConnectorConfig = { sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const domain = sourceConfig.domain as string - const projectKey = sourceConfig.projectKey as string + const projectKeys = parseMultiValue(sourceConfig.projectKey) - if (!domain || !projectKey) { - return { valid: false, error: 'Domain and project key are required' } + if (!domain || projectKeys.length === 0) { + return { valid: false, error: 'Domain and at least one project key are required' } } const maxIssues = sourceConfig.maxIssues as string | undefined @@ -308,9 +331,9 @@ export const jiraConnector: ConnectorConfig = { try { const cloudId = await getJiraCloudId(domain, accessToken, VALIDATE_RETRY_OPTIONS) + const projectClause = buildProjectClause(projectKeys) const params = new URLSearchParams() - const safeKey = projectKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"') - params.append('jql', `project = "${safeKey}"`) + params.append('jql', projectClause) params.append('maxResults', '1') const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` @@ -329,14 +352,17 @@ export const jiraConnector: ConnectorConfig = { if (!response.ok) { const errorText = await response.text() if (response.status === 400) { - return { valid: false, error: `Project "${projectKey}" not found or JQL is invalid` } + return { + valid: false, + error: `One or more projects not found (${projectKeys.join(', ')}) or JQL is invalid`, + } } return { valid: false, error: `Failed to validate: ${response.status} - ${errorText}` } } if (jqlFilter) { const filterParams = new URLSearchParams() - filterParams.append('jql', `project = "${safeKey}" AND (${jqlFilter})`) + filterParams.append('jql', `${projectClause} AND (${jqlFilter})`) filterParams.append('maxResults', '1') const filterUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${filterParams.toString()}` diff --git a/apps/sim/connectors/linear/linear.ts b/apps/sim/connectors/linear/linear.ts index 6aa181fe653..72067d2069f 100644 --- a/apps/sim/connectors/linear/linear.ts +++ b/apps/sim/connectors/linear/linear.ts @@ -4,7 +4,7 @@ import { LinearIcon } from '@/components/icons' import type { RetryOptions } from '@/lib/knowledge/documents/utils' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { joinTagArray, parseTagDate } from '@/connectors/utils' +import { joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('LinearConnector') @@ -135,28 +135,38 @@ const TEAMS_QUERY = ` * Dynamically builds a GraphQL issues query with only the filter clauses * that have values, preventing null comparators from being sent to Linear. */ -function buildIssuesQuery(sourceConfig: Record): { +function buildIssuesQuery( + sourceConfig: Record, + teamIds: string[], + projectIds: string[] +): { query: string variables: Record } { - const teamId = (sourceConfig.teamId as string) || '' - const projectId = (sourceConfig.projectId as string) || '' const stateFilter = (sourceConfig.stateFilter as string) || '' const varDefs: string[] = ['$first: Int!', '$after: String'] const filterClauses: string[] = [] const variables: Record = {} - if (teamId) { + if (teamIds.length === 1) { varDefs.push('$teamId: ID!') filterClauses.push('team: { id: { eq: $teamId } }') - variables.teamId = teamId + variables.teamId = teamIds[0] + } else if (teamIds.length > 1) { + varDefs.push('$teamIds: [ID!]!') + filterClauses.push('team: { id: { in: $teamIds } }') + variables.teamIds = teamIds } - if (projectId) { + if (projectIds.length === 1) { varDefs.push('$projectId: ID!') filterClauses.push('project: { id: { eq: $projectId } }') - variables.projectId = projectId + variables.projectId = projectIds[0] + } else if (projectIds.length > 1) { + varDefs.push('$projectIds: [ID!]!') + filterClauses.push('project: { id: { in: $projectIds } }') + variables.projectIds = projectIds } if (stateFilter) { @@ -202,41 +212,45 @@ export const linearConnector: ConnectorConfig = { configFields: [ { id: 'teamSelector', - title: 'Team', + title: 'Teams', type: 'selector', selectorKey: 'linear.teams', canonicalParamId: 'teamId', mode: 'basic', - placeholder: 'Select a team (optional)', + multi: true, + placeholder: 'Select one or more teams (optional)', required: false, }, { id: 'teamId', - title: 'Team ID', + title: 'Team IDs', type: 'short-input', canonicalParamId: 'teamId', mode: 'advanced', - placeholder: 'e.g. abc123 (leave empty for all teams)', + multi: true, + placeholder: 'e.g. abc123, def456 (comma-separated for multiple)', required: false, }, { id: 'projectSelector', - title: 'Project', + title: 'Projects', type: 'selector', selectorKey: 'linear.projects', canonicalParamId: 'projectId', mode: 'basic', + multi: true, dependsOn: ['teamSelector'], - placeholder: 'Select a project (optional)', + placeholder: 'Select one or more projects (optional)', required: false, }, { id: 'projectId', - title: 'Project ID', + title: 'Project IDs', type: 'short-input', canonicalParamId: 'projectId', mode: 'advanced', - placeholder: 'e.g. def456 (leave empty for all projects)', + multi: true, + placeholder: 'e.g. def456, ghi789 (comma-separated for multiple)', required: false, }, { @@ -264,14 +278,17 @@ export const linearConnector: ConnectorConfig = { const maxIssues = sourceConfig.maxIssues ? Number(sourceConfig.maxIssues) : 0 const pageSize = maxIssues > 0 ? Math.min(maxIssues, 50) : 50 - const { query, variables } = buildIssuesQuery(sourceConfig) + const teamIds = parseMultiValue(sourceConfig.teamId) + const projectIds = parseMultiValue(sourceConfig.projectId) + + const { query, variables } = buildIssuesQuery(sourceConfig, teamIds, projectIds) const allVars = { ...variables, first: pageSize, after: cursor || undefined } logger.info('Listing Linear issues', { cursor, pageSize, - hasTeamFilter: Boolean(sourceConfig.teamId), - hasProjectFilter: Boolean(sourceConfig.projectId), + teamFilterCount: teamIds.length, + projectFilterCount: projectIds.length, }) const data = await linearGraphQL(accessToken, query, allVars) @@ -389,13 +406,14 @@ export const linearConnector: ConnectorConfig = { } } - const teamId = sourceConfig.teamId as string | undefined - if (teamId) { - const found = teams.some((t) => t.id === teamId) - if (!found) { + const requestedTeamIds = parseMultiValue(sourceConfig.teamId) + if (requestedTeamIds.length > 0) { + const availableIds = new Set(teams.map((t) => t.id as string)) + const missing = requestedTeamIds.filter((id) => !availableIds.has(id)) + if (missing.length > 0) { return { valid: false, - error: `Team ID "${teamId}" not found. Available teams: ${teams.map((t) => `${t.name} (${t.id})`).join(', ')}`, + error: `Team ID(s) not found: ${missing.join(', ')}. Available teams: ${teams.map((t) => `${t.name} (${t.id})`).join(', ')}`, } } } diff --git a/apps/sim/connectors/microsoft-teams/microsoft-teams.ts b/apps/sim/connectors/microsoft-teams/microsoft-teams.ts index bfb857085cc..3d18b127726 100644 --- a/apps/sim/connectors/microsoft-teams/microsoft-teams.ts +++ b/apps/sim/connectors/microsoft-teams/microsoft-teams.ts @@ -3,7 +3,12 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { MicrosoftTeamsIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { computeContentHash, htmlToPlainText, parseTagDate } from '@/connectors/utils' +import { + computeContentHash, + htmlToPlainText, + parseMultiValue, + parseTagDate, +} from '@/connectors/utils' const logger = createLogger('MicrosoftTeamsConnector') @@ -108,8 +113,11 @@ async function fetchChannelMessages( (msg) => msg.messageType === 'message' && !msg.deletedDateTime ) + // Messages are sorted by lastModifiedDateTime (per Graph docs), so the first + // user message on the first page reflects the most recent activity. if (!lastActivityTs && userMessages.length > 0) { - lastActivityTs = userMessages[0].createdDateTime + const first = userMessages[0] + lastActivityTs = first.lastModifiedDateTime || first.createdDateTime } allMessages.push(...userMessages) @@ -159,7 +167,8 @@ async function resolveChannel( // Fetch all channels for the team let nextLink: string | undefined - const initialPath = `/teams/${encodeURIComponent(teamId)}/channels` + // $select avoids the expensive `email` property per Graph perf guidance. + const initialPath = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName,description` let currentUrl: string = initialPath do { @@ -217,24 +226,26 @@ export const microsoftTeamsConnector: ConnectorConfig = { }, { id: 'channelSelector', - title: 'Channel', + title: 'Channels', type: 'selector', selectorKey: 'microsoft.channels', canonicalParamId: 'channel', mode: 'basic', + multi: true, dependsOn: ['teamSelector'], - placeholder: 'Select a channel', + placeholder: 'Select one or more channels', required: true, }, { id: 'channel', - title: 'Channel', + title: 'Channels', type: 'short-input', canonicalParamId: 'channel', mode: 'advanced', - placeholder: 'e.g. General or 19:abc123@thread.tacv2', + multi: true, + placeholder: 'e.g. General, Announcements (comma-separated for multiple)', required: true, - description: 'Channel name or ID to sync messages from', + description: 'Channel names or IDs to sync messages from', }, { id: 'maxMessages', @@ -252,60 +263,68 @@ export const microsoftTeamsConnector: ConnectorConfig = { _syncContext?: Record ): Promise => { const teamId = sourceConfig.teamId as string - const channelInput = sourceConfig.channel as string + const channelInputs = parseMultiValue(sourceConfig.channel) if (!teamId?.trim()) { throw new Error('Team ID is required') } - if (!channelInput?.trim()) { - throw new Error('Channel is required') + if (channelInputs.length === 0) { + throw new Error('At least one channel is required') } const maxMessages = sourceConfig.maxMessages ? Number(sourceConfig.maxMessages) : DEFAULT_MAX_MESSAGES - logger.info('Syncing Microsoft Teams channel', { teamId, channel: channelInput, maxMessages }) + logger.info('Syncing Microsoft Teams channels', { + teamId, + channels: channelInputs, + maxMessages, + }) - const channel = await resolveChannel(accessToken, teamId, channelInput) - if (!channel) { - throw new Error(`Channel not found: ${channelInput}`) - } + const documents: ExternalDocument[] = [] - const { messages, lastActivityTs } = await fetchChannelMessages( - accessToken, - teamId, - channel.id, - maxMessages - ) + for (const channelInput of channelInputs) { + const channel = await resolveChannel(accessToken, teamId, channelInput) + if (!channel) { + throw new Error(`Channel not found: ${channelInput}`) + } - const content = formatMessages(messages) - if (!content.trim()) { - logger.info(`No messages found in channel: ${channel.displayName}`) - return { documents: [], hasMore: false } - } + const { messages, lastActivityTs } = await fetchChannelMessages( + accessToken, + teamId, + channel.id, + maxMessages + ) - const contentHash = await computeContentHash(content) - - const sourceUrl = `https://teams.microsoft.com/l/channel/${encodeURIComponent(channel.id)}/${encodeURIComponent(channel.displayName)}?groupId=${encodeURIComponent(teamId)}` - - const document: ExternalDocument = { - externalId: channel.id, - title: channel.displayName, - content, - mimeType: 'text/plain', - sourceUrl, - contentHash, - metadata: { - channelName: channel.displayName, - messageCount: messages.length, - lastActivity: lastActivityTs || undefined, - description: channel.description || undefined, - }, + const content = formatMessages(messages) + if (!content.trim()) { + logger.info(`No messages found in channel: ${channel.displayName}`) + continue + } + + const contentHash = await computeContentHash(content) + + const sourceUrl = `https://teams.microsoft.com/l/channel/${encodeURIComponent(channel.id)}/${encodeURIComponent(channel.displayName)}?groupId=${encodeURIComponent(teamId)}` + + documents.push({ + externalId: channel.id, + title: channel.displayName, + content, + mimeType: 'text/plain', + sourceUrl, + contentHash, + metadata: { + channelName: channel.displayName, + messageCount: messages.length, + lastActivity: lastActivityTs || undefined, + description: channel.description || undefined, + }, + }) } - // Each channel is one document; no pagination needed + // All selected channels are emitted in a single page; no pagination needed return { - documents: [document], + documents, hasMore: false, } }, @@ -371,15 +390,15 @@ export const microsoftTeamsConnector: ConnectorConfig = { sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const teamId = sourceConfig.teamId as string | undefined - const channelInput = sourceConfig.channel as string | undefined + const channelInputs = parseMultiValue(sourceConfig.channel) const maxMessages = sourceConfig.maxMessages as string | undefined if (!teamId?.trim()) { return { valid: false, error: 'Team ID is required' } } - if (!channelInput?.trim()) { - return { valid: false, error: 'Channel is required' } + if (channelInputs.length === 0) { + return { valid: false, error: 'At least one channel is required' } } if (maxMessages && (Number.isNaN(Number(maxMessages)) || Number(maxMessages) <= 0)) { @@ -387,15 +406,17 @@ export const microsoftTeamsConnector: ConnectorConfig = { } try { - const channel = await resolveChannel(accessToken, teamId, channelInput.trim()) - if (!channel) { - return { valid: false, error: `Channel not found: ${channelInput}` } + for (const channelInput of channelInputs) { + const channel = await resolveChannel(accessToken, teamId, channelInput) + if (!channel) { + return { valid: false, error: `Channel not found: ${channelInput}` } + } + + // Verify we can read messages by fetching a single message + const messagesPath = `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channel.id)}/messages?$top=1` + await graphApiGet(messagesPath, accessToken, VALIDATE_RETRY_OPTIONS) } - // Verify we can read messages by fetching a single message - const messagesPath = `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channel.id)}/messages?$top=1` - await graphApiGet(messagesPath, accessToken, VALIDATE_RETRY_OPTIONS) - return { valid: true } } catch (error) { const message = getErrorMessage(error, 'Failed to validate configuration') diff --git a/apps/sim/connectors/notion/notion.ts b/apps/sim/connectors/notion/notion.ts index 49da592e51e..e4181cd4803 100644 --- a/apps/sim/connectors/notion/notion.ts +++ b/apps/sim/connectors/notion/notion.ts @@ -3,7 +3,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { NotionIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { joinTagArray, parseTagDate } from '@/connectors/utils' +import { joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('NotionConnector') @@ -199,22 +199,24 @@ export const notionConnector: ConnectorConfig = { }, { id: 'databaseSelector', - title: 'Database', + title: 'Databases', type: 'selector', selectorKey: 'notion.databases', canonicalParamId: 'databaseId', mode: 'basic', - placeholder: 'Select a database', + multi: true, + placeholder: 'Select one or more databases', required: false, }, { id: 'databaseId', - title: 'Database ID', + title: 'Database IDs', type: 'short-input', canonicalParamId: 'databaseId', mode: 'advanced', + multi: true, required: false, - placeholder: 'e.g. 8a3b5f6e-1234-5678-abcd-ef0123456789', + placeholder: 'e.g. 8a3b5f6e-..., 9c4d6e7f-... (comma-separated for multiple)', }, { id: 'rootPageId', @@ -246,12 +248,12 @@ export const notionConnector: ConnectorConfig = { syncContext?: Record ): Promise => { const scope = (sourceConfig.scope as string) || 'workspace' - const databaseId = (sourceConfig.databaseId as string)?.trim() + const databaseIds = parseMultiValue(sourceConfig.databaseId) const rootPageId = (sourceConfig.rootPageId as string)?.trim() const maxPages = sourceConfig.maxPages ? Number(sourceConfig.maxPages) : 0 - if (scope === 'database' && databaseId) { - return listFromDatabase(accessToken, databaseId, maxPages, cursor, syncContext) + if (scope === 'database' && databaseIds.length > 0) { + return listFromDatabases(accessToken, databaseIds, maxPages, cursor, syncContext) } if (scope === 'page' && rootPageId) { @@ -304,7 +306,7 @@ export const notionConnector: ConnectorConfig = { sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const scope = (sourceConfig.scope as string) || 'workspace' - const databaseId = (sourceConfig.databaseId as string)?.trim() + const databaseIds = parseMultiValue(sourceConfig.databaseId) const rootPageId = (sourceConfig.rootPageId as string)?.trim() const maxPages = sourceConfig.maxPages as string | undefined @@ -312,8 +314,11 @@ export const notionConnector: ConnectorConfig = { return { valid: false, error: 'Max pages must be a positive number' } } - if (scope === 'database' && !databaseId) { - return { valid: false, error: 'Database ID is required when scope is "Specific database"' } + if (scope === 'database' && databaseIds.length === 0) { + return { + valid: false, + error: 'At least one database is required when scope is "Specific database"', + } } if (scope === 'page' && !rootPageId) { @@ -322,21 +327,26 @@ export const notionConnector: ConnectorConfig = { try { // Verify the token works - if (scope === 'database' && databaseId) { - // Verify database is accessible - const response = await fetchWithRetry( - `${NOTION_BASE_URL}/databases/${databaseId}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Notion-Version': NOTION_API_VERSION, + if (scope === 'database' && databaseIds.length > 0) { + // Verify every database is accessible + for (const databaseId of databaseIds) { + const response = await fetchWithRetry( + `${NOTION_BASE_URL}/databases/${databaseId}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Notion-Version': NOTION_API_VERSION, + }, }, - }, - VALIDATE_RETRY_OPTIONS - ) - if (!response.ok) { - return { valid: false, error: `Cannot access database: ${response.status}` } + VALIDATE_RETRY_OPTIONS + ) + if (!response.ok) { + return { + valid: false, + error: `Cannot access database ${databaseId}: ${response.status}`, + } + } } } else if (scope === 'page' && rootPageId) { // Verify page is accessible @@ -467,58 +477,131 @@ async function listFromWorkspace( } /** - * Lists pages from a specific Notion database. + * Lists pages from one or more Notion databases. + * + * Notion's `/v1/databases/{database_id}/query` endpoint is per-database — there + * is no batch query endpoint — so multiple databases are walked sequentially. + * + * Cursor format: + * - Single database: the Notion `start_cursor` string directly, or undefined. + * - Multiple databases: JSON-encoded `{ databaseIndex, cursor }` where + * `databaseIndex` is the position into `databaseIds` currently being drained + * and `cursor` is the Notion `start_cursor` for that database (or undefined + * when starting a fresh database). + * + * Page IDs returned by Notion are globally-unique UUIDs, so each page's + * `externalId` does not need to be namespaced by database. */ -async function listFromDatabase( +async function listFromDatabases( accessToken: string, - databaseId: string, + databaseIds: string[], maxPages: number, cursor?: string, syncContext?: Record ): Promise { - const body: Record = { - page_size: 100, - } + let databaseIndex = 0 + let startCursor: string | undefined if (cursor) { - body.start_cursor = cursor + if (databaseIds.length === 1) { + // Single-database path: cursor is always a bare Notion `next_cursor` string, + // matching the legacy pre-multi-select format. Never JSON-decode here. + startCursor = cursor + } else { + try { + const parsed = JSON.parse(cursor) as unknown + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as { databaseIndex?: unknown }).databaseIndex === 'number' + ) { + const compound = parsed as { databaseIndex: number; cursor?: string } + databaseIndex = compound.databaseIndex + startCursor = typeof compound.cursor === 'string' ? compound.cursor : undefined + } else { + // Legacy single-DB cursor carried forward into a now-multi-DB config: + // treat it as the start cursor for the first database. + startCursor = cursor + } + } catch { + startCursor = cursor + } + } } - logger.info('Querying Notion database', { databaseId, cursor }) + const documents: ExternalDocument[] = [] + let nextCursor: string | undefined + let hasMore = false + + while (databaseIndex < databaseIds.length) { + const databaseId = databaseIds[databaseIndex] + const body: Record = { page_size: 100 } + if (startCursor) body.start_cursor = startCursor + + logger.info('Querying Notion database', { + databaseId, + databaseIndex, + databaseCount: databaseIds.length, + startCursor, + }) - const response = await fetchWithRetry(`${NOTION_BASE_URL}/databases/${databaseId}/query`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Notion-Version': NOTION_API_VERSION, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }) + const response = await fetchWithRetry(`${NOTION_BASE_URL}/databases/${databaseId}/query`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Notion-Version': NOTION_API_VERSION, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) - if (!response.ok) { - const errorText = await response.text() - logger.error('Failed to query Notion database', { status: response.status, error: errorText }) - throw new Error(`Failed to query Notion database: ${response.status}`) - } + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to query Notion database', { + databaseId, + status: response.status, + error: errorText, + }) + throw new Error(`Failed to query Notion database ${databaseId}: ${response.status}`) + } - const data = await response.json() - const results = (data.results || []) as Record[] - const pages = results.filter((r) => r.object === 'page' && !(r.archived as boolean)) + const data = await response.json() + const results = (data.results || []) as Record[] + const pages = results.filter((r) => r.object === 'page' && !(r.archived as boolean)) + documents.push(...pages.map(pageToStub)) + + if (data.has_more === true && typeof data.next_cursor === 'string') { + const nextStart = data.next_cursor as string + nextCursor = + databaseIds.length === 1 ? nextStart : JSON.stringify({ databaseIndex, cursor: nextStart }) + hasMore = true + break + } - const documents = pages.map(pageToStub) + databaseIndex++ + startCursor = undefined + + if (databaseIndex < databaseIds.length) { + nextCursor = + databaseIds.length === 1 ? undefined : JSON.stringify({ databaseIndex, cursor: undefined }) + hasMore = true + break + } + } const totalFetched = ((syncContext?.totalDocsFetched as number) ?? 0) + documents.length if (syncContext) syncContext.totalDocsFetched = totalFetched const hitLimit = maxPages > 0 && totalFetched >= maxPages - if (hitLimit && syncContext) syncContext.listingCapped = true - - const nextCursor = hitLimit ? undefined : ((data.next_cursor as string) ?? undefined) + if (hitLimit) { + if (syncContext) syncContext.listingCapped = true + hasMore = false + nextCursor = undefined + } return { documents, - nextCursor, - hasMore: hitLimit ? false : data.has_more === true, + nextCursor: hasMore ? nextCursor : undefined, + hasMore, } } diff --git a/apps/sim/connectors/slack/slack.ts b/apps/sim/connectors/slack/slack.ts index 209a6fb4231..7b360309c32 100644 --- a/apps/sim/connectors/slack/slack.ts +++ b/apps/sim/connectors/slack/slack.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import { SlackIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { parseTagDate } from '@/connectors/utils' +import { parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('SlackConnector') @@ -41,12 +41,16 @@ const SLACK_NOISE_SUBTYPES = new Set([ interface SlackMessage { type: string user?: string + username?: string + bot_id?: string text?: string ts: string subtype?: string edited?: { ts: string; user?: string } latest_reply?: string reply_count?: number + attachments?: Record[] + blocks?: Record[] } interface SlackChannel { @@ -197,9 +201,116 @@ async function fetchChannelMessages( return { messages: trimmed, lastActivityTs, oldestTs } } +/** + * Pulls user-visible text from a Slack message's `text`, legacy `attachments`, + * and Block Kit `blocks`. Apps like GitHub typically post a short `text` + * summary with the actual PR/issue content inside attachments or blocks, so + * reading `text` alone drops the meaningful body. + */ +function extractMessageContent(msg: SlackMessage): string { + const parts: string[] = [] + if (msg.text) parts.push(msg.text) + + for (const attachment of msg.attachments ?? []) { + for (const key of ['pretext', 'author_name', 'title', 'text', 'footer'] as const) { + const v = attachment[key] + if (typeof v === 'string' && v.trim()) parts.push(v) + } + const fields = attachment.fields + if (Array.isArray(fields)) { + for (const f of fields) { + if (!f || typeof f !== 'object') continue + const fo = f as Record + const title = typeof fo.title === 'string' ? fo.title : '' + const value = typeof fo.value === 'string' ? fo.value : '' + if (title && value) parts.push(`${title}: ${value}`) + else if (title || value) parts.push(title || value) + } + } + /** + * Attachments may also embed Block Kit blocks + * (https://docs.slack.dev/legacy/legacy-messaging/legacy-secondary-message-attachments). + * Apps like GitHub put the bulk of the PR/issue body inside attachment.blocks. + */ + const nestedBlocks = attachment.blocks + if (Array.isArray(nestedBlocks)) { + for (const block of nestedBlocks) { + const blockParts: string[] = [] + walkBlockText(block, blockParts) + if (blockParts.length > 0) parts.push(blockParts.join(' ')) + } + } + } + + for (const block of msg.blocks ?? []) { + const blockParts: string[] = [] + walkBlockText(block, blockParts) + if (blockParts.length > 0) parts.push(blockParts.join(' ')) + } + + return parts.filter((s) => s.trim().length > 0).join('\n') +} + +/** + * Recursively walks Block Kit nodes pulling leaf text. Covers section + * (`text` + `fields` + `accessory`), header (`text`), context + * (`elements[].text`/`alt_text`), image blocks (`alt_text` + `title`), and + * rich_text (nested `elements[].elements[]`). Link nodes without text fall + * back to their URL; emoji nodes render as `:name:`; broadcast leafs render + * as `@here`/`@channel`/`@everyone`; date leafs render their `fallback`; + * user/channel/usergroup mentions render their referenced id. + */ +function walkBlockText(node: unknown, out: string[]): void { + if (!node || typeof node !== 'object') return + const n = node as Record + if (typeof n.text === 'string') { + out.push(n.text) + } else if (n.text && typeof n.text === 'object') { + walkBlockText(n.text, out) + } + if (Array.isArray(n.fields)) { + for (const f of n.fields) walkBlockText(f, out) + } + if (Array.isArray(n.elements)) { + for (const e of n.elements) walkBlockText(e, out) + } + /** + * Section blocks expose a single side accessory (button, image, overflow + * menu) that frequently carries user-visible labels. + */ + if (n.accessory && typeof n.accessory === 'object') { + walkBlockText(n.accessory, out) + } + if (typeof n.alt_text === 'string' && n.alt_text.trim()) { + out.push(n.alt_text) + } + if (n.type === 'link' && typeof n.url === 'string' && typeof n.text !== 'string') { + out.push(n.url) + } + if (n.type === 'emoji' && typeof n.name === 'string') { + out.push(`:${n.name}:`) + } + if (n.type === 'broadcast' && typeof n.range === 'string') { + out.push(`@${n.range}`) + } + if (n.type === 'user' && typeof n.user_id === 'string') { + out.push(`<@${n.user_id}>`) + } + if (n.type === 'channel' && typeof n.channel_id === 'string') { + out.push(`<#${n.channel_id}>`) + } + if (n.type === 'usergroup' && typeof n.usergroup_id === 'string') { + out.push(``) + } + if (n.type === 'date' && typeof n.fallback === 'string') { + out.push(n.fallback) + } +} + /** * Converts fetched messages into a single document content string. - * Each line: "[ISO timestamp] username: message text" + * Each entry: "[ISO timestamp] username: message text" (text may span lines + * when the message has rich attachment/block content). */ async function formatMessages( accessToken: string, @@ -212,8 +323,6 @@ async function formatMessages( const chronological = [...messages].reverse() for (const msg of chronological) { - // Skip non-user messages (join/leave, bot messages without text, etc.) - if (!msg.text) continue /** * Drop only known noise subtypes (channel join/leave/topic events, * bot add/remove, etc.). Per https://api.slack.com/events/message any @@ -222,12 +331,15 @@ async function formatMessages( */ if (msg.subtype && SLACK_NOISE_SUBTYPES.has(msg.subtype)) continue + const content = extractMessageContent(msg) + if (!content) continue + const timestamp = formatSlackTimestamp(msg.ts) const userName = msg.user ? await resolveUserName(accessToken, msg.user, syncContext) - : 'unknown' + : msg.username || 'unknown' - lines.push(`[${timestamp}] ${userName}: ${msg.text}`) + lines.push(`[${timestamp}] ${userName}: ${content}`) } return lines.join('\n') @@ -360,7 +472,13 @@ async function buildSlackChannelDocument( * in catches deletes (count drops) but still cannot detect reply edits * without fetching `conversations.replies` for each parent. */ - const contentHash = `slack:${channel.id}:${oldestTs ?? 'empty'}:${lastActivityTs ?? 'empty'}:${messageCount}:${maxEditTs || 'noedit'}:${maxReplyTs || 'noreply'}:${totalReplies}` + /** + * `slack-v2` prefix forces a one-time re-sync for channels indexed before + * we started extracting attachment + Block Kit content from bot messages. + * Per-message `ts` and `messageCount` are unchanged, so without the version + * bump the hash would match and richer content would not be re-embedded. + */ + const contentHash = `slack-v2:${channel.id}:${oldestTs ?? 'empty'}:${lastActivityTs ?? 'empty'}:${messageCount}:${maxEditTs || 'noedit'}:${maxReplyTs || 'noreply'}:${totalReplies}` return { content, contentHash, messageCount, lastActivityTs } } @@ -387,24 +505,26 @@ export const slackConnector: ConnectorConfig = { configFields: [ { id: 'channelSelector', - title: 'Channel', + title: 'Channels', type: 'selector', selectorKey: 'slack.channels', canonicalParamId: 'channel', mode: 'basic', - placeholder: 'Select a channel', + multi: true, + placeholder: 'Select one or more channels', required: true, - description: 'Channel to sync messages from', + description: 'Channels to sync messages from', }, { id: 'channel', - title: 'Channel', + title: 'Channels', type: 'short-input', canonicalParamId: 'channel', mode: 'advanced', - placeholder: 'e.g. general or C01ABC23DEF', + multi: true, + placeholder: 'e.g. general, C01ABC23DEF (comma-separated for multiple)', required: true, - description: 'Channel name or ID to sync messages from', + description: 'Channel names or IDs to sync messages from', }, { id: 'maxMessages', @@ -421,57 +541,63 @@ export const slackConnector: ConnectorConfig = { _cursor?: string, syncContext?: Record ): Promise => { - const channelInput = sourceConfig.channel as string - if (!channelInput?.trim()) { - throw new Error('Channel is required') + const channelInputs = parseMultiValue(sourceConfig.channel) + if (channelInputs.length === 0) { + throw new Error('At least one channel is required') } const maxMessages = sourceConfig.maxMessages ? Number(sourceConfig.maxMessages) : DEFAULT_MAX_MESSAGES - logger.info('Syncing Slack channel', { channel: channelInput, maxMessages }) + logger.info('Syncing Slack channels', { channels: channelInputs, maxMessages }) - const channel = await resolveChannel(accessToken, channelInput) - if (!channel) { - throw new Error(`Channel not found: ${channelInput}`) - } + const teamId = await resolveTeamId(accessToken, syncContext) + const documents: ExternalDocument[] = [] - const { content, contentHash, messageCount, lastActivityTs } = await buildSlackChannelDocument( - accessToken, - channel, - maxMessages, - syncContext - ) - if (!content.trim()) { - logger.info(`No messages found in channel: #${channel.name}`) - return { documents: [], hasMore: false } - } + for (const channelInput of channelInputs) { + const channel = await resolveChannel(accessToken, channelInput) + if (!channel) { + logger.info(`Channel not found, skipping: ${channelInput}`) + continue + } - const teamId = await resolveTeamId(accessToken, syncContext) - const sourceUrl = teamId - ? `https://app.slack.com/client/${teamId}/${channel.id}` - : `https://app.slack.com/client/${channel.id}` - - const document: ExternalDocument = { - externalId: channel.id, - title: `#${channel.name}`, - content, - mimeType: 'text/plain', - sourceUrl, - contentHash, - metadata: { - channelName: channel.name, - messageCount, - lastActivity: lastActivityTs ? formatSlackTimestamp(lastActivityTs) : undefined, - topic: channel.topic?.value, - purpose: channel.purpose?.value, - }, + const { content, contentHash, messageCount, lastActivityTs } = + await buildSlackChannelDocument(accessToken, channel, maxMessages, syncContext) + if (!content.trim()) { + logger.info(`No messages found in channel: #${channel.name}`) + continue + } + + const sourceUrl = teamId + ? `https://app.slack.com/client/${teamId}/${channel.id}` + : `https://app.slack.com/client/${channel.id}` + + documents.push({ + externalId: channel.id, + title: `#${channel.name}`, + content, + mimeType: 'text/plain', + sourceUrl, + contentHash, + metadata: { + channelName: channel.name, + messageCount, + lastActivity: lastActivityTs ? formatSlackTimestamp(lastActivityTs) : undefined, + topic: channel.topic?.value, + purpose: channel.purpose?.value, + }, + }) } - // Each channel is one document; no pagination needed + /** + * All channels are processed in one call — the multi-select UI keeps the + * count small, and each channel is an independent document with its own + * `externalId` and `contentHash`, so the sync engine treats them as + * independent documents. + */ return { - documents: [document], + documents, hasMore: false, } }, @@ -527,11 +653,11 @@ export const slackConnector: ConnectorConfig = { accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { - const channelInput = sourceConfig.channel as string | undefined + const channelInputs = parseMultiValue(sourceConfig.channel) const maxMessages = sourceConfig.maxMessages as string | undefined - if (!channelInput?.trim()) { - return { valid: false, error: 'Channel is required' } + if (channelInputs.length === 0) { + return { valid: false, error: 'At least one channel is required' } } if (maxMessages && (Number.isNaN(Number(maxMessages)) || Number(maxMessages) <= 0)) { @@ -539,21 +665,37 @@ export const slackConnector: ConnectorConfig = { } try { - const trimmed = channelInput.trim().replace(/^#/, '') + /** + * Validate every selected channel. ID-shaped inputs use `conversations.info` + * directly; name-shaped inputs are resolved by paginating `conversations.list` + * once and matching all remaining names against the same pages — this avoids + * walking the full channel list once per name. + */ + const nameLookups: string[] = [] + for (const input of channelInputs) { + const trimmed = input.trim().replace(/^#/, '') + + if (/^[CG][A-Z0-9]+$/.test(trimmed)) { + try { + await slackApiGet( + 'conversations.info', + accessToken, + { channel: trimmed }, + VALIDATE_RETRY_OPTIONS + ) + } catch { + return { valid: false, error: `Channel not found: ${input}` } + } + } else { + nameLookups.push(trimmed) + } + } - // If it looks like a channel ID, verify directly. DMs (D...) are excluded - // because we don't request im:*/mpim:* scopes — see resolveChannel. - if (/^[CG][A-Z0-9]+$/.test(trimmed)) { - await slackApiGet( - 'conversations.info', - accessToken, - { channel: trimmed }, - VALIDATE_RETRY_OPTIONS - ) + if (nameLookups.length === 0) { return { valid: true } } - // Otherwise search by name (include private channels the bot is in) + const remaining = new Set(nameLookups) let cursor: string | undefined do { const params: Record = { @@ -573,14 +715,20 @@ export const slackConnector: ConnectorConfig = { ) const channels = (data.channels as SlackChannel[]) || [] - const match = channels.find((ch) => ch.name === trimmed) - if (match) return { valid: true } + for (const ch of channels) { + if (remaining.has(ch.name)) { + remaining.delete(ch.name) + } + } + + if (remaining.size === 0) return { valid: true } const responseMeta = data.response_metadata as { next_cursor?: string } | undefined cursor = responseMeta?.next_cursor || undefined } while (cursor) - return { valid: false, error: `Channel not found: ${channelInput}` } + const missing = Array.from(remaining) + return { valid: false, error: `Channel(s) not found: ${missing.join(', ')}` } } catch (error) { const message = toError(error).message || 'Failed to validate configuration' return { valid: false, error: message } diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index 927678f12a5..71452b5c512 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -74,6 +74,14 @@ export interface ConnectorConfigField { mode?: 'basic' | 'advanced' /** Links selector + manual input fields that resolve to the same config key */ canonicalParamId?: string + + /** + * When true, the field accepts multiple values. + * - For `selector` fields, renders the picker in multi-select mode and persists `string[]` to sourceConfig. + * - For `short-input` fields, accepts a comma-separated list and persists `string[]` to sourceConfig. + * Connector handlers receive `string | string[]` and should normalize via `parseMultiValue`. + */ + multi?: boolean } /** diff --git a/apps/sim/connectors/utils.ts b/apps/sim/connectors/utils.ts index dce83b7a76e..391d3a590f8 100644 --- a/apps/sim/connectors/utils.ts +++ b/apps/sim/connectors/utils.ts @@ -43,3 +43,38 @@ export function joinTagArray(value: unknown): string | undefined { const arr = Array.isArray(value) ? (value as string[]) : [] return arr.length > 0 ? arr.join(', ') : undefined } + +/** + * Normalizes a multi-value sourceConfig field into a trimmed, deduplicated string array. + * + * Accepts a string (CSV from advanced manual input or legacy single-value), an array + * of strings (from multi-select UI or new array storage), or undefined/null. Always + * returns a string[] — connectors call this once at the top of listDocuments to + * branch on `values.length` for single vs multi behavior. + */ +export function parseMultiValue(value: unknown): string[] { + if (Array.isArray(value)) { + const seen = new Set() + const out: string[] = [] + for (const item of value) { + if (typeof item !== 'string') continue + const trimmed = item.trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + out.push(trimmed) + } + return out + } + if (typeof value === 'string') { + const seen = new Set() + const out: string[] = [] + for (const part of value.split(',')) { + const trimmed = part.trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + out.push(trimmed) + } + return out + } + return [] +} diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts index 84e0e528609..7ebf81640bb 100644 --- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts +++ b/apps/sim/hooks/selectors/providers/confluence/selectors.ts @@ -37,7 +37,7 @@ export const confluenceSelectors = { signal, }) for (const space of data.spaces || []) { - collected.push({ id: space.id, label: formatConfluenceSpaceLabel(space) }) + collected.push({ id: space.key, label: formatConfluenceSpaceLabel(space) }) } cursor = data.nextCursor } while (cursor) @@ -57,7 +57,7 @@ export const confluenceSelectors = { }) return { items: (data.spaces || []).map((space) => ({ - id: space.id, + id: space.key, label: formatConfluenceSpaceLabel(space), })), nextCursor: data.nextCursor, @@ -81,9 +81,9 @@ export const confluenceSelectors = { }, signal, }) - const space = (data.spaces || []).find((s) => s.id === detailId) ?? null + const space = (data.spaces || []).find((s) => s.key === detailId) ?? null if (!space) return null - return { id: space.id, label: formatConfluenceSpaceLabel(space) } + return { id: space.key, label: formatConfluenceSpaceLabel(space) } }, }, 'confluence.pages': { From e6d8ccd7972e37a0623f2710fffab0fad69d3883 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 18:21:17 -0700 Subject: [PATCH 2/6] fix(kb-connectors): valuesEqual treats legacy scalar as equal to multi-array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing connectors created before multi-select store sourceConfig values as scalars (e.g. projectKey: "ENG"). With the field now declared multi: true, resolveSourceConfig returns an array (["ENG"]), and the original valuesEqual fell through to a strict reference comparison — falsely flagging unsaved changes on open and triggering an unnecessary string→array shape rewrite on save. valuesEqual now normalizes both sides to string[] via CSV-split when either is an array, so persisted scalar and in-memory array of the same content compare equal. Single-value (non-multi) fields keep strict string equality. --- .../edit-connector-modal.tsx | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index af0440be9a7..711bde025bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -67,20 +67,40 @@ function readPersistedCanonicalModes( /** * Deep equality for sourceConfig values (string, string[], or undefined/null). - * Empty string and empty array are treated as equivalent to absence. + * + * Empty string, empty array, and nullish are treated as equivalent to absence. + * When either side is an array (multi-value field), both sides are normalized + * to string[] via CSV-split-and-trim so a persisted legacy scalar `"ENG"` + * compares equal to an in-memory `["ENG"]` and a persisted CSV `"ENG,PROJ"` + * compares equal to `["ENG","PROJ"]`. Without this, opening edit on a + * pre-multi-select connector would falsely show unsaved changes. */ function valuesEqual(a: unknown, b: unknown): boolean { const isEmpty = (v: unknown): boolean => { if (v == null) return true if (Array.isArray(v)) return v.length === 0 - if (typeof v === 'string') return v === '' + if (typeof v === 'string') return v.trim() === '' return false } if (isEmpty(a) && isEmpty(b)) return true - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false + + const toArray = (v: unknown): string[] | null => { + if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string') + if (typeof v === 'string') { + return v + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + return null + } + + if (Array.isArray(a) || Array.isArray(b)) { + const arrA = toArray(a) ?? [] + const arrB = toArray(b) ?? [] + if (arrA.length !== arrB.length) return false + for (let i = 0; i < arrA.length; i++) { + if (arrA[i] !== arrB[i]) return false } return true } From 9bd72ceda6b45a94df4f497f793706a6fb612e7a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 18:40:44 -0700 Subject: [PATCH 3/6] fix(kb-connectors): GCal externalId on config downgrade, Slack silent skip, valuesEqual order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - google-calendar getDocument: derive isMultiCalendar from the externalId's `:` separator instead of the current config count. Prevents duplicates when a user downgrades from multi to single calendar — previously the returned doc lost its `calendarId:` prefix and was treated as a new row by the sync engine, orphaning the original. - slack listDocuments: throw on unresolvable channel instead of silently skipping. Matches MS Teams behaviour. Silent skip would let the sync engine orphan-delete the previously indexed channel content if a bot was removed or a channel was archived/renamed mid-life. - edit-connector-modal valuesEqual: order-insensitive comparison for multi- select arrays via Set membership. Multi-select UI doesn't guarantee insertion order matches the server-returned order, so `["A","B"]` vs `["B","A"]` would otherwise flag false unsaved changes. --- .../edit-connector-modal/edit-connector-modal.tsx | 11 +++++++---- .../sim/connectors/google-calendar/google-calendar.ts | 9 ++++++++- apps/sim/connectors/slack/slack.ts | 10 ++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 711bde025bf..a15578145f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -99,10 +99,13 @@ function valuesEqual(a: unknown, b: unknown): boolean { const arrA = toArray(a) ?? [] const arrB = toArray(b) ?? [] if (arrA.length !== arrB.length) return false - for (let i = 0; i < arrA.length; i++) { - if (arrA[i] !== arrB[i]) return false - } - return true + /** + * Order-insensitive: the multi-select UI does not guarantee insertion order + * matches the server-returned order, so `["PROD","ENG"]` and `["ENG","PROD"]` + * should be treated as equal to avoid a false unsaved-changes state. + */ + const setA = new Set(arrA) + return arrB.every((v) => setA.has(v)) } return a === b } diff --git a/apps/sim/connectors/google-calendar/google-calendar.ts b/apps/sim/connectors/google-calendar/google-calendar.ts index f8946e8b97d..104743b535d 100644 --- a/apps/sim/connectors/google-calendar/google-calendar.ts +++ b/apps/sim/connectors/google-calendar/google-calendar.ts @@ -455,9 +455,16 @@ export const googleCalendarConnector: ConnectorConfig = { */ const parsedCalendarIds = parseMultiValue(sourceConfig.calendarId) const calendarIds = parsedCalendarIds.length > 0 ? parsedCalendarIds : ['primary'] - const isMultiCalendar = calendarIds.length > 1 + /** + * Derive `isMultiCalendar` from the externalId itself, not from the current + * config. If a row was synced under a multi-calendar config and the user + * later removed calendars, the row's externalId still has the prefix — + * returning a doc without the prefix would mint a duplicate via the sync + * engine's externalId-keyed matching. + */ const separatorIndex = externalId.indexOf(':') + const isMultiCalendar = separatorIndex !== -1 let calendarId: string let eventId: string if (separatorIndex === -1) { diff --git a/apps/sim/connectors/slack/slack.ts b/apps/sim/connectors/slack/slack.ts index 7b360309c32..814fe13a734 100644 --- a/apps/sim/connectors/slack/slack.ts +++ b/apps/sim/connectors/slack/slack.ts @@ -558,8 +558,14 @@ export const slackConnector: ConnectorConfig = { for (const channelInput of channelInputs) { const channel = await resolveChannel(accessToken, channelInput) if (!channel) { - logger.info(`Channel not found, skipping: ${channelInput}`) - continue + /** + * Fail loudly rather than silently skipping. A configured channel that + * suddenly stops resolving (bot removed, channel archived, renamed) + * would otherwise have its previously-indexed document orphaned and + * deleted by the sync engine with no error surfaced. Matches the MS + * Teams connector's behaviour. + */ + throw new Error(`Channel not found: ${channelInput}`) } const { content, contentHash, messageCount, lastActivityTs } = From 239217367c67555a6dbbc2c08ad4c8d708e9af72 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 19:09:08 -0700 Subject: [PATCH 4/6] chore(kb-connectors): use emptyValue() fallback in isFieldPopulated for consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior unchanged — isValuePopulated('') and isValuePopulated([]) both return false — but reading the field-typed fallback inline matches the convention used elsewhere in the hook (coerceForField, handleFieldChange, resolveSourceConfig). --- .../knowledge/[id]/hooks/use-connector-config-fields.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts index 13558506ddb..737c96caced 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts @@ -145,7 +145,8 @@ export function useConnectorConfigFields({ ) const isFieldPopulated = useCallback( - (field: ConnectorConfigField): boolean => isValuePopulated(sourceConfig[field.id] ?? ''), + (field: ConnectorConfigField): boolean => + isValuePopulated(sourceConfig[field.id] ?? emptyValue(field)), [sourceConfig] ) From 07049be4d6b56daf9e36d653bf19a83c9324a579 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 19:17:55 -0700 Subject: [PATCH 5/6] fix(kb-connectors): Linear projects selector loads across all selected teams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the team selector is in multi-select mode, the basic-mode projects dropdown was passing only the first team ID into the linear.projects selector context (via readFirst in resolveDepValue), so projects from other selected teams were invisible. resolveDepValue now joins multi-value parents into a CSV string so dependent selectors receive every selected parent ID. The /api/tools/linear/projects route splits the CSV teamId, fetches projects from each team in parallel, and dedupes by project ID. Single-team configs pass through unchanged (`split(",")` on a bare ID yields a one-element array). The AND-of-filters semantics in buildIssuesQuery is intentional and matches standard GraphQL filter behavior — a user filtering on teams [A,B] and projects [X,Y] gets issues in (A or B) AND (X or Y). With this fix the project dropdown now shows every project under any selected team so the user can compose the right project set. --- .../app/api/tools/linear/projects/route.ts | 39 +++++++++++++++---- .../connector-selector-field.tsx | 16 +++++--- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index abf3ad14135..e7ca9bab1a7 100644 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -45,17 +45,40 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const linearClient = new LinearClient({ accessToken }) - let projects: Array<{ id: string; name: string }> = [] - const team = await linearClient.team(teamId) - const projectsResult = await team.projects() - projects = projectsResult.nodes.map((project: Project) => ({ - id: project.id, - name: project.name, - })) + /** + * teamId may be a single ID or a comma-separated list when the basic-mode + * team selector is in multi-select. Fetch projects from each team in + * parallel and dedupe by project ID (Linear projects can be cross-team). + */ + const teamIds = teamId + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + const perTeam = await Promise.all( + teamIds.map(async (id) => { + const team = await linearClient.team(id) + const result = await team.projects() + return result.nodes.map((project: Project) => ({ + id: project.id, + name: project.name, + })) + }) + ) + + const seen = new Set() + const projects: Array<{ id: string; name: string }> = [] + for (const teamProjects of perTeam) { + for (const project of teamProjects) { + if (seen.has(project.id)) continue + seen.add(project.id) + projects.push(project) + } + } if (projects.length === 0) { - logger.info('No projects found for team', { teamId }) + logger.info('No projects found for team(s)', { teamIds }) } return NextResponse.json({ projects }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index 9fb05d90aef..aab56688cad 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -128,19 +128,25 @@ function resolveDepValue( sourceConfig: ConfigFieldMap ): string { const depField = configFields.find((f) => f.id === depFieldId) - const readFirst = (raw: ConfigFieldValue | undefined): string => { - if (Array.isArray(raw)) return raw[0] ?? '' + /** + * For multi-value parent fields, pass all selected values to dependent + * selectors as a comma-joined string so the downstream selector can load + * options across every selected parent (e.g. Linear projects across multiple + * selected teams). Single-value parents pass through unchanged. + */ + const readDep = (raw: ConfigFieldValue | undefined): string => { + if (Array.isArray(raw)) return raw.join(',') return raw ?? '' } - if (!depField?.canonicalParamId) return readFirst(sourceConfig[depFieldId]) + if (!depField?.canonicalParamId) return readDep(sourceConfig[depFieldId]) const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic' - if (depField.mode === activeMode) return readFirst(sourceConfig[depFieldId]) + if (depField.mode === activeMode) return readDep(sourceConfig[depFieldId]) const activeField = configFields.find( (f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode ) - return activeField ? readFirst(sourceConfig[activeField.id]) : readFirst(sourceConfig[depFieldId]) + return activeField ? readDep(sourceConfig[activeField.id]) : readDep(sourceConfig[depFieldId]) } function getDependencyLabel( From 84c31598ed6ec2b335b831a363185b48f878c1c2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 May 2026 19:24:14 -0700 Subject: [PATCH 6/6] fix(gmail-connector): always wrap OR-containing custom query, not just unwrapped ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous check `!/^\(.*\)$/.test(trimmedCustom)` was supposed to avoid double-wrapping an already-parenthesized expression, but it false-positives on inputs like `(from:alice) OR (from:bob)` where the parens don't bracket the whole string. Those would skip wrapping and the top-level OR would bind across the preceding label / category / date filters instead of the custom clause. Always wrap when an OR is present — double-parens are a no-op in Gmail search syntax, so `((from:a OR from:b))` parses the same as `(from:a OR from:b)`. Simpler than walking parens depth and provably safe. --- apps/sim/connectors/gmail/gmail.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/sim/connectors/gmail/gmail.ts b/apps/sim/connectors/gmail/gmail.ts index e0408073823..eab77834c27 100644 --- a/apps/sim/connectors/gmail/gmail.ts +++ b/apps/sim/connectors/gmail/gmail.ts @@ -114,10 +114,15 @@ function buildSearchQuery(sourceConfig: Record): string { const customQuery = sourceConfig.query as string | undefined const trimmedCustom = customQuery?.trim() if (trimmedCustom) { - // Wrap the user-supplied query in parentheses so any internal OR operators - // bind only within the custom expression and don't combine with preceding - // label / category / date filters. - const needsGroup = /\bOR\b/i.test(trimmedCustom) && !/^\(.*\)$/.test(trimmedCustom) + /** + * Wrap the user-supplied query in parentheses whenever it contains an OR + * so it's AND-joined as a single clause with the preceding label / category + * / date filters. Always wrap (rather than try to detect existing outer + * parens) because a regex like /^\(.*\)$/ misclassifies inputs such as + * `(from:alice) OR (from:bob)` where the parens don't bracket the whole + * expression. Double-wrapping is a no-op in Gmail search syntax. + */ + const needsGroup = /\bOR\b/i.test(trimmedCustom) parts.push(needsGroup ? `(${trimmedCustom})` : trimmedCustom) }