Skip to content

Commit 3a79289

Browse files
authored
improvement(confluence): expand scopes, persist canonical mode toggle (#4461)
* improvement(confluence): expand scopes, persist canonical mode toggle * improvement(confluence): memoize persisted canonical modes parse * fix(confluence): paginate space selector dropdown Confluence v2 spaces endpoint caps at limit=250 per page. The selector endpoint was making one request and silently dropping every space past the first page, which is why some spaces only worked when entered as a manual spaceKey. Now follows _links.next cursor up to 20 pages (5000 spaces). * fix(confluence): include archived spaces in selector dropdown Confluence v2 /spaces defaults to status=current and the status param is a single-value enum, so archived spaces never surface. They synced fine when entered manually as a spaceKey because the connector looks up spaces via ?keys=<key> which ignores status. Now fetches current and archived in parallel and tags archived ones in the dropdown label. * improvement(confluence): stream paginated space selector results Bake pagination support into the selector abstraction via an opt-in fetchPage definition so dropdowns populate progressively instead of blocking on a full page-walk. Confluence spaces now stream current then archived in a single cursor sequence.
1 parent cef351f commit 3a79289

11 files changed

Lines changed: 303 additions & 54 deletions

File tree

apps/sim/app/api/tools/confluence/selector-spaces/route.ts

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,33 @@ const logger = createLogger('ConfluenceSelectorSpacesAPI')
1919

2020
export const dynamic = 'force-dynamic'
2121

22+
const PAGE_LIMIT = 250
23+
24+
type SpaceStatus = 'current' | 'archived'
25+
26+
/**
27+
* Cursor format: `<status>:<innerCursor>`. Empty inner cursor means "first page
28+
* of that status". When current is exhausted we hand back `archived:` so the
29+
* client transparently flips to the archived stream — listing both surfaces
30+
* archived spaces in the dropdown, which would otherwise only be reachable by
31+
* typing the space key manually even though sync works against archived spaces.
32+
*/
33+
function parseCursor(raw: string | undefined): { status: SpaceStatus; inner?: string } {
34+
if (!raw) return { status: 'current' }
35+
const idx = raw.indexOf(':')
36+
if (idx === -1) return { status: 'current' }
37+
const status = raw.slice(0, idx) === 'archived' ? 'archived' : 'current'
38+
const inner = raw.slice(idx + 1)
39+
return { status, inner: inner || undefined }
40+
}
41+
2242
export const POST = withRouteHandler(async (request: NextRequest) => {
2343
const requestId = generateRequestId()
2444
try {
2545
const parsed = await parseRequest(confluenceSpacesSelectorContract, request, {})
2646
if (!parsed.success) return parsed.response
2747

28-
const { credential, workflowId, domain } = parsed.data.body
48+
const { credential, workflowId, domain, cursor } = parsed.data.body
2949

3050
if (!credential) {
3151
logger.error('Missing credential in request')
@@ -44,11 +64,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4464
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
4565
}
4666

47-
// Resolve once so we know whether this is an Atlassian SA credential before
48-
// doing any token / cloudId work. Atlassian SAs short-circuit the entire path:
49-
// the API token IS the access token, and cloudId lives in the encrypted secret —
50-
// so we skip refreshAccessTokenIfNeeded (avoids a redundant resolve+decrypt) and
51-
// skip getConfluenceCloudId (which 401s for scoped SA tokens).
5267
const resolved = await resolveOAuthAccountId(credential)
5368
const isAtlassianServiceAccount =
5469
resolved?.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && !!resolved.credentialId
@@ -83,37 +98,51 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8398
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
8499
}
85100

86-
const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces?limit=250`
101+
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces`
102+
const { status, inner } = parseCursor(cursor)
103+
104+
const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status })
105+
if (inner) params.set('cursor', inner)
106+
const url = `${baseUrl}?${params.toString()}`
87107

88108
const response = await fetch(url, {
89109
method: 'GET',
90-
headers: {
91-
Accept: 'application/json',
92-
Authorization: `Bearer ${accessToken}`,
93-
},
110+
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
94111
})
95112

96113
if (!response.ok) {
97114
const errorText = await response.text()
98-
logger.error('Confluence API error response:', {
99-
status: response.status,
100-
statusText: response.statusText,
101-
error: errorText,
102-
})
103-
return NextResponse.json(
104-
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
105-
{ status: response.status }
106-
)
115+
const message = parseAtlassianErrorMessage(response.status, response.statusText, errorText)
116+
logger.error('Confluence API error response', { error: message, status: response.status })
117+
return NextResponse.json({ error: message }, { status: 502 })
107118
}
108119

109120
const data = await response.json()
110121
const spaces = (data.results || []).map((space: { id: string; name: string; key: string }) => ({
111122
id: space.id,
112123
name: space.name,
113124
key: space.key,
125+
status,
114126
}))
115127

116-
return NextResponse.json({ spaces })
128+
let nextInner: string | undefined
129+
const nextLink = data._links?.next as string | undefined
130+
if (nextLink) {
131+
try {
132+
nextInner = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined
133+
} catch {
134+
nextInner = undefined
135+
}
136+
}
137+
138+
let nextCursor: string | undefined
139+
if (nextInner) {
140+
nextCursor = `${status}:${nextInner}`
141+
} else if (status === 'current') {
142+
nextCursor = 'archived:'
143+
}
144+
145+
return NextResponse.json({ spaces, nextCursor })
117146
} catch (error) {
118147
logger.error('Error listing Confluence spaces:', error)
119148
return NextResponse.json(

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,13 @@ export function AddConnectorModal({
157157
for (const [key, value] of Object.entries(resolveSourceConfig())) {
158158
if (value) resolvedConfig[key] = value
159159
}
160-
const finalSourceConfig =
161-
disabledTagIds.size > 0
162-
? { ...resolvedConfig, disabledTagIds: Array.from(disabledTagIds) }
163-
: resolvedConfig
160+
if (disabledTagIds.size > 0) {
161+
resolvedConfig.disabledTagIds = Array.from(disabledTagIds)
162+
}
163+
if (Object.keys(canonicalModes).length > 0) {
164+
resolvedConfig._canonicalModes = canonicalModes
165+
}
166+
const finalSourceConfig = resolvedConfig
164167

165168
createConnector(
166169
{

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,33 @@ import type { SelectorKey } from '@/hooks/selectors/types'
4343

4444
const logger = createLogger('EditConnectorModal')
4545

46-
/** Keys injected by the sync engine — not user-editable */
47-
const INTERNAL_CONFIG_KEYS = new Set(['tagSlotMapping', 'disabledTagIds'])
46+
/** Keys injected by the sync engine or modal state — not user-editable */
47+
const INTERNAL_CONFIG_KEYS = new Set(['tagSlotMapping', 'disabledTagIds', '_canonicalModes'])
48+
49+
const CANONICAL_MODES_KEY = '_canonicalModes'
50+
51+
function readPersistedCanonicalModes(
52+
sourceConfig: Record<string, unknown>
53+
): Record<string, 'basic' | 'advanced'> {
54+
const raw = sourceConfig[CANONICAL_MODES_KEY]
55+
if (!raw || typeof raw !== 'object') return {}
56+
const result: Record<string, 'basic' | 'advanced'> = {}
57+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
58+
if (value === 'basic' || value === 'advanced') result[key] = value
59+
}
60+
return result
61+
}
62+
63+
function didCanonicalModesChange(
64+
current: Record<string, 'basic' | 'advanced'>,
65+
persisted: Record<string, 'basic' | 'advanced'>
66+
): boolean {
67+
const keys = new Set([...Object.keys(persisted), ...Object.keys(current)])
68+
for (const key of keys) {
69+
if ((current[key] ?? 'basic') !== (persisted[key] ?? 'basic')) return true
70+
}
71+
return false
72+
}
4873

4974
interface EditConnectorModalProps {
5075
open: boolean
@@ -87,6 +112,10 @@ export function EditConnectorModal({
87112
return config
88113
})
89114

115+
const [initialCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>(() =>
116+
readPersistedCanonicalModes(connector.sourceConfig)
117+
)
118+
90119
const {
91120
sourceConfig,
92121
canonicalModes,
@@ -95,22 +124,39 @@ export function EditConnectorModal({
95124
handleFieldChange,
96125
toggleCanonicalMode,
97126
resolveSourceConfig,
98-
} = useConnectorConfigFields({ connectorConfig, initialSourceConfig })
127+
} = useConnectorConfigFields({
128+
connectorConfig,
129+
initialSourceConfig,
130+
initialCanonicalModes,
131+
})
99132

100133
const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector()
101134

102135
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
103136
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
104137
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
105138

139+
const persistedCanonicalModes = useMemo(
140+
() => readPersistedCanonicalModes(connector.sourceConfig),
141+
[connector.sourceConfig]
142+
)
143+
106144
const hasChanges = useMemo(() => {
107145
if (syncInterval !== connector.syncIntervalMinutes) return true
146+
if (didCanonicalModesChange(canonicalModes, persistedCanonicalModes)) return true
108147
const resolved = resolveSourceConfig()
109148
for (const [key, value] of Object.entries(resolved)) {
110149
if (String(connector.sourceConfig[key] ?? '') !== value) return true
111150
}
112151
return false
113-
}, [resolveSourceConfig, syncInterval, connector.syncIntervalMinutes, connector.sourceConfig])
152+
}, [
153+
resolveSourceConfig,
154+
syncInterval,
155+
connector.syncIntervalMinutes,
156+
connector.sourceConfig,
157+
canonicalModes,
158+
persistedCanonicalModes,
159+
])
114160

115161
const handleSave = () => {
116162
setError(null)
@@ -126,8 +172,17 @@ export function EditConnectorModal({
126172
for (const [key, value] of Object.entries(resolved)) {
127173
if (String(connector.sourceConfig[key] ?? '') !== value) changedEntries[key] = value
128174
}
129-
if (Object.keys(changedEntries).length > 0) {
130-
updates.sourceConfig = { ...connector.sourceConfig, ...changedEntries }
175+
176+
const modesChanged = didCanonicalModesChange(canonicalModes, persistedCanonicalModes)
177+
178+
if (Object.keys(changedEntries).length > 0 || modesChanged) {
179+
const next: Record<string, unknown> = { ...connector.sourceConfig, ...changedEntries }
180+
if (Object.keys(canonicalModes).length > 0) {
181+
next[CANONICAL_MODES_KEY] = canonicalModes
182+
} else {
183+
delete next[CANONICAL_MODES_KEY]
184+
}
185+
updates.sourceConfig = next
131186
}
132187

133188
if (Object.keys(updates).length === 0) {

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
77
export interface UseConnectorConfigFieldsOptions {
88
connectorConfig: ConnectorConfig | null
99
initialSourceConfig?: Record<string, string>
10+
initialCanonicalModes?: Record<string, 'basic' | 'advanced'>
1011
}
1112

1213
export interface UseConnectorConfigFieldsResult {
@@ -34,11 +35,14 @@ export interface UseConnectorConfigFieldsResult {
3435
export function useConnectorConfigFields({
3536
connectorConfig,
3637
initialSourceConfig,
38+
initialCanonicalModes,
3739
}: UseConnectorConfigFieldsOptions): UseConnectorConfigFieldsResult {
3840
const [sourceConfig, setSourceConfig] = useState<Record<string, string>>(
3941
() => initialSourceConfig ?? {}
4042
)
41-
const [canonicalModes, setCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>({})
43+
const [canonicalModes, setCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>(
44+
() => initialCanonicalModes ?? {}
45+
)
4246

4347
const canonicalGroups = useMemo(() => {
4448
const groups = new Map<string, ConnectorConfigField[]>()

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export function SelectorCombobox({
5353
const {
5454
data: options = [],
5555
isLoading,
56+
hasMore,
5657
error,
5758
} = useSelectorOptions(selectorKey, {
5859
context: selectorContext,
@@ -67,6 +68,7 @@ export function SelectorCombobox({
6768
Boolean(activeValue) &&
6869
Boolean(missingOptionLabel) &&
6970
!isLoading &&
71+
!hasMore &&
7072
!optionMap.get(activeValue!)
7173
const selectedLabel = activeValue
7274
? hasMissingOption

apps/sim/connectors/confluence/confluence.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ConfluenceIcon } from '@/components/icons'
44
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
55
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
66
import { htmlToPlainText, joinTagArray, parseTagDate } from '@/connectors/utils'
7-
import { getConfluenceCloudId } from '@/tools/confluence/utils'
7+
import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/confluence/utils'
88

99
const logger = createLogger('ConfluenceConnector')
1010

@@ -141,7 +141,15 @@ export const confluenceConnector: ConnectorConfig = {
141141
auth: {
142142
mode: 'oauth',
143143
provider: 'confluence',
144-
requiredScopes: ['read:confluence-content.all', 'read:page:confluence', 'offline_access'],
144+
requiredScopes: [
145+
'read:confluence-content.all',
146+
'read:page:confluence',
147+
'read:blogpost:confluence',
148+
'read:space:confluence',
149+
'read:label:confluence',
150+
'search:confluence',
151+
'offline_access',
152+
],
145153
},
146154

147155
configFields: [
@@ -205,7 +213,7 @@ export const confluenceConnector: ConnectorConfig = {
205213
cursor?: string,
206214
syncContext?: Record<string, unknown>
207215
): Promise<ExternalDocumentList> => {
208-
const domain = sourceConfig.domain as string
216+
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
209217
const spaceKey = sourceConfig.spaceKey as string
210218
const contentType = (sourceConfig.contentType as string) || 'page'
211219
const labelFilter = (sourceConfig.labelFilter as string) || ''
@@ -269,7 +277,7 @@ export const confluenceConnector: ConnectorConfig = {
269277
externalId: string,
270278
syncContext?: Record<string, unknown>
271279
): Promise<ExternalDocument | null> => {
272-
const domain = sourceConfig.domain as string
280+
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
273281
let cloudId = syncContext?.cloudId as string | undefined
274282
if (!cloudId) {
275283
cloudId = await getConfluenceCloudId(domain, accessToken)

0 commit comments

Comments
 (0)