Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
316 changes: 316 additions & 0 deletions .claude/commands/validate-connector.md

Large diffs are not rendered by default.

69 changes: 49 additions & 20 deletions apps/sim/connectors/confluence/confluence.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { ConfluenceIcon } from '@/components/icons'
import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { computeContentHash, htmlToPlainText, joinTagArray, parseTagDate } from '@/connectors/utils'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
Expand Down Expand Up @@ -243,43 +243,54 @@ export const confluenceConnector: ConnectorConfig = {
const domain = sourceConfig.domain as string
const cloudId = await getConfluenceCloudId(domain, accessToken)

const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${externalId}?body-format=storage`

const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
// Try pages first, fall back to blogposts if not found
let page: Record<string, unknown> | null = null
for (const endpoint of ['pages', 'blogposts']) {
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/${endpoint}/${externalId}?body-format=storage`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})

if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to get Confluence page: ${response.status}`)
if (response.ok) {
page = await response.json()
break
}
if (response.status !== 404) {
throw new Error(`Failed to get Confluence content: ${response.status}`)
}
}

const page = await response.json()
const rawContent = page.body?.storage?.value || ''
if (!page) return null
const body = page.body as Record<string, unknown> | undefined
const storage = body?.storage as Record<string, unknown> | undefined
const rawContent = (storage?.value as string) || ''
const plainText = htmlToPlainText(rawContent)
const contentHash = await computeContentHash(plainText)

// Fetch labels for this page
const labelMap = await fetchLabelsForPages(cloudId, accessToken, [String(page.id)])
const labels = labelMap.get(String(page.id)) ?? []

const links = page._links as Record<string, unknown> | undefined
const version = page.version as Record<string, unknown> | undefined

return {
externalId: String(page.id),
title: page.title || 'Untitled',
title: (page.title as string) || 'Untitled',
content: plainText,
mimeType: 'text/plain',
sourceUrl: page._links?.webui ? `https://${domain}/wiki${page._links.webui}` : undefined,
sourceUrl: links?.webui ? `https://${domain}/wiki${links.webui}` : undefined,
contentHash,
metadata: {
spaceId: page.spaceId,
status: page.status,
version: page.version?.number,
version: version?.number,
labels,
lastModified: page.version?.createdAt,
lastModified: version?.createdAt,
},
}
},
Expand All @@ -302,7 +313,25 @@ export const confluenceConnector: ConnectorConfig = {

try {
const cloudId = await getConfluenceCloudId(domain, accessToken)
await resolveSpaceId(cloudId, accessToken, spaceKey)
const spaceUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?keys=${encodeURIComponent(spaceKey)}&limit=1`
const response = await fetchWithRetry(
spaceUrl,
{
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
return { valid: false, error: `Failed to validate space: ${response.status}` }
}
const data = await response.json()
if (!data.results?.length) {
return { valid: false, error: `Space "${spaceKey}" not found` }
}
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to validate configuration'
Expand Down
14 changes: 10 additions & 4 deletions apps/sim/connectors/github/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { GithubIcon } from '@/components/icons'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { computeContentHash } from '@/connectors/utils'
import { computeContentHash, parseTagDate } from '@/connectors/utils'

const logger = createLogger('GitHubConnector')

Expand Down Expand Up @@ -82,7 +82,7 @@ async function fetchTree(
const data = await response.json()

if (data.truncated) {
logger.error('GitHub tree was truncated — some files may be missing', { owner, repo, branch })
logger.warn('GitHub tree was truncated — some files may be missing', { owner, repo, branch })
}

return (data.tree || []).filter((item: TreeItem) => item.type === 'blob')
Expand Down Expand Up @@ -139,7 +139,7 @@ async function treeItemToDocument(
title: item.path.split('/').pop() || item.path,
content,
mimeType: 'text/plain',
sourceUrl: `https://github.com/${owner}/${repo}/blob/${branch}/${item.path}`,
sourceUrl: `https://github.com/${owner}/${repo}/blob/${encodeURIComponent(branch)}/${item.path.split('/').map(encodeURIComponent).join('/')}`,
contentHash,
metadata: {
path: item.path,
Expand Down Expand Up @@ -302,6 +302,7 @@ export const githubConnector: ConnectorConfig = {
throw new Error(`Failed to fetch file ${path}: ${response.status}`)
}

const lastModifiedHeader = response.headers.get('last-modified') || undefined
const data = await response.json()
const content =
data.encoding === 'base64'
Expand All @@ -314,14 +315,15 @@ export const githubConnector: ConnectorConfig = {
title: path.split('/').pop() || path,
content,
mimeType: 'text/plain',
sourceUrl: `https://github.com/${owner}/${repo}/blob/${branch}/${path}`,
sourceUrl: `https://github.com/${owner}/${repo}/blob/${encodeURIComponent(branch)}/${path.split('/').map(encodeURIComponent).join('/')}`,
contentHash,
metadata: {
path,
sha: data.sha as string,
size: data.size as number,
branch,
repository: `${owner}/${repo}`,
lastModified: lastModifiedHeader,
},
}
} catch (error) {
Expand Down Expand Up @@ -400,6 +402,7 @@ export const githubConnector: ConnectorConfig = {
{ id: 'repository', displayName: 'Repository', fieldType: 'text' },
{ id: 'branch', displayName: 'Branch', fieldType: 'text' },
{ id: 'size', displayName: 'File Size', fieldType: 'number' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
],

mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
Expand All @@ -414,6 +417,9 @@ export const githubConnector: ConnectorConfig = {
if (!Number.isNaN(num)) result.size = num
}

const lastModified = parseTagDate(metadata.lastModified)
if (lastModified) result.lastModified = lastModified

return result
},
}
8 changes: 8 additions & 0 deletions apps/sim/connectors/google-calendar/google-calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,8 @@ export const googleCalendarConnector: ConnectorConfig = {
{ id: 'attendeeCount', displayName: 'Attendee Count', fieldType: 'number' },
{ id: 'location', displayName: 'Location', fieldType: 'text' },
{ id: 'eventDate', displayName: 'Event Date', fieldType: 'date' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
{ id: 'createdAt', displayName: 'Created', fieldType: 'date' },
],

mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
Expand All @@ -459,6 +461,12 @@ export const googleCalendarConnector: ConnectorConfig = {
const eventDate = parseTagDate(metadata.eventDate)
if (eventDate) result.eventDate = eventDate

const lastModified = parseTagDate(metadata.updatedTime)
if (lastModified) result.lastModified = lastModified

const createdAt = parseTagDate(metadata.createdTime)
if (createdAt) result.createdAt = createdAt

return result
},
}
2 changes: 1 addition & 1 deletion apps/sim/connectors/google-docs/google-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ function buildQuery(sourceConfig: Record<string, unknown>): string {

const folderId = sourceConfig.folderId as string | undefined
if (folderId?.trim()) {
parts.push(`'${folderId.trim()}' in parents`)
parts.push(`'${folderId.trim().replace(/'/g, "\\'")}' in parents`)
}

return parts.join(' and ')
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/connectors/google-drive/google-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ function buildQuery(sourceConfig: Record<string, unknown>): string {

const folderId = sourceConfig.folderId as string | undefined
if (folderId?.trim()) {
parts.push(`'${folderId.trim()}' in parents`)
parts.push(`'${folderId.trim().replace(/'/g, "\\'")}' in parents`)
}

const fileType = (sourceConfig.fileType as string) || 'all'
Expand Down
78 changes: 72 additions & 6 deletions apps/sim/connectors/google-sheets/google-sheets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { createLogger } from '@sim/logger'
import { GoogleSheetsIcon } from '@/components/icons'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { computeContentHash } from '@/connectors/utils'
import { computeContentHash, parseTagDate } from '@/connectors/utils'

const logger = createLogger('GoogleSheetsConnector')

const SHEETS_API_BASE = 'https://sheets.googleapis.com/v4/spreadsheets'
const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3/files'
const MAX_ROWS = 10000
const CONCURRENCY = 3

Expand Down Expand Up @@ -102,14 +103,47 @@ async function fetchSpreadsheetMetadata(
return (await response.json()) as SpreadsheetMetadata
}

/**
* Fetches the spreadsheet's modifiedTime from the Drive API.
*/
async function fetchSpreadsheetModifiedTime(
accessToken: string,
spreadsheetId: string
): Promise<string | undefined> {
try {
const url = `${DRIVE_API_BASE}/${encodeURIComponent(spreadsheetId)}?fields=modifiedTime&supportsAllDrives=true`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})

if (!response.ok) {
logger.warn('Failed to fetch modifiedTime from Drive API', { status: response.status })
return undefined
}

const data = (await response.json()) as { modifiedTime?: string }
return data.modifiedTime
} catch (error) {
logger.warn('Error fetching modifiedTime from Drive API', {
error: error instanceof Error ? error.message : String(error),
})
return undefined
}
}

/**
* Converts a single sheet tab into an ExternalDocument.
*/
async function sheetToDocument(
accessToken: string,
spreadsheetId: string,
spreadsheetTitle: string,
sheet: SheetProperties
sheet: SheetProperties,
modifiedTime?: string
): Promise<ExternalDocument | null> {
try {
const values = await fetchSheetValues(accessToken, spreadsheetId, sheet.title)
Expand Down Expand Up @@ -151,6 +185,7 @@ async function sheetToDocument(
sheetId: sheet.sheetId,
rowCount,
columnCount: headers.length,
...(modifiedTime ? { modifiedTime } : {}),
},
}
} catch (error) {
Expand Down Expand Up @@ -208,7 +243,10 @@ export const googleSheetsConnector: ConnectorConfig = {

logger.info('Fetching spreadsheet metadata', { spreadsheetId })

const metadata = await fetchSpreadsheetMetadata(accessToken, spreadsheetId)
const [metadata, modifiedTime] = await Promise.all([
fetchSpreadsheetMetadata(accessToken, spreadsheetId),
fetchSpreadsheetModifiedTime(accessToken, spreadsheetId),
])
const sheetFilter = (sourceConfig.sheetFilter as string) || 'all'

let sheets = metadata.sheets.map((s) => s.properties)
Expand All @@ -226,7 +264,13 @@ export const googleSheetsConnector: ConnectorConfig = {
const batch = sheets.slice(i, i + CONCURRENCY)
const results = await Promise.all(
batch.map((sheet) =>
sheetToDocument(accessToken, spreadsheetId, metadata.properties.title, sheet)
sheetToDocument(
accessToken,
spreadsheetId,
metadata.properties.title,
sheet,
modifiedTime
)
)
)
documents.push(...(results.filter(Boolean) as ExternalDocument[]))
Expand Down Expand Up @@ -257,7 +301,22 @@ export const googleSheetsConnector: ConnectorConfig = {
return null
}

const metadata = await fetchSpreadsheetMetadata(accessToken, spreadsheetId)
let metadata: SpreadsheetMetadata
let modifiedTime: string | undefined
try {
;[metadata, modifiedTime] = await Promise.all([
fetchSpreadsheetMetadata(accessToken, spreadsheetId),
fetchSpreadsheetModifiedTime(accessToken, spreadsheetId),
])
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('404')) {
logger.info('Spreadsheet not found (possibly deleted)', { spreadsheetId })
return null
}
throw error
}

const sheetEntry = metadata.sheets.find((s) => s.properties.sheetId === sheetId)

if (!sheetEntry) {
Expand All @@ -269,7 +328,8 @@ export const googleSheetsConnector: ConnectorConfig = {
accessToken,
spreadsheetId,
metadata.properties.title,
sheetEntry.properties
sheetEntry.properties,
modifiedTime
)
},

Expand Down Expand Up @@ -325,6 +385,7 @@ export const googleSheetsConnector: ConnectorConfig = {
{ id: 'sheetTitle', displayName: 'Sheet Name', fieldType: 'text' },
{ id: 'rowCount', displayName: 'Row Count', fieldType: 'number' },
{ id: 'columnCount', displayName: 'Column Count', fieldType: 'number' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
],

mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
Expand All @@ -342,6 +403,11 @@ export const googleSheetsConnector: ConnectorConfig = {
result.columnCount = metadata.columnCount
}

const lastModified = parseTagDate(metadata.modifiedTime)
if (lastModified) {
result.lastModified = lastModified
}

return result
},
}
Loading
Loading