Files
simstudioai--sim/apps/sim/connectors/google-docs/google-docs.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

383 lines
12 KiB
TypeScript

import { createLogger } from '@sim/logger'
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 {
buildDriveParentsClause,
joinTagArray,
parseMultiValue,
parseTagDate,
} from '@/connectors/utils'
const logger = createLogger('GoogleDocsConnector')
/**
* Represents a Google Drive file entry returned by the Drive API.
*/
interface DriveFile {
id: string
name: string
mimeType: string
modifiedTime?: string
createdTime?: string
webViewLink?: string
owners?: { displayName?: string; emailAddress?: string }[]
}
/**
* Represents a structural element within a Google Docs document body.
*/
interface DocsStructuralElement {
paragraph?: {
paragraphStyle?: {
namedStyleType?: string
}
elements?: {
textRun?: {
content?: string
}
}[]
}
}
/**
* Represents the response from the Google Docs API for a single document.
*/
interface DocsDocument {
documentId: string
title: string
body?: {
content?: DocsStructuralElement[]
}
}
/**
* Maps a Google Docs heading style to a Markdown heading prefix.
*/
function headingPrefix(namedStyleType?: string): string {
switch (namedStyleType) {
case 'HEADING_1':
return '# '
case 'HEADING_2':
return '## '
case 'HEADING_3':
return '### '
case 'HEADING_4':
return '#### '
case 'HEADING_5':
return '##### '
case 'HEADING_6':
return '###### '
default:
return ''
}
}
/**
* Extracts plain text from a Google Docs API structured document response.
* Headings are prefixed with Markdown-style `#` markers.
*/
function extractTextFromDocsBody(doc: DocsDocument): string {
const elements = doc.body?.content
if (!elements) return ''
const parts: string[] = []
for (const element of elements) {
const paragraph = element.paragraph
if (!paragraph?.elements) continue
const prefix = headingPrefix(paragraph.paragraphStyle?.namedStyleType)
/**
* Each paragraph's final `textRun.content` already ends with `\n`. Strip
* it before joining with `\n` so a heading followed by a body paragraph
* is separated by a single newline, not two.
*/
const text = paragraph.elements
.map((el) => el.textRun?.content ?? '')
.join('')
.replace(/\n+$/, '')
if (text.trim()) {
parts.push(`${prefix}${text}`)
}
}
return parts.join('\n').trim()
}
/**
* Fetches the structured content of a Google Doc via the Docs API and
* extracts it as plain text.
*/
async function fetchDocContent(accessToken: string, documentId: string): Promise<string> {
const url = `https://docs.googleapis.com/v1/documents/${documentId}?fields=body.content`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
throw new Error(`Failed to fetch Google Doc content ${documentId}: ${response.status}`)
}
const doc = (await response.json()) as DocsDocument
return extractTextFromDocsBody(doc)
}
/**
* Creates a lightweight stub from a Drive file entry. Content is deferred
* and only fetched via getDocument for new or changed documents.
*/
function fileToStub(file: DriveFile): ExternalDocument {
return {
externalId: file.id,
title: file.name || 'Untitled',
content: '',
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: file.webViewLink || `https://docs.google.com/document/d/${file.id}/edit`,
contentHash: `gdocs:${file.id}:${file.modifiedTime ?? ''}`,
metadata: {
modifiedTime: file.modifiedTime,
createdTime: file.createdTime,
owners: file.owners?.map((o) => o.displayName || o.emailAddress).filter(Boolean),
},
}
}
/**
* Builds the Drive API query string for listing Google Docs.
*/
function buildQuery(sourceConfig: Record<string, unknown>): string {
const parts: string[] = ['trashed = false', "mimeType = 'application/vnd.google-apps.document'"]
const parentsClause = buildDriveParentsClause(parseMultiValue(sourceConfig.folderId))
if (parentsClause) parts.push(parentsClause)
return parts.join(' and ')
}
export const googleDocsConnector: ConnectorConfig = {
...googleDocsConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const query = buildQuery(sourceConfig)
const pageSize = 100
const queryParams = new URLSearchParams({
q: query,
pageSize: String(pageSize),
fields: 'nextPageToken,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners)',
supportsAllDrives: 'true',
includeItemsFromAllDrives: 'true',
})
if (cursor) {
queryParams.set('pageToken', cursor)
}
const url = `https://www.googleapis.com/drive/v3/files?${queryParams.toString()}`
logger.info('Listing Google Docs', { query, cursor: cursor ?? 'initial' })
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to list Google Docs', {
status: response.status,
error: errorText,
})
throw new Error(`Failed to list Google Docs: ${response.status}`)
}
const data = await response.json()
const files = (data.files || []) as DriveFile[]
/**
* Drive sets `incompleteSearch` when it could not search every corpus (it
* arises with the `allDrives` scope enabled by `includeItemsFromAllDrives`).
* A partial listing drops still-existing docs, so reconciliation must be
* suppressed to avoid hard-deleting valid documents.
*/
const incompleteSearch = data.incompleteSearch === true
const maxDocs = sourceConfig.maxDocs ? Number(sourceConfig.maxDocs) : 0
const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0
let documents = files.map(fileToStub)
let slicedSome = false
if (maxDocs > 0) {
const remaining = maxDocs - previouslyFetched
if (documents.length > remaining) {
slicedSome = true
documents = documents.slice(0, remaining)
}
}
const totalFetched = previouslyFetched + documents.length
if (syncContext) syncContext.totalDocsFetched = totalFetched
const hitLimit = maxDocs > 0 && totalFetched >= maxDocs
const nextPageToken = data.nextPageToken as string | undefined
/**
* Mark the listing as incomplete so the sync engine skips deletion
* reconciliation when this page does not represent the full source set:
* - `slicedSome`: the page held more docs than the `maxDocs` cap allowed.
* - `hitLimit` with a next page: the cap was reached while more pages remain.
* - `incompleteSearch`: Drive could not search every corpus, so the page is
* partial and may omit still-existing docs.
* Reconciliation against any of these would hard-delete valid documents.
*/
if (syncContext && (slicedSome || (hitLimit && Boolean(nextPageToken)) || incompleteSearch)) {
syncContext.listingCapped = true
}
return {
documents,
nextCursor: hitLimit ? undefined : nextPageToken,
hasMore: hitLimit ? false : Boolean(nextPageToken),
}
},
getDocument: async (
accessToken: string,
_sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const fields = 'id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,trashed'
const url = `https://www.googleapis.com/drive/v3/files/${externalId}?fields=${encodeURIComponent(fields)}&supportsAllDrives=true`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to get Google Doc metadata: ${response.status}`)
}
const file = (await response.json()) as DriveFile & { trashed?: boolean }
if (file.trashed) return null
if (file.mimeType !== 'application/vnd.google-apps.document') return null
try {
const content = await fetchDocContent(accessToken, file.id)
if (!content.trim()) return null
return { ...fileToStub(file), content, contentDeferred: false }
} catch (error) {
logger.warn(`Failed to extract content from document: ${file.name} (${file.id})`, {
error: toError(error).message,
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const folderIds = parseMultiValue(sourceConfig.folderId)
const maxDocs = sourceConfig.maxDocs as string | undefined
if (maxDocs && (Number.isNaN(Number(maxDocs)) || Number(maxDocs) <= 0)) {
return { valid: false, error: 'Max documents must be a positive number' }
}
try {
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 "${folderId}" not found. Check the folder ID and permissions.`,
}
}
return {
valid: false,
error: `Failed to access folder "${folderId}": ${response.status}`,
}
}
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 =
"https://www.googleapis.com/drive/v3/files?pageSize=1&q=mimeType%3D'application%2Fvnd.google-apps.document'&fields=files(id)"
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
return { valid: false, error: `Failed to access Google Docs: ${response.status}` }
}
}
return { valid: true }
} catch (error) {
return { valid: false, error: toError(error).message || 'Failed to validate configuration' }
}
},
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
const owners = joinTagArray(metadata.owners)
if (owners) result.owners = owners
const lastModified = parseTagDate(metadata.modifiedTime)
if (lastModified) result.lastModified = lastModified
return result
},
}