From b8c18765438e6baa3dbf42ac9d6bec7e921ec921 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 17 Jun 2026 12:32:15 -0700 Subject: [PATCH 1/2] feat(connectors): use resource selectors for KB connector config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw ID text inputs with selector pickers (canonical selector + manual-input pairs) across Google Drive/Docs/Forms/Sheets, Notion, Monday, and Webflow KB connectors, so users pick folders/spreadsheets/pages/boards/ collections instead of pasting IDs — matching the workflow blocks. - Add multi-select where the sync handler supports it (Drive/Docs/Forms folders, Monday boards, Webflow collections) via parseMultiValue - Add shared escapeDriveQueryValue/buildDriveParentsClause helpers for safe multi-folder Drive queries - Add ConnectorConfigField.mimeType, plumbed into the selector context - Fix Webflow listingCapped not set on maxItems truncation (deletion- reconciliation data-loss safety) Fully backward compatible: legacy single-string IDs and CSV both normalize via parseMultiValue; resolved canonical keys are unchanged. --- .../connector-selector-field.tsx | 3 +- .../sim/connectors/google-docs/google-docs.ts | 65 ++++++++------- apps/sim/connectors/google-docs/meta.ts | 19 ++++- .../connectors/google-drive/google-drive.ts | 62 ++++++++------- apps/sim/connectors/google-drive/meta.ts | 19 ++++- .../connectors/google-forms/google-forms.ts | 79 +++++++++++-------- apps/sim/connectors/google-forms/meta.ts | 22 +++++- apps/sim/connectors/google-sheets/meta.ts | 13 +++ apps/sim/connectors/monday/meta.ts | 16 ++++ apps/sim/connectors/notion/meta.ts | 12 +++ apps/sim/connectors/types.ts | 2 + apps/sim/connectors/utils.ts | 20 +++++ apps/sim/connectors/webflow/meta.ts | 19 ++++- apps/sim/connectors/webflow/webflow.ts | 20 +++-- 14 files changed, 265 insertions(+), 106 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index 242854ee878..ac16b24c93a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -38,6 +38,7 @@ export function ConnectorSelectorField({ const context = useMemo(() => { const ctx: SelectorContext = {} if (credentialId) ctx.oauthCredential = credentialId + if (field.mimeType) ctx.mimeType = field.mimeType for (const depFieldId of getDependsOnFields(field.dependsOn)) { const depField = configFields.find((f) => f.id === depFieldId) @@ -49,7 +50,7 @@ export function ConnectorSelectorField({ } return ctx - }, [credentialId, field.dependsOn, sourceConfig, configFields, canonicalModes]) + }, [credentialId, field.mimeType, field.dependsOn, sourceConfig, configFields, canonicalModes]) const depsResolved = useMemo(() => { if (!field.dependsOn) return true diff --git a/apps/sim/connectors/google-docs/google-docs.ts b/apps/sim/connectors/google-docs/google-docs.ts index 5c62fcc61dd..f08b405eb52 100644 --- a/apps/sim/connectors/google-docs/google-docs.ts +++ b/apps/sim/connectors/google-docs/google-docs.ts @@ -3,7 +3,12 @@ import { toError } from '@sim/utils/errors' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { googleDocsConnectorMeta } from '@/connectors/google-docs/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { joinTagArray, parseTagDate } from '@/connectors/utils' +import { + buildDriveParentsClause, + joinTagArray, + parseMultiValue, + parseTagDate, +} from '@/connectors/utils' const logger = createLogger('GoogleDocsConnector') @@ -152,10 +157,8 @@ function fileToStub(file: DriveFile): ExternalDocument { function buildQuery(sourceConfig: Record): string { const parts: string[] = ['trashed = false', "mimeType = 'application/vnd.google-apps.document'"] - const folderId = sourceConfig.folderId as string | undefined - if (folderId?.trim()) { - parts.push(`'${folderId.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' in parents`) - } + const parentsClause = buildDriveParentsClause(parseMultiValue(sourceConfig.folderId)) + if (parentsClause) parts.push(parentsClause) return parts.join(' and ') } @@ -298,7 +301,7 @@ export const googleDocsConnector: ConnectorConfig = { accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { - const folderId = sourceConfig.folderId as string | undefined + const folderIds = parseMultiValue(sourceConfig.folderId) const maxDocs = sourceConfig.maxDocs as string | undefined if (maxDocs && (Number.isNaN(Number(maxDocs)) || Number(maxDocs) <= 0)) { @@ -306,30 +309,38 @@ export const googleDocsConnector: ConnectorConfig = { } try { - if (folderId?.trim()) { - const url = `https://www.googleapis.com/drive/v3/files/${folderId.trim()}?fields=id,name,mimeType&supportsAllDrives=true` - const response = await fetchWithRetry( - url, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', + if (folderIds.length > 0) { + for (const folderId of folderIds) { + const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(folderId)}?fields=id,name,mimeType&supportsAllDrives=true` + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, }, - }, - VALIDATE_RETRY_OPTIONS - ) - - if (!response.ok) { - if (response.status === 404) { - return { valid: false, error: 'Folder not found. Check the folder ID and permissions.' } + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (response.status === 404) { + return { + valid: false, + error: `Folder "${folderId}" not found. Check the folder ID and permissions.`, + } + } + return { + valid: false, + error: `Failed to access folder "${folderId}": ${response.status}`, + } } - return { valid: false, error: `Failed to access folder: ${response.status}` } - } - const folder = await response.json() - if (folder.mimeType !== 'application/vnd.google-apps.folder') { - return { valid: false, error: 'The provided ID is not a folder' } + const folder = await response.json() + if (folder.mimeType !== 'application/vnd.google-apps.folder') { + return { valid: false, error: `"${folderId}" is not a folder` } + } } } else { const url = diff --git a/apps/sim/connectors/google-docs/meta.ts b/apps/sim/connectors/google-docs/meta.ts index f1cc2a8e6a0..eaca1bd826b 100644 --- a/apps/sim/connectors/google-docs/meta.ts +++ b/apps/sim/connectors/google-docs/meta.ts @@ -15,11 +15,26 @@ export const googleDocsConnectorMeta: ConnectorMeta = { }, configFields: [ + { + id: 'folderSelector', + title: 'Folders', + type: 'selector', + selectorKey: 'google.drive', + mimeType: 'application/vnd.google-apps.folder', + canonicalParamId: 'folderId', + mode: 'basic', + multi: true, + placeholder: 'Select one or more folders (optional)', + required: false, + }, { id: 'folderId', - title: 'Folder ID', + title: 'Folder IDs', type: 'short-input', - placeholder: 'e.g. 1aBcDeFgHiJkLmNoPqRsTuVwXyZ (optional)', + canonicalParamId: 'folderId', + mode: 'advanced', + multi: true, + placeholder: 'e.g. 1aBcDeFg…, 2cDeFgHi… (comma-separated for multiple)', required: false, }, { diff --git a/apps/sim/connectors/google-drive/google-drive.ts b/apps/sim/connectors/google-drive/google-drive.ts index 14d98bc413d..e7c2def3b5c 100644 --- a/apps/sim/connectors/google-drive/google-drive.ts +++ b/apps/sim/connectors/google-drive/google-drive.ts @@ -4,11 +4,13 @@ import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/document import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { + buildDriveParentsClause, CONNECTOR_MAX_FILE_BYTES, ConnectorFileTooLargeError, htmlToPlainText, joinTagArray, markSkipped, + parseMultiValue, parseTagDate, readBodyWithLimit, sizeLimitSkipReason, @@ -137,10 +139,8 @@ interface DriveFile { function buildQuery(sourceConfig: Record): string { const parts: string[] = ['trashed = false'] - const folderId = sourceConfig.folderId as string | undefined - if (folderId?.trim()) { - parts.push(`'${folderId.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' in parents`) - } + const parentsClause = buildDriveParentsClause(parseMultiValue(sourceConfig.folderId)) + if (parentsClause) parts.push(parentsClause) const fileType = (sourceConfig.fileType as string) || 'all' switch (fileType) { @@ -324,7 +324,7 @@ export const googleDriveConnector: ConnectorConfig = { accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { - const folderId = sourceConfig.folderId as string | undefined + const folderIds = parseMultiValue(sourceConfig.folderId) const maxFiles = sourceConfig.maxFiles as string | undefined if (maxFiles && (Number.isNaN(Number(maxFiles)) || Number(maxFiles) <= 0)) { @@ -333,31 +333,39 @@ export const googleDriveConnector: ConnectorConfig = { // Verify access to Drive API try { - if (folderId?.trim()) { - // Verify the folder exists and is accessible - const url = `https://www.googleapis.com/drive/v3/files/${folderId.trim()}?fields=id,name,mimeType&supportsAllDrives=true` - const response = await fetchWithRetry( - url, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', + if (folderIds.length > 0) { + // Verify each folder exists, is accessible, and is actually a folder + for (const folderId of folderIds) { + const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(folderId)}?fields=id,name,mimeType&supportsAllDrives=true` + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, }, - }, - VALIDATE_RETRY_OPTIONS - ) - - if (!response.ok) { - if (response.status === 404) { - return { valid: false, error: 'Folder not found. Check the folder ID and permissions.' } + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (response.status === 404) { + return { + valid: false, + error: `Folder "${folderId}" not found. Check the folder ID and permissions.`, + } + } + return { + valid: false, + error: `Failed to access folder "${folderId}": ${response.status}`, + } } - return { valid: false, error: `Failed to access folder: ${response.status}` } - } - const folder = await response.json() - if (folder.mimeType !== 'application/vnd.google-apps.folder') { - return { valid: false, error: 'The provided ID is not a folder' } + const folder = await response.json() + if (folder.mimeType !== 'application/vnd.google-apps.folder') { + return { valid: false, error: `"${folderId}" is not a folder` } + } } } else { // Verify basic Drive access by listing one file diff --git a/apps/sim/connectors/google-drive/meta.ts b/apps/sim/connectors/google-drive/meta.ts index 6af635129b4..eec6fd8fa2e 100644 --- a/apps/sim/connectors/google-drive/meta.ts +++ b/apps/sim/connectors/google-drive/meta.ts @@ -15,11 +15,26 @@ export const googleDriveConnectorMeta: ConnectorMeta = { }, configFields: [ + { + id: 'folderSelector', + title: 'Folders', + type: 'selector', + selectorKey: 'google.drive', + mimeType: 'application/vnd.google-apps.folder', + canonicalParamId: 'folderId', + mode: 'basic', + multi: true, + placeholder: 'Select one or more folders (optional)', + required: false, + }, { id: 'folderId', - title: 'Folder ID', + title: 'Folder IDs', type: 'short-input', - placeholder: 'e.g. 1aBcDeFgHiJkLmNoPqRsTuVwXyZ (optional)', + canonicalParamId: 'folderId', + mode: 'advanced', + multi: true, + placeholder: 'e.g. 1aBcDeFg…, 2cDeFgHi… (comma-separated for multiple)', required: false, }, { diff --git a/apps/sim/connectors/google-forms/google-forms.ts b/apps/sim/connectors/google-forms/google-forms.ts index 992c698dd80..1849fd0b380 100644 --- a/apps/sim/connectors/google-forms/google-forms.ts +++ b/apps/sim/connectors/google-forms/google-forms.ts @@ -3,7 +3,12 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { googleFormsConnectorMeta, MAX_RESPONSES_PER_FORM } from '@/connectors/google-forms/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { joinTagArray, parseTagDate } from '@/connectors/utils' +import { + buildDriveParentsClause, + joinTagArray, + parseMultiValue, + parseTagDate, +} from '@/connectors/utils' const logger = createLogger('GoogleFormsConnector') @@ -448,16 +453,14 @@ function renderFormDocument(form: FormStructure, responses: FormResponse[]): str } /** - * Builds the Drive `q` query that selects form files, optionally scoped to a - * folder. Single quotes and backslashes in the folder ID are escaped to prevent - * query injection. + * Builds the Drive `q` query that selects form files, optionally scoped to one + * or more folders. Single quotes and backslashes in folder IDs are escaped to + * prevent query injection. */ -function buildDriveQuery(folderId?: string): string { +function buildDriveQuery(folderIds: string[]): string { const parts = ['trashed = false', `mimeType = '${FORM_MIME_TYPE}'`] - if (folderId?.trim()) { - const escaped = folderId.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'") - parts.push(`'${escaped}' in parents`) - } + const parentsClause = buildDriveParentsClause(folderIds) + if (parentsClause) parts.push(parentsClause) return parts.join(' and ') } @@ -479,9 +482,9 @@ export const googleFormsConnector: ConnectorConfig = { return { documents: [], hasMore: false } } - const folderId = sourceConfig.folderId as string | undefined + const folderIds = parseMultiValue(sourceConfig.folderId) const queryParams = new URLSearchParams({ - q: buildDriveQuery(folderId), + q: buildDriveQuery(folderIds), pageSize: String(DRIVE_PAGE_SIZE), orderBy: 'modifiedTime desc', fields: 'nextPageToken,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners)', @@ -493,7 +496,7 @@ export const googleFormsConnector: ConnectorConfig = { const url = `${DRIVE_API_BASE}/files?${queryParams.toString()}` logger.info('Listing Google Forms', { - folderId: folderId?.trim() || 'all', + folderId: folderIds.length > 0 ? folderIds.join(',') : 'all', contentScope, cursor: cursor ?? 'initial', }) @@ -667,7 +670,7 @@ export const googleFormsConnector: ConnectorConfig = { accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { - const folderId = sourceConfig.folderId as string | undefined + const folderIds = parseMultiValue(sourceConfig.folderId) const maxForms = sourceConfig.maxForms as string | undefined const maxResponsesPerForm = sourceConfig.maxResponsesPerForm as string | undefined @@ -683,30 +686,38 @@ export const googleFormsConnector: ConnectorConfig = { } try { - if (folderId?.trim()) { - const url = `${DRIVE_API_BASE}/files/${encodeURIComponent(folderId.trim())}?fields=id,name,mimeType&supportsAllDrives=true` - const response = await fetchWithRetry( - url, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', + if (folderIds.length > 0) { + for (const folderId of folderIds) { + const url = `${DRIVE_API_BASE}/files/${encodeURIComponent(folderId)}?fields=id,name,mimeType&supportsAllDrives=true` + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, }, - }, - VALIDATE_RETRY_OPTIONS - ) - - if (!response.ok) { - if (response.status === 404) { - return { valid: false, error: 'Folder not found. Check the folder ID and permissions.' } + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (response.status === 404) { + return { + valid: false, + error: `Folder "${folderId}" not found. Check the folder ID and permissions.`, + } + } + return { + valid: false, + error: `Failed to access folder "${folderId}": ${response.status}`, + } } - return { valid: false, error: `Failed to access folder: ${response.status}` } - } - const folder = await response.json() - if (folder.mimeType !== FOLDER_MIME_TYPE) { - return { valid: false, error: 'The provided ID is not a folder' } + const folder = await response.json() + if (folder.mimeType !== FOLDER_MIME_TYPE) { + return { valid: false, error: `"${folderId}" is not a folder` } + } } } else { const url = `${DRIVE_API_BASE}/files?pageSize=1&q=${encodeURIComponent(`mimeType = '${FORM_MIME_TYPE}'`)}&fields=files(id)&supportsAllDrives=true&includeItemsFromAllDrives=true` diff --git a/apps/sim/connectors/google-forms/meta.ts b/apps/sim/connectors/google-forms/meta.ts index 8ced1c05d1f..3ea4b31fbdb 100644 --- a/apps/sim/connectors/google-forms/meta.ts +++ b/apps/sim/connectors/google-forms/meta.ts @@ -25,13 +25,29 @@ export const googleFormsConnectorMeta: ConnectorMeta = { }, configFields: [ + { + id: 'folderSelector', + title: 'Folders', + type: 'selector', + selectorKey: 'google.drive', + mimeType: 'application/vnd.google-apps.folder', + canonicalParamId: 'folderId', + mode: 'basic', + multi: true, + placeholder: 'Select one or more folders (optional)', + required: false, + description: 'Only sync forms inside these Drive folders. Leave blank to sync all forms.', + }, { id: 'folderId', - title: 'Folder ID', + title: 'Folder IDs', type: 'short-input', - placeholder: 'e.g. 1aBcDeFgHiJkLmNoPqRsTuVwXyZ (optional)', + canonicalParamId: 'folderId', + mode: 'advanced', + multi: true, + placeholder: 'e.g. 1aBcDeFg…, 2cDeFgHi… (comma-separated for multiple)', required: false, - description: 'Only sync forms inside this Drive folder. Leave blank to sync all forms.', + description: 'Only sync forms inside these Drive folders. Leave blank to sync all forms.', }, { id: 'contentScope', diff --git a/apps/sim/connectors/google-sheets/meta.ts b/apps/sim/connectors/google-sheets/meta.ts index 1e7fd7b6daa..7a20991d719 100644 --- a/apps/sim/connectors/google-sheets/meta.ts +++ b/apps/sim/connectors/google-sheets/meta.ts @@ -15,10 +15,23 @@ export const googleSheetsConnectorMeta: ConnectorMeta = { }, configFields: [ + { + id: 'spreadsheetSelector', + title: 'Spreadsheet', + type: 'selector', + selectorKey: 'google.drive', + mimeType: 'application/vnd.google-apps.spreadsheet', + canonicalParamId: 'spreadsheetId', + mode: 'basic', + placeholder: 'Select a spreadsheet', + required: true, + }, { id: 'spreadsheetId', title: 'Spreadsheet ID', type: 'short-input', + canonicalParamId: 'spreadsheetId', + mode: 'advanced', placeholder: 'e.g. 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms', required: true, description: 'The ID from the spreadsheet URL: docs.google.com/spreadsheets/d/{ID}/edit', diff --git a/apps/sim/connectors/monday/meta.ts b/apps/sim/connectors/monday/meta.ts index 27b62875218..ab9ffc882e1 100644 --- a/apps/sim/connectors/monday/meta.ts +++ b/apps/sim/connectors/monday/meta.ts @@ -15,10 +15,26 @@ export const mondayConnectorMeta: ConnectorMeta = { }, configFields: [ + { + id: 'boardSelector', + title: 'Boards', + type: 'selector', + selectorKey: 'monday.boards', + canonicalParamId: 'boardIds', + mode: 'basic', + multi: true, + required: false, + placeholder: 'Select boards (empty = all active boards)', + description: + 'Boards to sync. Leave empty to sync items from every active board you can access.', + }, { id: 'boardIds', title: 'Board IDs', type: 'short-input', + canonicalParamId: 'boardIds', + mode: 'advanced', + multi: true, required: false, placeholder: 'e.g. 1234567890, 9876543210 (empty = all active boards)', description: diff --git a/apps/sim/connectors/notion/meta.ts b/apps/sim/connectors/notion/meta.ts index f0fdd7bd6f9..e1220cdb94d 100644 --- a/apps/sim/connectors/notion/meta.ts +++ b/apps/sim/connectors/notion/meta.ts @@ -43,10 +43,22 @@ export const notionConnectorMeta: ConnectorMeta = { required: false, placeholder: 'e.g. 8a3b5f6e-..., 9c4d6e7f-... (comma-separated for multiple)', }, + { + id: 'rootPageSelector', + title: 'Page', + type: 'selector', + selectorKey: 'notion.pages', + canonicalParamId: 'rootPageId', + mode: 'basic', + placeholder: 'Select a page', + required: false, + }, { id: 'rootPageId', title: 'Page ID', type: 'short-input', + canonicalParamId: 'rootPageId', + mode: 'advanced', required: false, placeholder: 'e.g. 8a3b5f6e-1234-5678-abcd-ef0123456789', }, diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index c012a7ae56f..2782fc588fa 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -74,6 +74,8 @@ export interface ConnectorConfigField { /** Selector key from the selector registry (used when type is 'selector') */ selectorKey?: SelectorKey + /** MIME type filter passed to the selector context (e.g. limit a Google Drive picker to folders) */ + mimeType?: string /** Field IDs this field depends on — clears when deps change */ dependsOn?: string[] | { all?: string[]; any?: string[] } diff --git a/apps/sim/connectors/utils.ts b/apps/sim/connectors/utils.ts index 242edbae721..49d9c306696 100644 --- a/apps/sim/connectors/utils.ts +++ b/apps/sim/connectors/utils.ts @@ -95,6 +95,26 @@ export function parseMultiValue(value: unknown): string[] { return [] } +/** + * Escapes a value for safe interpolation into a Google Drive `q` query string, + * neutralizing backslashes and single quotes to prevent query injection. + */ +export function escapeDriveQueryValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") +} + +/** + * Builds a Drive `q` clause matching files parented by any of the given folder + * IDs — e.g. `('A' in parents or 'B' in parents)`. Returns null when no folder + * IDs are supplied so callers can omit the clause entirely. A single ID is + * emitted without wrapping parentheses to keep the query minimal. + */ +export function buildDriveParentsClause(folderIds: string[]): string | null { + if (folderIds.length === 0) return null + const clause = folderIds.map((id) => `'${escapeDriveQueryValue(id)}' in parents`).join(' or ') + return folderIds.length > 1 ? `(${clause})` : clause +} + /** * Reads a response body into a Buffer while enforcing a hard byte cap. The * declared `content-length` header cannot be trusted as the sole guard — diff --git a/apps/sim/connectors/webflow/meta.ts b/apps/sim/connectors/webflow/meta.ts index b3b6b37e349..81de15a16a6 100644 --- a/apps/sim/connectors/webflow/meta.ts +++ b/apps/sim/connectors/webflow/meta.ts @@ -31,11 +31,26 @@ export const webflowConnectorMeta: ConnectorMeta = { placeholder: 'Your Webflow site ID', required: true, }, + { + id: 'collectionSelector', + title: 'Collections', + type: 'selector', + selectorKey: 'webflow.collections', + canonicalParamId: 'collectionId', + mode: 'basic', + multi: true, + dependsOn: ['siteSelector'], + placeholder: 'Select collections (default: all collections)', + required: false, + }, { id: 'collectionId', - title: 'Collection ID', + title: 'Collection IDs', type: 'short-input', - placeholder: 'Specific collection ID (default: all collections)', + canonicalParamId: 'collectionId', + mode: 'advanced', + multi: true, + placeholder: 'Specific collection IDs, comma-separated (default: all collections)', required: false, }, { diff --git a/apps/sim/connectors/webflow/webflow.ts b/apps/sim/connectors/webflow/webflow.ts index e6f208a9715..77d46360d7a 100644 --- a/apps/sim/connectors/webflow/webflow.ts +++ b/apps/sim/connectors/webflow/webflow.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { htmlToPlainText, parseTagDate } from '@/connectors/utils' +import { htmlToPlainText, parseMultiValue, parseTagDate } from '@/connectors/utils' import { webflowConnectorMeta } from '@/connectors/webflow/meta' const logger = createLogger('WebflowConnector') @@ -89,7 +89,7 @@ export const webflowConnector: ConnectorConfig = { syncContext?: Record ): Promise => { const siteId = sourceConfig.siteId as string - const collectionId = sourceConfig.collectionId as string | undefined + const collectionIds = parseMultiValue(sourceConfig.collectionId) const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : 0 let cursorState: CursorState @@ -97,7 +97,7 @@ export const webflowConnector: ConnectorConfig = { if (cursor) { cursorState = JSON.parse(cursor) as CursorState } else { - const collections = await fetchCollectionIds(accessToken, siteId, collectionId) + const collections = await fetchCollectionIds(accessToken, siteId, collectionIds) cursorState = { collectionIndex: 0, offset: 0, collections } } @@ -172,6 +172,10 @@ export const webflowConnector: ConnectorConfig = { const hasMoreCollections = cursorState.collectionIndex < cursorState.collections.length - 1 const hitMaxItems = maxItems > 0 && totalDocsFetched + documents.length >= maxItems + if (hitMaxItems && (hasMoreInCollection || hasMoreCollections) && syncContext) { + syncContext.listingCapped = true + } + let nextCursor: string | undefined if (hitMaxItems) { nextCursor = undefined @@ -236,7 +240,7 @@ export const webflowConnector: ConnectorConfig = { sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const siteId = sourceConfig.siteId as string - const collectionId = sourceConfig.collectionId as string | undefined + const collectionIds = parseMultiValue(sourceConfig.collectionId) const maxItems = sourceConfig.maxItems as string | undefined if (!siteId) { @@ -272,7 +276,7 @@ export const webflowConnector: ConnectorConfig = { return { valid: false, error: `Webflow API error: ${siteResponse.status} - ${errorText}` } } - if (collectionId) { + for (const collectionId of collectionIds) { const collectionUrl = `${WEBFLOW_API}/collections/${collectionId}` const collectionResponse = await fetchWithRetry( collectionUrl, @@ -357,10 +361,10 @@ function itemToDocument( async function fetchCollectionIds( accessToken: string, siteId: string, - collectionId?: string + collectionIds: string[] ): Promise { - if (collectionId) { - return [collectionId] + if (collectionIds.length > 0) { + return collectionIds } const url = `${WEBFLOW_API}/sites/${siteId}/collections` From 345bc5a8e4ca780270fe87e97f96dec794d34cde Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 17 Jun 2026 12:40:41 -0700 Subject: [PATCH 2/2] fix(webflow): set listingCapped on within-page maxItems truncation When a collection's items fit in a single API page but maxItems cuts the list within that page, neither hasMoreInCollection nor hasMoreCollections is true, so listingCapped was not set and the sync engine could hard-delete still-existing documents. Add the within-page drop signal to the guard. --- apps/sim/connectors/webflow/webflow.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/sim/connectors/webflow/webflow.ts b/apps/sim/connectors/webflow/webflow.ts index 77d46360d7a..c2172f0afa7 100644 --- a/apps/sim/connectors/webflow/webflow.ts +++ b/apps/sim/connectors/webflow/webflow.ts @@ -152,12 +152,13 @@ export const webflowConnector: ConnectorConfig = { } const items = data.items || [] - let documents: ExternalDocument[] = items.map((item) => + const pageDocuments: ExternalDocument[] = items.map((item) => itemToDocument(item, currentCollectionId, collectionName) ) + let documents = pageDocuments if (maxItems > 0) { - const remaining = maxItems - totalDocsFetched + const remaining = Math.max(0, maxItems - totalDocsFetched) if (documents.length > remaining) { documents = documents.slice(0, remaining) } @@ -171,8 +172,20 @@ export const webflowConnector: ConnectorConfig = { const hasMoreInCollection = cursorState.offset + pagination.limit < pagination.total const hasMoreCollections = cursorState.collectionIndex < cursorState.collections.length - 1 const hitMaxItems = maxItems > 0 && totalDocsFetched + documents.length >= maxItems - - if (hitMaxItems && (hasMoreInCollection || hasMoreCollections) && syncContext) { + /** + * When the cap stops the sync, flag the listing as capped so the sync engine + * skips deletion reconciliation — otherwise still-existing documents that + * were never listed get hard-deleted. "More" means any of: items dropped + * from this page (`pageDocuments.length > documents.length`), more pages in + * this collection, or more collections still to visit. The within-page drop + * is the only signal when a collection fits in a single API response. + */ + const droppedWithinPage = documents.length < pageDocuments.length + if ( + syncContext && + hitMaxItems && + (droppedWithinPage || hasMoreInCollection || hasMoreCollections) + ) { syncContext.listingCapped = true }