chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { sharepointConnector } from '@/connectors/sharepoint/sharepoint'
+44
View File
@@ -0,0 +1,44 @@
import { MicrosoftSharepointIcon } from '@/components/icons'
import type { ConnectorMeta } from '@/connectors/types'
export const sharepointConnectorMeta: ConnectorMeta = {
id: 'sharepoint',
name: 'SharePoint',
description: 'Sync documents from a SharePoint site',
version: '1.0.0',
icon: MicrosoftSharepointIcon,
auth: { mode: 'oauth', provider: 'sharepoint', requiredScopes: ['Sites.Read.All'] },
configFields: [
{
id: 'siteUrl',
title: 'Site URL',
type: 'short-input',
placeholder: 'e.g. contoso.sharepoint.com/sites/mysite',
required: true,
},
{
id: 'folderPath',
title: 'Folder Path',
type: 'short-input',
placeholder: 'e.g. Documents/Reports (optional, defaults to root)',
required: false,
},
{
id: 'maxFiles',
title: 'Max Files',
type: 'short-input',
placeholder: 'e.g. 500 (default: unlimited)',
required: false,
},
],
tagDefinitions: [
{ id: 'path', displayName: 'Path', fieldType: 'text' },
{ id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
{ id: 'fileSize', displayName: 'File Size', fieldType: 'number' },
{ id: 'createdBy', displayName: 'Created By', fieldType: 'text' },
{ id: 'siteName', displayName: 'Site Name', fieldType: 'text' },
],
}
@@ -0,0 +1,556 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { sharepointConnectorMeta } from '@/connectors/sharepoint/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import {
CONNECTOR_MAX_FILE_BYTES,
ConnectorFileTooLargeError,
htmlToPlainText,
isSkippedDocument,
markSkipped,
parseTagDate,
readBodyWithLimit,
sizeLimitSkipReason,
stubOrSkipBySize,
takeIndexableWithinCap,
} from '@/connectors/utils'
const logger = createLogger('SharePointConnector')
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0'
const SUPPORTED_TEXT_EXTENSIONS = new Set([
'.txt',
'.md',
'.html',
'.htm',
'.csv',
'.json',
'.xml',
'.yaml',
'.yml',
'.log',
'.rst',
'.tsv',
])
const MAX_DOWNLOAD_SIZE = CONNECTOR_MAX_FILE_BYTES
/** Microsoft Graph drive item shape (subset of fields we use). */
interface DriveItem {
id: string
name: string
webUrl?: string
size?: number
file?: { mimeType?: string }
folder?: { childCount?: number }
lastModifiedDateTime?: string
createdDateTime?: string
createdBy?: { user?: { displayName?: string } }
parentReference?: { path?: string; siteId?: string }
}
interface DriveItemListResponse {
value: DriveItem[]
'@odata.nextLink'?: string
}
/**
* Returns true when the file extension is in the supported text set.
*/
function isSupportedTextFile(name: string): boolean {
const dotIndex = name.lastIndexOf('.')
if (dotIndex === -1) return false
return SUPPORTED_TEXT_EXTENSIONS.has(name.slice(dotIndex).toLowerCase())
}
/**
* Resolves a SharePoint site URL like "contoso.sharepoint.com/sites/mysite"
* into a Microsoft Graph siteId.
*/
async function resolveSiteId(
accessToken: string,
siteUrl: string,
retryOptions?: Parameters<typeof fetchWithRetry>[2]
): Promise<{ id: string; displayName: string }> {
// Normalise: strip protocol, trailing slashes
const cleaned = siteUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')
// Split into hostname and server-relative path
const firstSlash = cleaned.indexOf('/')
let hostname: string
let serverRelativePath: string
if (firstSlash === -1) {
hostname = cleaned
serverRelativePath = ''
} else {
hostname = cleaned.slice(0, firstSlash)
serverRelativePath = cleaned.slice(firstSlash)
}
// Graph endpoint: GET /sites/{hostname}:/{path}
const url = serverRelativePath
? `${GRAPH_BASE}/sites/${hostname}:${serverRelativePath}`
: `${GRAPH_BASE}/sites/${hostname}`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
retryOptions
)
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`Failed to resolve SharePoint site "${siteUrl}": ${response.status} ${errorText}`
)
}
const site = (await response.json()) as { id: string; displayName?: string }
logger.info('Resolved SharePoint site', {
siteUrl,
siteId: site.id,
displayName: site.displayName,
})
return { id: site.id, displayName: site.displayName ?? '' }
}
/**
* Downloads the text content of a drive item.
*/
async function downloadFileContent(
accessToken: string,
siteId: string,
itemId: string,
fileName: string
): Promise<string> {
const url = `${GRAPH_BASE}/sites/${siteId}/drive/items/${itemId}/content`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
redirect: 'follow',
})
if (!response.ok) {
throw new Error(`Failed to download file "${fileName}" (${itemId}): ${response.status}`)
}
// Stream with a hard byte cap so a file with missing/under-reported listing
// size metadata is never fully buffered into memory. Oversized files are
// skipped (returned empty) rather than indexed as truncated partial content.
const buffer = await readBodyWithLimit(response, MAX_DOWNLOAD_SIZE)
if (!buffer) {
throw new ConnectorFileTooLargeError(MAX_DOWNLOAD_SIZE)
}
return buffer.toString('utf8')
}
/**
* Fetches file content, applying HTML-to-text conversion for .html files.
*/
async function fetchFileContent(
accessToken: string,
siteId: string,
itemId: string,
fileName: string
): Promise<string> {
const raw = await downloadFileContent(accessToken, siteId, itemId, fileName)
if (fileName.toLowerCase().endsWith('.html') || fileName.toLowerCase().endsWith('.htm')) {
return htmlToPlainText(raw)
}
return raw
}
/**
* Converts a DriveItem to a lightweight metadata stub (no content download).
*/
function itemToStub(item: DriveItem, siteName: string): ExternalDocument {
return {
externalId: item.id,
title: item.name || 'Untitled',
content: '',
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: item.webUrl,
contentHash: `sharepoint:${item.id}:${item.lastModifiedDateTime ?? ''}`,
metadata: {
lastModifiedDateTime: item.lastModifiedDateTime,
createdDateTime: item.createdDateTime,
createdBy: item.createdBy?.user?.displayName,
fileSize: item.size,
path: item.parentReference?.path,
siteName,
},
}
}
/**
* Lists items in a folder. When folderId is omitted the root of the default
* document library is listed.
*/
async function listFolderItems(
accessToken: string,
siteId: string,
folderId?: string,
nextLink?: string
): Promise<DriveItemListResponse> {
const url =
nextLink ??
(folderId
? `${GRAPH_BASE}/sites/${siteId}/drive/items/${folderId}/children?$top=200`
: `${GRAPH_BASE}/sites/${siteId}/drive/root/children?$top=200`)
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to list folder items: ${response.status} ${errorText}`)
}
return response.json() as Promise<DriveItemListResponse>
}
/**
* Resolves a slash-separated folder path (e.g. "Documents/Reports") to a
* DriveItem ID by walking the path segments from root.
*/
async function resolveFolderPath(
accessToken: string,
siteId: string,
folderPath: string
): Promise<string> {
const cleaned = folderPath.replace(/^\/+|\/+$/g, '')
if (!cleaned) {
throw new Error('Folder path is empty after normalisation')
}
const encoded = cleaned.split('/').map(encodeURIComponent).join('/')
const url = `${GRAPH_BASE}/sites/${siteId}/drive/root:/${encoded}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Folder not found: "${folderPath}"`)
}
throw new Error(`Failed to resolve folder path "${folderPath}": ${response.status}`)
}
const item = (await response.json()) as DriveItem
if (!item.folder) {
throw new Error(`Path "${folderPath}" is not a folder`)
}
return item.id
}
/**
* Pagination state encoded as the cursor string.
* We track a stack of folder IDs to traverse plus an optional @odata.nextLink.
*/
interface PaginationState {
/** Folders still to be listed (depth-first) */
folderStack: string[]
/** Current folder being listed (undefined = root) */
currentFolder?: string
/** @odata.nextLink for the current folder page */
nextLink?: string
}
function encodeCursor(state: PaginationState): string {
return Buffer.from(JSON.stringify(state)).toString('base64')
}
function decodeCursor(cursor: string): PaginationState {
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as PaginationState
}
export const sharepointConnector: ConnectorConfig = {
...sharepointConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const siteUrl = sourceConfig.siteUrl as string
// Resolve and cache siteId in syncContext
let siteId: string
let siteName: string
if (syncContext?.siteId) {
siteId = syncContext.siteId as string
siteName = (syncContext.siteName as string) ?? ''
} else {
const site = await resolveSiteId(accessToken, siteUrl)
siteId = site.id
siteName = site.displayName || siteUrl
if (syncContext) {
syncContext.siteId = siteId
syncContext.siteName = siteName
}
}
// Resolve starting folder if configured (cache in syncContext)
let rootFolderId: string | undefined
const folderPath = (sourceConfig.folderPath as string)?.trim()
if (folderPath) {
if (syncContext?.rootFolderId) {
rootFolderId = syncContext.rootFolderId as string
} else {
rootFolderId = await resolveFolderPath(accessToken, siteId, folderPath)
if (syncContext) syncContext.rootFolderId = rootFolderId
}
}
// Decode or initialise pagination state
let state: PaginationState
if (cursor) {
state = decodeCursor(cursor)
} else {
state = {
folderStack: [],
currentFolder: rootFolderId,
}
}
const documents: ExternalDocument[] = []
const maxFiles = sourceConfig.maxFiles ? Number(sourceConfig.maxFiles) : 0
let totalFetched = (syncContext?.totalDocsFetched as number) ?? 0
// Process one page of items from the current folder
const data = await listFolderItems(accessToken, siteId, state.currentFolder, state.nextLink)
// Separate files and subfolders
const subfolders: string[] = []
const files: DriveItem[] = []
for (const item of data.value) {
if (item.folder) {
subfolders.push(item.id)
} else if (item.file && isSupportedTextFile(item.name)) {
// Keep oversized files; they are surfaced as skipped (failed) docs below.
files.push(item)
}
}
// Push subfolders onto the stack for depth-first traversal
state.folderStack.push(...subfolders)
// Convert files to lightweight stubs (no content download). Oversized files are
// kept as skipped stubs but do not consume the max-files cap.
const previouslyFetched = totalFetched
const stubs = files.map((file) =>
stubOrSkipBySize(itemToStub(file, siteName), file.size, MAX_DOWNLOAD_SIZE)
)
const {
documents: pageDocuments,
indexableCount,
capReached,
} = takeIndexableWithinCap(stubs, isSkippedDocument, maxFiles, previouslyFetched)
documents.push(...pageDocuments)
totalFetched += indexableCount
if (syncContext) syncContext.totalDocsFetched = totalFetched
const hitLimit = capReached
if (hitLimit && syncContext) syncContext.listingCapped = true
if (hitLimit) {
return { documents, hasMore: false }
}
if (data['@odata.nextLink']) {
// More pages in the current folder
state.nextLink = data['@odata.nextLink']
return {
documents,
nextCursor: encodeCursor(state),
hasMore: true,
}
}
// Current folder exhausted — move to next folder on the stack
if (state.folderStack.length > 0) {
const nextFolder = state.folderStack.pop()!
state.currentFolder = nextFolder
state.nextLink = undefined
return {
documents,
nextCursor: encodeCursor(state),
hasMore: true,
}
}
// Nothing left
return { documents, hasMore: false }
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocument | null> => {
const siteUrl = sourceConfig.siteUrl as string
let siteId = syncContext?.siteId as string | undefined
let siteName = syncContext?.siteName as string | undefined
if (!siteId) {
const site = await resolveSiteId(accessToken, siteUrl)
siteId = site.id
siteName = site.displayName ?? siteUrl
if (syncContext) {
syncContext.siteId = siteId
syncContext.siteName = siteName
}
}
const url = `${GRAPH_BASE}/sites/${siteId}/drive/items/${externalId}`
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 SharePoint file: ${response.status}`)
}
const item = (await response.json()) as DriveItem
if (!item.file || !isSupportedTextFile(item.name)) {
return null
}
try {
const content = await fetchFileContent(accessToken, siteId, item.id, item.name)
if (!content.trim()) return null
const stub = itemToStub(item, siteName ?? siteUrl)
return { ...stub, content, contentDeferred: false }
} catch (error) {
if (error instanceof ConnectorFileTooLargeError) {
logger.info('Skipping oversized SharePoint file', { fileId: item.id, name: item.name })
return markSkipped(
itemToStub(item, siteName ?? siteUrl),
sizeLimitSkipReason(error.limitBytes)
)
}
logger.warn(`Failed to fetch content for file: ${item.name} (${item.id})`, {
error: toError(error).message,
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const siteUrl = (sourceConfig.siteUrl as string)?.trim()
if (!siteUrl) {
return { valid: false, error: 'Site URL is required' }
}
const maxFiles = sourceConfig.maxFiles as string | undefined
if (maxFiles && (Number.isNaN(Number(maxFiles)) || Number(maxFiles) <= 0)) {
return { valid: false, error: 'Max files must be a positive number' }
}
try {
const site = await resolveSiteId(accessToken, siteUrl, VALIDATE_RETRY_OPTIONS)
const siteId = site.id
// If a folder path is configured, verify it exists
const folderPath = (sourceConfig.folderPath as string)?.trim()
if (folderPath) {
const encodedPath = folderPath
.replace(/^\/+|\/+$/g, '')
.split('/')
.map(encodeURIComponent)
.join('/')
const folderUrl = `${GRAPH_BASE}/sites/${siteId}/drive/root:/${encodedPath}`
const response = await fetchWithRetry(
folderUrl,
{
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: "${folderPath}"` }
}
return { valid: false, error: `Failed to access folder: ${response.status}` }
}
const item = (await response.json()) as DriveItem
if (!item.folder) {
return { valid: false, error: `Path "${folderPath}" is not a folder` }
}
}
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.path === 'string') {
result.path = metadata.path
}
const lastModified = parseTagDate(metadata.lastModifiedDateTime)
if (lastModified) result.lastModified = lastModified
if (typeof metadata.fileSize === 'number') {
result.fileSize = metadata.fileSize
}
if (typeof metadata.createdBy === 'string') {
result.createdBy = metadata.createdBy
}
if (typeof metadata.siteName === 'string') {
result.siteName = metadata.siteName
}
return result
},
}