Skip to content

Commit c4f4ec9

Browse files
committed
Merge remote-tracking branch 'origin/staging' into mship-fixes
2 parents 51f2549 + ef225f9 commit c4f4ec9

12 files changed

Lines changed: 1163 additions & 227 deletions

File tree

apps/sim/connectors/onedrive/onedrive.ts

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/
66
import {
77
CONNECTOR_MAX_FILE_BYTES,
88
ConnectorFileTooLargeError,
9-
htmlToPlainText,
9+
ConnectorTextExtractionError,
10+
connectorFileExtension,
11+
extractConnectorText,
12+
extractionFailedSkipReason,
13+
isIndexableConnectorFile,
1014
isSkippedDocument,
1115
markSkipped,
1216
parseTagDate,
@@ -18,23 +22,11 @@ import {
1822

1923
const logger = createLogger('OneDriveConnector')
2024

21-
const SUPPORTED_EXTENSIONS = new Set([
22-
'.txt',
23-
'.md',
24-
'.html',
25-
'.htm',
26-
'.csv',
27-
'.json',
28-
'.xml',
29-
'.yaml',
30-
'.yml',
31-
'.log',
32-
'.rst',
33-
'.tsv',
34-
])
35-
3625
const MAX_FILE_SIZE = CONNECTOR_MAX_FILE_BYTES
3726

27+
/** Distinct extensions named in the per-page skipped-file diagnostic. */
28+
const MAX_LOGGED_SKIPPED_EXTENSIONS = 10
29+
3830
const GRAPH_API_ORIGIN = 'https://graph.microsoft.com'
3931
const GRAPH_BASE_URL = `${GRAPH_API_ORIGIN}/v1.0`
4032

@@ -85,19 +77,9 @@ interface OneDriveListResponse {
8577
}
8678

8779
/**
88-
* Checks whether a file has a supported text extension.
80+
* Downloads the raw bytes of a OneDrive file.
8981
*/
90-
function isSupportedTextFile(name: string): boolean {
91-
const dotIndex = name.lastIndexOf('.')
92-
if (dotIndex === -1) return false
93-
const ext = name.slice(dotIndex).toLowerCase()
94-
return SUPPORTED_EXTENSIONS.has(ext)
95-
}
96-
97-
/**
98-
* Downloads the raw content of a OneDrive file.
99-
*/
100-
async function downloadFileContent(accessToken: string, fileId: string): Promise<string> {
82+
async function downloadFileContent(accessToken: string, fileId: string): Promise<Buffer> {
10183
const url = `${GRAPH_BASE_URL}/me/drive/items/${encodeURIComponent(fileId)}/content`
10284

10385
const response = await fetchWithRetry(url, {
@@ -114,25 +96,20 @@ async function downloadFileContent(accessToken: string, fileId: string): Promise
11496
if (!buffer) {
11597
throw new ConnectorFileTooLargeError(MAX_FILE_SIZE)
11698
}
117-
return buffer.toString('utf8')
99+
return buffer
118100
}
119101

120102
/**
121-
* Fetches file content, converting HTML to plain text when applicable.
103+
* Fetches a file and extracts its indexable text — a UTF-8 decode for text
104+
* formats, and the shared knowledge-base parsers for Office documents and PDFs.
122105
*/
123106
async function fetchFileContent(
124107
accessToken: string,
125108
fileId: string,
126109
fileName: string
127110
): Promise<string> {
128-
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
129-
const raw = await downloadFileContent(accessToken, fileId)
130-
131-
if (ext === '.html' || ext === '.htm') {
132-
return htmlToPlainText(raw)
133-
}
134-
135-
return raw
111+
const buffer = await downloadFileContent(accessToken, fileId)
112+
return extractConnectorText(buffer, fileName)
136113
}
137114

138115
/**
@@ -282,15 +259,39 @@ export const onedriveConnector: ConnectorConfig = {
282259
const items = data.value || []
283260

284261
const files: OneDriveItem[] = []
262+
/**
263+
* Extensions this connector cannot index, tallied per page. A folder of
264+
* unsupported files otherwise syncs as "success, 0 documents", which reads
265+
* exactly like a wrong folder path — the failure mode this log exists for.
266+
* Unsupported files are counted rather than turned into `failed` document
267+
* rows, so a drive full of images does not fill the knowledge base with noise.
268+
*/
269+
const skippedExtensions = new Map<string, number>()
270+
285271
for (const item of items) {
286272
if (item.folder) {
287273
state.folderStack.push(item.id)
288-
} else if (item.file && isSupportedTextFile(item.name)) {
289-
// Keep oversized files; they are surfaced as skipped (failed) docs below.
290-
files.push(item)
274+
} else if (item.file) {
275+
if (isIndexableConnectorFile(item.name)) {
276+
// Keep oversized files; they are surfaced as skipped (failed) docs below.
277+
files.push(item)
278+
} else {
279+
const extension = connectorFileExtension(item.name) ?? '(none)'
280+
skippedExtensions.set(extension, (skippedExtensions.get(extension) ?? 0) + 1)
281+
}
291282
}
292283
}
293284

285+
if (skippedExtensions.size > 0) {
286+
let skippedCount = 0
287+
for (const count of skippedExtensions.values()) skippedCount += count
288+
logger.info('Skipped OneDrive files with unsupported extensions', {
289+
folderId: state.currentFolder ?? 'root',
290+
skippedCount,
291+
extensions: Array.from(skippedExtensions.keys()).slice(0, MAX_LOGGED_SKIPPED_EXTENSIONS),
292+
})
293+
}
294+
294295
const stubs = files.map((item) =>
295296
stubOrSkipBySize(fileToStub(item), item.size, MAX_FILE_SIZE)
296297
)
@@ -373,7 +374,7 @@ export const onedriveConnector: ConnectorConfig = {
373374

374375
const item = (await response.json()) as OneDriveItem
375376

376-
if (!item.file || !isSupportedTextFile(item.name)) return null
377+
if (!item.file || !isIndexableConnectorFile(item.name)) return null
377378

378379
try {
379380
const content = await fetchFileContent(accessToken, item.id, item.name)
@@ -386,6 +387,13 @@ export const onedriveConnector: ConnectorConfig = {
386387
logger.info('Skipping oversized OneDrive file', { fileId: item.id, name: item.name })
387388
return markSkipped(fileToStub(item), sizeLimitSkipReason(error.limitBytes))
388389
}
390+
if (error instanceof ConnectorTextExtractionError) {
391+
logger.info('Skipping OneDrive file with no extractable text', {
392+
fileId: item.id,
393+
name: item.name,
394+
})
395+
return markSkipped(fileToStub(item), extractionFailedSkipReason(error.extension))
396+
}
389397
/**
390398
* A transport or Graph failure that survived `fetchWithRetry`. Returning
391399
* `null` would drop the file from the run with no `failed` row and no error

apps/sim/connectors/sharepoint/sharepoint.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() }))
6+
const { mockFetchWithRetry, mockParseBuffer } = vi.hoisted(() => ({
7+
mockFetchWithRetry: vi.fn(),
8+
mockParseBuffer: vi.fn(),
9+
}))
710

811
vi.mock('@/lib/knowledge/documents/utils', () => ({
912
fetchWithRetry: mockFetchWithRetry,
1013
VALIDATE_RETRY_OPTIONS: {},
1114
}))
15+
vi.mock('@/lib/file-parsers', () => ({ parseBuffer: mockParseBuffer }))
1216
vi.mock('@/components/icons', () => ({ MicrosoftSharepointIcon: () => null }))
1317

1418
import {
@@ -27,6 +31,8 @@ const POLICIES_DRIVE_ID = 'b!policies'
2731
interface GraphRoute {
2832
status?: number
2933
body?: unknown
34+
/** Serve `body` as bytes, for the `/content` endpoint the downloader reads. */
35+
raw?: boolean
3036
}
3137

3238
/** Folder-shaped drive item for children listings. */
@@ -49,6 +55,9 @@ function mockGraph(routes: Record<string, GraphRoute>) {
4955
status,
5056
json: async () => route.body,
5157
text: async () => JSON.stringify(route.body ?? {}),
58+
/** `readBodyWithLimit` falls back to this when there is no stream body. */
59+
arrayBuffer: async () =>
60+
Buffer.from(route.raw ? String(route.body ?? '') : JSON.stringify(route.body ?? {})),
5261
} as unknown as Response
5362
})
5463
return requested
@@ -411,6 +420,47 @@ describe('listDocuments', () => {
411420
expect(syncContext.listingCapped).toBeUndefined()
412421
})
413422

423+
/**
424+
* The reported failure: a document library of Office SOPs synced as
425+
* "success, 0 documents" because the listing filter accepted only plain text,
426+
* which is indistinguishable from a wrong folder path.
427+
*/
428+
it('lists Office documents and PDFs alongside text files', async () => {
429+
mockGraph(
430+
childrenRoute(DEFAULT_DRIVE_ID, null, [
431+
file('f1', 'Market Data SOP.docx'),
432+
file('f2', 'Vendor Contract.pdf'),
433+
file('f3', 'User List.xlsx'),
434+
file('f4', 'Overview.pptx'),
435+
file('f5', 'notes.txt'),
436+
])
437+
)
438+
439+
const result = await list(undefined, listContext())
440+
441+
expect(result.documents.map((doc) => doc.title)).toEqual([
442+
'Market Data SOP.docx',
443+
'Vendor Contract.pdf',
444+
'User List.xlsx',
445+
'Overview.pptx',
446+
'notes.txt',
447+
])
448+
})
449+
450+
it('still excludes files with no extractable text', async () => {
451+
mockGraph(
452+
childrenRoute(DEFAULT_DRIVE_ID, null, [
453+
file('f1', 'diagram.png'),
454+
file('f2', 'recording.mp4'),
455+
file('f3', 'notes.txt'),
456+
])
457+
)
458+
459+
const result = await list(undefined, listContext())
460+
461+
expect(result.documents.map((doc) => doc.externalId)).toEqual(['f3'])
462+
})
463+
414464
it('builds a metadata-only contentHash that getDocument can reproduce', async () => {
415465
mockGraph(childrenRoute(DEFAULT_DRIVE_ID, null, [file('f1', 'a.txt')]))
416466

@@ -421,6 +471,73 @@ describe('listDocuments', () => {
421471
})
422472
})
423473

474+
describe('getDocument content extraction', () => {
475+
function itemRoute(itemId: string, name: string) {
476+
return {
477+
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/items/${itemId}?$select=${ITEM_SELECT}`]: {
478+
body: file(itemId, name),
479+
},
480+
}
481+
}
482+
483+
/** The content endpoint is fetched directly, not through the JSON `graphGet`. */
484+
function contentRoute(itemId: string, body: string) {
485+
return {
486+
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/items/${itemId}/content`]: { body, raw: true },
487+
}
488+
}
489+
490+
function get(externalId: string) {
491+
return sharepointConnector.getDocument!(
492+
'token',
493+
{ siteUrl: SITE_URL },
494+
externalId,
495+
listContext()
496+
)
497+
}
498+
499+
it('indexes the parsed text of an Office document', async () => {
500+
mockGraph({ ...itemRoute('f1', 'SOP.docx'), ...contentRoute('f1', 'ignored') })
501+
mockParseBuffer.mockResolvedValue({
502+
content: 'Approved vendor list',
503+
metadata: { extractionMethod: 'mammoth' },
504+
})
505+
506+
const doc = await get('f1')
507+
508+
expect(doc?.content).toBe('Approved vendor list')
509+
expect(doc?.skippedReason).toBeUndefined()
510+
expect(doc?.contentDeferred).toBe(false)
511+
})
512+
513+
/**
514+
* A degraded extraction must become a visible `failed` row, not a silent drop
515+
* and not indexed placeholder text — the same treatment oversized files get.
516+
*/
517+
it('surfaces a degraded extraction as a skipped document with an actionable reason', async () => {
518+
mockGraph({ ...itemRoute('f2', 'Deck.ppt'), ...contentRoute('f2', 'ole2') })
519+
mockParseBuffer.mockResolvedValue({
520+
content: 'Unable to extract text from PowerPoint file.',
521+
metadata: { extractionMethod: 'fallback', degraded: true },
522+
})
523+
524+
const doc = await get('f2')
525+
526+
expect(doc?.content).toBe('')
527+
expect(doc?.skippedReason).toContain('PPTX')
528+
expect(doc?.externalId).toBe('f2')
529+
})
530+
531+
it('reads a text file without invoking a parser', async () => {
532+
mockGraph({ ...itemRoute('f3', 'notes.txt'), ...contentRoute('f3', 'plain notes') })
533+
534+
const doc = await get('f3')
535+
536+
expect(doc?.content).toBe('plain notes')
537+
expect(mockParseBuffer).not.toHaveBeenCalled()
538+
})
539+
})
540+
424541
describe('serverRelativePathFromUrl', () => {
425542
it('strips the site prefix from a site-scoped URL', () => {
426543
expect(

0 commit comments

Comments
 (0)