d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
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, parseMultiValue, parseTagDate } from '@/connectors/utils'
|
|
import { webflowConnectorMeta } from '@/connectors/webflow/meta'
|
|
|
|
const logger = createLogger('WebflowConnector')
|
|
|
|
const WEBFLOW_API = 'https://api.webflow.com/v2'
|
|
const PAGE_SIZE = 100
|
|
|
|
interface WebflowCollection {
|
|
id: string
|
|
displayName: string
|
|
slug: string
|
|
}
|
|
|
|
interface WebflowItem {
|
|
id: string
|
|
fieldData: Record<string, unknown>
|
|
lastPublished?: string
|
|
lastUpdated?: string
|
|
createdOn?: string
|
|
}
|
|
|
|
interface WebflowPagination {
|
|
total: number
|
|
offset: number
|
|
limit: number
|
|
}
|
|
|
|
interface CursorState {
|
|
collectionIndex: number
|
|
offset: number
|
|
collections: string[]
|
|
}
|
|
|
|
/**
|
|
* Formats a CMS item's field data into structured plain text.
|
|
*/
|
|
function itemToPlainText(item: WebflowItem, collectionName: string): string {
|
|
const lines: string[] = []
|
|
const fieldData = item.fieldData || {}
|
|
|
|
const title = (fieldData.name as string) || (fieldData.title as string) || 'Untitled'
|
|
lines.push(`# ${title}`)
|
|
lines.push(`Collection: ${collectionName}`)
|
|
|
|
for (const [key, value] of Object.entries(fieldData)) {
|
|
if (value == null) continue
|
|
if (key === 'name' || key === 'title') continue
|
|
|
|
if (Array.isArray(value)) {
|
|
const items = value.map((v) => {
|
|
if (typeof v === 'object' && v !== null) {
|
|
return JSON.stringify(v)
|
|
}
|
|
return String(v)
|
|
})
|
|
lines.push(`${key}: ${items.join(', ')}`)
|
|
} else if (typeof value === 'object') {
|
|
lines.push(`${key}: ${JSON.stringify(value)}`)
|
|
} else if (typeof value === 'string' && /<[a-z][^>]*>/i.test(value)) {
|
|
lines.push(`${key}: ${htmlToPlainText(value)}`)
|
|
} else {
|
|
lines.push(`${key}: ${String(value)}`)
|
|
}
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Extracts a human-readable title from a Webflow CMS item.
|
|
*/
|
|
function extractItemTitle(item: WebflowItem): string {
|
|
const fieldData = item.fieldData || {}
|
|
return (fieldData.name as string) || (fieldData.title as string) || 'Untitled'
|
|
}
|
|
|
|
export const webflowConnector: ConnectorConfig = {
|
|
...webflowConnectorMeta,
|
|
|
|
listDocuments: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
cursor?: string,
|
|
syncContext?: Record<string, unknown>
|
|
): Promise<ExternalDocumentList> => {
|
|
const siteId = sourceConfig.siteId as string
|
|
const collectionIds = parseMultiValue(sourceConfig.collectionId)
|
|
const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : 0
|
|
|
|
let cursorState: CursorState
|
|
|
|
if (cursor) {
|
|
cursorState = JSON.parse(cursor) as CursorState
|
|
} else {
|
|
const collections = await fetchCollectionIds(accessToken, siteId, collectionIds)
|
|
cursorState = { collectionIndex: 0, offset: 0, collections }
|
|
}
|
|
|
|
if (cursorState.collections.length === 0) {
|
|
return { documents: [], hasMore: false }
|
|
}
|
|
|
|
if (syncContext && !syncContext.collectionNames) {
|
|
syncContext.collectionNames = {}
|
|
}
|
|
|
|
const totalDocsFetched = (syncContext?.totalDocsFetched as number) ?? 0
|
|
if (maxItems > 0 && totalDocsFetched >= maxItems) {
|
|
return { documents: [], hasMore: false }
|
|
}
|
|
|
|
const currentCollectionId = cursorState.collections[cursorState.collectionIndex]
|
|
const collectionName = await fetchCollectionName(accessToken, currentCollectionId, syncContext)
|
|
|
|
const params = new URLSearchParams()
|
|
params.append('limit', String(PAGE_SIZE))
|
|
params.append('offset', String(cursorState.offset))
|
|
|
|
const url = `${WEBFLOW_API}/collections/${currentCollectionId}/items?${params.toString()}`
|
|
|
|
logger.info('Listing Webflow CMS items', {
|
|
siteId,
|
|
collectionId: currentCollectionId,
|
|
offset: cursorState.offset,
|
|
})
|
|
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'accept-version': '2.0.0',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
logger.error('Failed to list Webflow items', {
|
|
status: response.status,
|
|
error: errorText,
|
|
})
|
|
throw new Error(`Failed to list Webflow items: ${response.status}`)
|
|
}
|
|
|
|
const data = (await response.json()) as {
|
|
items: WebflowItem[]
|
|
pagination: WebflowPagination
|
|
}
|
|
|
|
const items = data.items || []
|
|
const pageDocuments: ExternalDocument[] = items.map((item) =>
|
|
itemToDocument(item, currentCollectionId, collectionName)
|
|
)
|
|
|
|
let documents = pageDocuments
|
|
if (maxItems > 0) {
|
|
const remaining = Math.max(0, maxItems - totalDocsFetched)
|
|
if (documents.length > remaining) {
|
|
documents = documents.slice(0, remaining)
|
|
}
|
|
}
|
|
|
|
if (syncContext) {
|
|
syncContext.totalDocsFetched = totalDocsFetched + documents.length
|
|
}
|
|
|
|
const { pagination } = data
|
|
const hasMoreInCollection = cursorState.offset + pagination.limit < pagination.total
|
|
const hasMoreCollections = cursorState.collectionIndex < cursorState.collections.length - 1
|
|
const hitMaxItems = maxItems > 0 && totalDocsFetched + documents.length >= maxItems
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
let nextCursor: string | undefined
|
|
if (hitMaxItems) {
|
|
nextCursor = undefined
|
|
} else if (hasMoreInCollection) {
|
|
nextCursor = JSON.stringify({
|
|
collectionIndex: cursorState.collectionIndex,
|
|
offset: cursorState.offset + pagination.limit,
|
|
collections: cursorState.collections,
|
|
})
|
|
} else if (hasMoreCollections) {
|
|
nextCursor = JSON.stringify({
|
|
collectionIndex: cursorState.collectionIndex + 1,
|
|
offset: 0,
|
|
collections: cursorState.collections,
|
|
})
|
|
}
|
|
|
|
return {
|
|
documents,
|
|
nextCursor,
|
|
hasMore: Boolean(nextCursor),
|
|
}
|
|
},
|
|
|
|
getDocument: async (
|
|
accessToken: string,
|
|
_sourceConfig: Record<string, unknown>,
|
|
externalId: string
|
|
): Promise<ExternalDocument | null> => {
|
|
const separatorIndex = externalId.indexOf(':')
|
|
if (separatorIndex === -1) {
|
|
logger.error('Invalid externalId format, expected collectionId:itemId', { externalId })
|
|
return null
|
|
}
|
|
|
|
const docCollectionId = externalId.slice(0, separatorIndex)
|
|
const itemId = externalId.slice(separatorIndex + 1)
|
|
|
|
const url = `${WEBFLOW_API}/collections/${docCollectionId}/items/${itemId}`
|
|
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'accept-version': '2.0.0',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404) return null
|
|
throw new Error(`Failed to get Webflow item: ${response.status}`)
|
|
}
|
|
|
|
const item = (await response.json()) as WebflowItem
|
|
|
|
const collectionName = await fetchCollectionNameDirect(accessToken, docCollectionId)
|
|
return itemToDocument(item, docCollectionId, collectionName)
|
|
},
|
|
|
|
validateConfig: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>
|
|
): Promise<{ valid: boolean; error?: string }> => {
|
|
const siteId = sourceConfig.siteId as string
|
|
const collectionIds = parseMultiValue(sourceConfig.collectionId)
|
|
const maxItems = sourceConfig.maxItems as string | undefined
|
|
|
|
if (!siteId) {
|
|
return { valid: false, error: 'Site ID is required' }
|
|
}
|
|
|
|
if (maxItems && (Number.isNaN(Number(maxItems)) || Number(maxItems) <= 0)) {
|
|
return { valid: false, error: 'Max items must be a positive number' }
|
|
}
|
|
|
|
try {
|
|
const siteUrl = `${WEBFLOW_API}/sites/${siteId}`
|
|
const siteResponse = await fetchWithRetry(
|
|
siteUrl,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'accept-version': '2.0.0',
|
|
},
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!siteResponse.ok) {
|
|
if (siteResponse.status === 404) {
|
|
return { valid: false, error: `Site "${siteId}" not found` }
|
|
}
|
|
if (siteResponse.status === 403 || siteResponse.status === 401) {
|
|
return { valid: false, error: 'Access denied. Check your Webflow permissions.' }
|
|
}
|
|
const errorText = await siteResponse.text()
|
|
return { valid: false, error: `Webflow API error: ${siteResponse.status} - ${errorText}` }
|
|
}
|
|
|
|
for (const collectionId of collectionIds) {
|
|
const collectionUrl = `${WEBFLOW_API}/collections/${collectionId}`
|
|
const collectionResponse = await fetchWithRetry(
|
|
collectionUrl,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'accept-version': '2.0.0',
|
|
},
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!collectionResponse.ok) {
|
|
if (collectionResponse.status === 404) {
|
|
return { valid: false, error: `Collection "${collectionId}" not found` }
|
|
}
|
|
return {
|
|
valid: false,
|
|
error: `Failed to verify collection: ${collectionResponse.status}`,
|
|
}
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
} catch (error) {
|
|
const message = getErrorMessage(error, 'Failed to validate configuration')
|
|
return { valid: false, error: message }
|
|
}
|
|
},
|
|
|
|
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {}
|
|
|
|
if (typeof metadata.collectionName === 'string') {
|
|
result.collectionName = metadata.collectionName
|
|
}
|
|
|
|
const lastModified = parseTagDate(metadata.lastModified)
|
|
if (lastModified) result.lastModified = lastModified
|
|
|
|
if (typeof metadata.slug === 'string') {
|
|
result.slug = metadata.slug
|
|
}
|
|
|
|
return result
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Converts a Webflow CMS item to an ExternalDocument.
|
|
*/
|
|
function itemToDocument(
|
|
item: WebflowItem,
|
|
collectionId: string,
|
|
collectionName: string
|
|
): ExternalDocument {
|
|
const plainText = itemToPlainText(item, collectionName)
|
|
const lastModified = item.lastUpdated || item.lastPublished || item.createdOn || ''
|
|
const contentHash = `webflow:${item.id}:${lastModified}`
|
|
const title = extractItemTitle(item)
|
|
const slug = (item.fieldData?.slug as string) || ''
|
|
|
|
return {
|
|
externalId: `${collectionId}:${item.id}`,
|
|
title,
|
|
content: plainText,
|
|
mimeType: 'text/plain',
|
|
contentHash,
|
|
metadata: {
|
|
collectionName,
|
|
lastModified: item.lastUpdated || item.lastPublished || item.createdOn,
|
|
slug,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches collection IDs for a site. If a specific collectionId is provided,
|
|
* returns only that ID. Otherwise fetches all collections from the site.
|
|
*/
|
|
async function fetchCollectionIds(
|
|
accessToken: string,
|
|
siteId: string,
|
|
collectionIds: string[]
|
|
): Promise<string[]> {
|
|
if (collectionIds.length > 0) {
|
|
return collectionIds
|
|
}
|
|
|
|
const url = `${WEBFLOW_API}/sites/${siteId}/collections`
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'accept-version': '2.0.0',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to list Webflow collections: ${response.status}`)
|
|
}
|
|
|
|
const data = (await response.json()) as { collections: WebflowCollection[] }
|
|
return (data.collections || []).map((c) => c.id)
|
|
}
|
|
|
|
/**
|
|
* Fetches a collection's display name, caching in syncContext.
|
|
*/
|
|
async function fetchCollectionName(
|
|
accessToken: string,
|
|
collectionId: string,
|
|
syncContext?: Record<string, unknown>
|
|
): Promise<string> {
|
|
const names = (syncContext?.collectionNames ?? {}) as Record<string, string>
|
|
if (names[collectionId]) return names[collectionId]
|
|
|
|
const name = await fetchCollectionNameDirect(accessToken, collectionId)
|
|
|
|
if (syncContext) {
|
|
const cached = (syncContext.collectionNames ?? {}) as Record<string, string>
|
|
cached[collectionId] = name
|
|
syncContext.collectionNames = cached
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
/**
|
|
* Fetches a collection's display name directly from the API.
|
|
*/
|
|
async function fetchCollectionNameDirect(
|
|
accessToken: string,
|
|
collectionId: string
|
|
): Promise<string> {
|
|
try {
|
|
const url = `${WEBFLOW_API}/collections/${collectionId}`
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'accept-version': '2.0.0',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
logger.warn('Failed to fetch collection name', {
|
|
collectionId,
|
|
status: response.status,
|
|
})
|
|
return collectionId
|
|
}
|
|
|
|
const data = (await response.json()) as WebflowCollection
|
|
return data.displayName || data.slug || collectionId
|
|
} catch (error) {
|
|
logger.warn('Error fetching collection name', {
|
|
collectionId,
|
|
error: toError(error).message,
|
|
})
|
|
return collectionId
|
|
}
|
|
}
|