mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(kb-connectors): multi-select fields + Slack bot/app message extraction (#4711)
* 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
* fix(kb-connectors): valuesEqual treats legacy scalar as equal to multi-array
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.
* fix(kb-connectors): GCal externalId on config downgrade, Slack silent skip, valuesEqual order
- 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.
* chore(kb-connectors): use emptyValue() fallback in isFieldPopulated for consistency
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).
* fix(kb-connectors): Linear projects selector loads across all selected teams
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.
* fix(gmail-connector): always wrap OR-containing custom query, not just unwrapped ones
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.
This commit is contained in:
@@ -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<string>()
|
||||
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 })
|
||||
|
||||
+23
-7
@@ -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<string, unknown> = {}
|
||||
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 ? (
|
||||
<ConnectorSelectorField
|
||||
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
|
||||
value={sourceConfig[field.id] || ''}
|
||||
onChange={(value) => 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()}`}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={sourceConfig[field.id] || ''}
|
||||
value={
|
||||
Array.isArray(sourceConfig[field.id])
|
||||
? (sourceConfig[field.id] as string[]).join(', ')
|
||||
: (sourceConfig[field.id] as string) || ''
|
||||
}
|
||||
onChange={(e) => handleFieldChange(field.id, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
|
||||
+48
-9
@@ -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<string, string>
|
||||
sourceConfig: ConfigFieldMap
|
||||
configFields: ConnectorConfigField[]
|
||||
canonicalModes: Record<string, 'basic' | 'advanced'>
|
||||
disabled?: boolean
|
||||
@@ -29,6 +33,8 @@ export function ConnectorSelectorField({
|
||||
canonicalModes,
|
||||
disabled,
|
||||
}: ConnectorSelectorFieldProps) {
|
||||
const isMulti = Boolean(field.multi)
|
||||
|
||||
const context = useMemo<SelectorContext>(() => {
|
||||
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 (
|
||||
<Combobox
|
||||
multiSelect
|
||||
options={comboboxOptions}
|
||||
multiSelectValues={multiValues}
|
||||
onMultiSelectChange={(values) => 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 (
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
value={singleValue || undefined}
|
||||
onChange={(next) => onChange(next)}
|
||||
searchable
|
||||
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
|
||||
placeholder={
|
||||
@@ -96,18 +125,28 @@ function resolveDepValue(
|
||||
depFieldId: string,
|
||||
configFields: ConnectorConfigField[],
|
||||
canonicalModes: Record<string, 'basic' | 'advanced'>,
|
||||
sourceConfig: Record<string, string>
|
||||
sourceConfig: ConfigFieldMap
|
||||
): string {
|
||||
const depField = configFields.find((f) => f.id === depFieldId)
|
||||
if (!depField?.canonicalParamId) return sourceConfig[depFieldId] ?? ''
|
||||
/**
|
||||
* 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 readDep(sourceConfig[depFieldId])
|
||||
|
||||
const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic'
|
||||
if (depField.mode === activeMode) return sourceConfig[depFieldId] ?? ''
|
||||
if (depField.mode === activeMode) return readDep(sourceConfig[depFieldId])
|
||||
|
||||
const activeField = configFields.find(
|
||||
(f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode
|
||||
)
|
||||
return activeField ? (sourceConfig[activeField.id] ?? '') : (sourceConfig[depFieldId] ?? '')
|
||||
return activeField ? readDep(sourceConfig[activeField.id]) : readDep(sourceConfig[depFieldId])
|
||||
}
|
||||
|
||||
function getDependencyLabel(
|
||||
|
||||
+89
-13
@@ -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,51 @@ function readPersistedCanonicalModes(
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality for sourceConfig values (string, string[], or undefined/null).
|
||||
*
|
||||
* 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.trim() === ''
|
||||
return false
|
||||
}
|
||||
if (isEmpty(a) && isEmpty(b)) return true
|
||||
|
||||
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
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
function didCanonicalModesChange(
|
||||
current: Record<string, 'basic' | 'advanced'>,
|
||||
persisted: Record<string, 'basic' | 'advanced'>
|
||||
@@ -96,11 +145,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<Record<string, string>>(() => {
|
||||
const config: Record<string, string> = {}
|
||||
const [initialSourceConfig] = useState<ConfigFieldMap>(() => {
|
||||
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 +162,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 +215,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 +237,9 @@ export function EditConnectorModal({
|
||||
}
|
||||
|
||||
const resolved = resolveSourceConfig()
|
||||
const changedEntries: Record<string, string> = {}
|
||||
const changedEntries: Record<string, unknown> = {}
|
||||
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 +344,12 @@ export function EditConnectorModal({
|
||||
|
||||
interface SettingsTabProps {
|
||||
connectorConfig: ConnectorConfig | null
|
||||
sourceConfig: Record<string, string>
|
||||
sourceConfig: ConfigFieldMap
|
||||
credentialId: string | null
|
||||
canonicalGroups: Map<string, ConnectorConfigField[]>
|
||||
canonicalModes: Record<string, 'basic' | 'advanced'>
|
||||
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 +412,8 @@ function SettingsTab({
|
||||
{field.type === 'selector' && field.selectorKey ? (
|
||||
<ConnectorSelectorField
|
||||
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
|
||||
value={sourceConfig[field.id] || ''}
|
||||
onChange={(value) => 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 +427,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()}`}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={sourceConfig[field.id] || ''}
|
||||
value={
|
||||
Array.isArray(sourceConfig[field.id])
|
||||
? (sourceConfig[field.id] as string[]).join(', ')
|
||||
: (sourceConfig[field.id] as string) || ''
|
||||
}
|
||||
onChange={(e) => onFieldChange(field.id, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
|
||||
+71
-22
@@ -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<string, ConfigFieldValue>
|
||||
|
||||
export interface UseConnectorConfigFieldsOptions {
|
||||
connectorConfig: ConnectorConfig | null
|
||||
initialSourceConfig?: Record<string, string>
|
||||
initialSourceConfig?: ConfigFieldMap
|
||||
initialCanonicalModes?: Record<string, 'basic' | 'advanced'>
|
||||
}
|
||||
|
||||
export interface UseConnectorConfigFieldsResult {
|
||||
sourceConfig: Record<string, string>
|
||||
setSourceConfig: React.Dispatch<React.SetStateAction<Record<string, string>>>
|
||||
sourceConfig: ConfigFieldMap
|
||||
setSourceConfig: React.Dispatch<React.SetStateAction<ConfigFieldMap>>
|
||||
canonicalModes: Record<string, 'basic' | 'advanced'>
|
||||
setCanonicalModes: React.Dispatch<React.SetStateAction<Record<string, 'basic' | 'advanced'>>>
|
||||
canonicalGroups: Map<string, ConnectorConfigField[]>
|
||||
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<string, string>
|
||||
resolveSourceConfig: () => Record<string, unknown>
|
||||
}
|
||||
|
||||
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<Record<string, string>>(
|
||||
() => initialSourceConfig ?? {}
|
||||
)
|
||||
const [sourceConfig, setSourceConfig] = useState<ConfigFieldMap>(() => initialSourceConfig ?? {})
|
||||
const [canonicalModes, setCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>(
|
||||
() => initialCanonicalModes ?? {}
|
||||
)
|
||||
@@ -56,6 +89,13 @@ export function useConnectorConfigFields({
|
||||
return groups
|
||||
}, [connectorConfig])
|
||||
|
||||
const fieldsById = useMemo(() => {
|
||||
const map = new Map<string, ConnectorConfigField>()
|
||||
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<string, string[]>()
|
||||
if (!connectorConfig) return result
|
||||
@@ -104,12 +144,18 @@ export function useConnectorConfigFields({
|
||||
[canonicalModes]
|
||||
)
|
||||
|
||||
const handleFieldChange = (fieldId: string, value: string) => {
|
||||
const isFieldPopulated = useCallback(
|
||||
(field: ConnectorConfigField): boolean =>
|
||||
isValuePopulated(sourceConfig[field.id] ?? emptyValue(field)),
|
||||
[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 +168,8 @@ export function useConnectorConfigFields({
|
||||
}))
|
||||
}
|
||||
|
||||
const resolveSourceConfig = useCallback((): Record<string, string> => {
|
||||
const resolved: Record<string, string> = {}
|
||||
const resolveSourceConfig = useCallback((): Record<string, unknown> => {
|
||||
const resolved: Record<string, unknown> = {}
|
||||
const processed = new Set<string>()
|
||||
if (!connectorConfig) return resolved
|
||||
|
||||
@@ -135,9 +181,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 +198,7 @@ export function useConnectorConfigFields({
|
||||
setCanonicalModes,
|
||||
canonicalGroups,
|
||||
isFieldVisible,
|
||||
isFieldPopulated,
|
||||
handleFieldChange,
|
||||
toggleCanonicalMode,
|
||||
resolveSourceConfig,
|
||||
|
||||
@@ -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<string, unknown>
|
||||
): Promise<ExternalDocumentList> => {
|
||||
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<string, unknown>
|
||||
): 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<Record<string, unknown>> | 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"'
|
||||
|
||||
@@ -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, unknown>): 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,18 @@ function buildSearchQuery(sourceConfig: Record<string, unknown>): 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 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)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
@@ -318,24 +352,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 +565,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 +587,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' ||
|
||||
|
||||
@@ -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<string, unknown>): { 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<string, unknown>
|
||||
): Promise<ExternalDocumentList> => {
|
||||
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,41 @@ export const googleCalendarConnector: ConnectorConfig = {
|
||||
sourceConfig: Record<string, unknown>,
|
||||
externalId: string
|
||||
): Promise<ExternalDocument | null> => {
|
||||
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']
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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 +494,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 +506,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())}`
|
||||
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',
|
||||
const response = await fetchWithRetry(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
},
|
||||
VALIDATE_RETRY_OPTIONS
|
||||
)
|
||||
VALIDATE_RETRY_OPTIONS
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return { valid: false, error: 'Calendar not found. Check the calendar ID.' }
|
||||
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 }
|
||||
|
||||
@@ -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<string, unknown>
|
||||
): Promise<ExternalDocumentList> => {
|
||||
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<string, unknown>
|
||||
): 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()}`
|
||||
|
||||
@@ -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<string, unknown>): {
|
||||
function buildIssuesQuery(
|
||||
sourceConfig: Record<string, unknown>,
|
||||
teamIds: string[],
|
||||
projectIds: string[]
|
||||
): {
|
||||
query: string
|
||||
variables: Record<string, unknown>
|
||||
} {
|
||||
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<string, unknown> = {}
|
||||
|
||||
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(', ')}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
): Promise<ExternalDocumentList> => {
|
||||
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 })
|
||||
|
||||
const channel = await resolveChannel(accessToken, teamId, channelInput)
|
||||
if (!channel) {
|
||||
throw new Error(`Channel not found: ${channelInput}`)
|
||||
}
|
||||
|
||||
const { messages, lastActivityTs } = await fetchChannelMessages(
|
||||
accessToken,
|
||||
logger.info('Syncing Microsoft Teams channels', {
|
||||
teamId,
|
||||
channel.id,
|
||||
maxMessages
|
||||
)
|
||||
channels: channelInputs,
|
||||
maxMessages,
|
||||
})
|
||||
|
||||
const content = formatMessages(messages)
|
||||
if (!content.trim()) {
|
||||
logger.info(`No messages found in channel: ${channel.displayName}`)
|
||||
return { documents: [], hasMore: false }
|
||||
const documents: ExternalDocument[] = []
|
||||
|
||||
for (const channelInput of channelInputs) {
|
||||
const channel = await resolveChannel(accessToken, teamId, channelInput)
|
||||
if (!channel) {
|
||||
throw new Error(`Channel not found: ${channelInput}`)
|
||||
}
|
||||
|
||||
const { messages, lastActivityTs } = await fetchChannelMessages(
|
||||
accessToken,
|
||||
teamId,
|
||||
channel.id,
|
||||
maxMessages
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
// 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<string, unknown>
|
||||
): 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,14 +406,16 @@ 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<TeamsMessagesResponse>(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<TeamsMessagesResponse>(messagesPath, accessToken, VALIDATE_RETRY_OPTIONS)
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
} catch (error) {
|
||||
|
||||
@@ -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<string, unknown>
|
||||
): Promise<ExternalDocumentList> => {
|
||||
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<string, unknown>
|
||||
): 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<string, unknown>
|
||||
): Promise<ExternalDocumentList> {
|
||||
const body: Record<string, unknown> = {
|
||||
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
|
||||
|
||||
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),
|
||||
})
|
||||
while (databaseIndex < databaseIds.length) {
|
||||
const databaseId = databaseIds[databaseIndex]
|
||||
const body: Record<string, unknown> = { page_size: 100 }
|
||||
if (startCursor) body.start_cursor = startCursor
|
||||
|
||||
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}`)
|
||||
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),
|
||||
})
|
||||
|
||||
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<string, unknown>[]
|
||||
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
|
||||
}
|
||||
|
||||
databaseIndex++
|
||||
startCursor = undefined
|
||||
|
||||
if (databaseIndex < databaseIds.length) {
|
||||
nextCursor =
|
||||
databaseIds.length === 1 ? undefined : JSON.stringify({ databaseIndex, cursor: undefined })
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const results = (data.results || []) as Record<string, unknown>[]
|
||||
const pages = results.filter((r) => r.object === 'page' && !(r.archived as boolean))
|
||||
|
||||
const documents = pages.map(pageToStub)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>[]
|
||||
blocks?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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(`<!subteam^${n.usergroup_id}>`)
|
||||
}
|
||||
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,69 @@ export const slackConnector: ConnectorConfig = {
|
||||
_cursor?: string,
|
||||
syncContext?: Record<string, unknown>
|
||||
): Promise<ExternalDocumentList> => {
|
||||
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 })
|
||||
|
||||
const channel = await resolveChannel(accessToken, channelInput)
|
||||
if (!channel) {
|
||||
throw new Error(`Channel not found: ${channelInput}`)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
logger.info('Syncing Slack channels', { channels: channelInputs, maxMessages })
|
||||
|
||||
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 documents: ExternalDocument[] = []
|
||||
|
||||
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,
|
||||
},
|
||||
for (const channelInput of channelInputs) {
|
||||
const channel = await resolveChannel(accessToken, channelInput)
|
||||
if (!channel) {
|
||||
/**
|
||||
* 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 } =
|
||||
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 +659,11 @@ export const slackConnector: ConnectorConfig = {
|
||||
accessToken: string,
|
||||
sourceConfig: Record<string, unknown>
|
||||
): 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 +671,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 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 (/^[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 (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<string, string> = {
|
||||
@@ -573,14 +721,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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string>()
|
||||
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<string>()
|
||||
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 []
|
||||
}
|
||||
|
||||
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user