mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
fix(connectors): resolve SharePoint folder paths against the right document library (#6026)
Folder-scoped SharePoint connectors failed with "Folder not found" for folders that exist and are readable with the same credential, leaving whole-library sync as the only option. - resolve the target drive explicitly and thread it through listing, download and hydration, which previously hardcoded the site default - resolve folder paths in layers: byte-exact addressing first (unchanged), then a leading document-library name, then a normalized children walk that recovers names carrying non-breaking or invisible whitespace - accept a folder URL from the browser address bar - report the site, library, attempted path and existing folder names on failure instead of a bare "Folder not found" - document the expected folder path format in the connector schema
This commit is contained in:
@@ -22,7 +22,9 @@ export const sharepointConnectorMeta: ConnectorMeta = {
|
||||
id: 'folderPath',
|
||||
title: 'Folder Path',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. Documents/Reports (optional, defaults to root)',
|
||||
placeholder: 'e.g. Reports/2026 (optional, defaults to the whole library)',
|
||||
description:
|
||||
'Path relative to the document library root — omit a leading "Documents" or "Shared Documents". To target a different library, start the path with that library\'s name. You can also paste the folder URL from your browser\'s address bar.',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/knowledge/documents/utils', () => ({
|
||||
fetchWithRetry: mockFetchWithRetry,
|
||||
VALIDATE_RETRY_OPTIONS: {},
|
||||
}))
|
||||
vi.mock('@/components/icons', () => ({ MicrosoftSharepointIcon: () => null }))
|
||||
|
||||
import {
|
||||
normalizeSegment,
|
||||
resolveFolderTarget,
|
||||
serverRelativePathFromUrl,
|
||||
} from '@/connectors/sharepoint/sharepoint'
|
||||
|
||||
const GRAPH = 'https://graph.microsoft.com/v1.0'
|
||||
const SITE_ID = 'contoso.sharepoint.com,site-guid,web-guid'
|
||||
const SITE_URL = 'contoso.sharepoint.com'
|
||||
const DEFAULT_DRIVE_ID = 'b!default'
|
||||
const POLICIES_DRIVE_ID = 'b!policies'
|
||||
|
||||
interface GraphRoute {
|
||||
status?: number
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
/** Folder-shaped drive item for children listings. */
|
||||
function folder(id: string, name: string) {
|
||||
return { id, name, folder: { childCount: 0 } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a URL-keyed fake Graph. Any URL without a route replies 404, which is
|
||||
* what makes the "falls through to the next layer" assertions meaningful.
|
||||
*/
|
||||
function mockGraph(routes: Record<string, GraphRoute>) {
|
||||
const requested: string[] = []
|
||||
mockFetchWithRetry.mockImplementation(async (url: string) => {
|
||||
requested.push(url)
|
||||
const route = routes[url] ?? { status: 404 }
|
||||
const status = route.status ?? 200
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => route.body,
|
||||
text: async () => JSON.stringify(route.body ?? {}),
|
||||
} as unknown as Response
|
||||
})
|
||||
return requested
|
||||
}
|
||||
|
||||
const defaultDriveRoute = {
|
||||
[`${GRAPH}/sites/${SITE_ID}/drive?$select=id,name,webUrl`]: {
|
||||
body: {
|
||||
id: DEFAULT_DRIVE_ID,
|
||||
name: 'Documents',
|
||||
webUrl: 'https://contoso.sharepoint.com/Shared%20Documents',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const sitesDrivesRoute = {
|
||||
[`${GRAPH}/sites/${SITE_ID}/drives?$select=id,name,webUrl`]: {
|
||||
body: {
|
||||
value: [
|
||||
{
|
||||
id: DEFAULT_DRIVE_ID,
|
||||
name: 'Documents',
|
||||
webUrl: 'https://contoso.sharepoint.com/Shared%20Documents',
|
||||
},
|
||||
{
|
||||
id: POLICIES_DRIVE_ID,
|
||||
name: 'Policies',
|
||||
webUrl: 'https://contoso.sharepoint.com/Policies',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function rootChildren(driveId: string, items: unknown[]) {
|
||||
return {
|
||||
[`${GRAPH}/drives/${driveId}/root/children?$top=200&$select=id,name,folder`]: {
|
||||
body: { value: items },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resolve(folderPath?: string) {
|
||||
return resolveFolderTarget('token', SITE_ID, SITE_URL, 'Contoso', folderPath)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('resolveFolderTarget', () => {
|
||||
it('returns the default library root when no folder path is configured', async () => {
|
||||
const requested = mockGraph({ ...defaultDriveRoute })
|
||||
|
||||
await expect(resolve(undefined)).resolves.toEqual({
|
||||
driveId: DEFAULT_DRIVE_ID,
|
||||
driveName: 'Documents',
|
||||
})
|
||||
expect(requested.some((url) => url.includes('root:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves a top-level folder by exact path against the default library', async () => {
|
||||
const requested = mockGraph({
|
||||
...defaultDriveRoute,
|
||||
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/root:/00%20IWW%20Library`]: {
|
||||
body: folder('folder-1', '00 IWW Library'),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(resolve('00 IWW Library')).resolves.toEqual({
|
||||
driveId: DEFAULT_DRIVE_ID,
|
||||
driveName: 'Documents',
|
||||
folderId: 'folder-1',
|
||||
})
|
||||
expect(requested).toContain(`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/root:/00%20IWW%20Library`)
|
||||
})
|
||||
|
||||
it('ignores leading and trailing slashes', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/root:/00%20IWW%20Library`]: {
|
||||
body: folder('folder-1', '00 IWW Library'),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(resolve('/00 IWW Library/')).resolves.toMatchObject({ folderId: 'folder-1' })
|
||||
})
|
||||
|
||||
it('resolves a nested folder', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/root:/00%20IWW%20Library/Templates`]: {
|
||||
body: folder('folder-2', 'Templates'),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(resolve('00 IWW Library/Templates')).resolves.toMatchObject({
|
||||
folderId: 'folder-2',
|
||||
})
|
||||
})
|
||||
|
||||
it('strips a leading document-library name that is not a real folder', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
...sitesDrivesRoute,
|
||||
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/root:/00%20IWW%20Library`]: {
|
||||
body: folder('folder-1', '00 IWW Library'),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(resolve('Shared Documents/00 IWW Library')).resolves.toEqual({
|
||||
driveId: DEFAULT_DRIVE_ID,
|
||||
driveName: 'Documents',
|
||||
folderId: 'folder-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers a real folder named "Documents" over the library-name interpretation', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
...sitesDrivesRoute,
|
||||
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/root:/Documents/Reports`]: {
|
||||
body: folder('real-nested', 'Reports'),
|
||||
},
|
||||
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/root:/Reports`]: {
|
||||
body: folder('wrong-one', 'Reports'),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(resolve('Documents/Reports')).resolves.toMatchObject({
|
||||
folderId: 'real-nested',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a folder in a non-default document library', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
...sitesDrivesRoute,
|
||||
[`${GRAPH}/drives/${POLICIES_DRIVE_ID}/root:/HR`]: { body: folder('hr-1', 'HR') },
|
||||
})
|
||||
|
||||
await expect(resolve('Policies/HR')).resolves.toEqual({
|
||||
driveId: POLICIES_DRIVE_ID,
|
||||
driveName: 'Policies',
|
||||
folderId: 'hr-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a bare non-default library name to that library root', async () => {
|
||||
mockGraph({ ...defaultDriveRoute, ...sitesDrivesRoute })
|
||||
|
||||
await expect(resolve('Policies')).resolves.toEqual({
|
||||
driveId: POLICIES_DRIVE_ID,
|
||||
driveName: 'Policies',
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers a folder whose real name contains a non-breaking space', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
...sitesDrivesRoute,
|
||||
...rootChildren(DEFAULT_DRIVE_ID, [
|
||||
folder('other', 'Archive'),
|
||||
folder('folder-1', '00\u00a0IWW Library'),
|
||||
]),
|
||||
})
|
||||
|
||||
await expect(resolve('00 IWW Library')).resolves.toMatchObject({ folderId: 'folder-1' })
|
||||
})
|
||||
|
||||
it('recovers a folder that differs only by case', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
...sitesDrivesRoute,
|
||||
...rootChildren(DEFAULT_DRIVE_ID, [folder('folder-1', '00 iww library')]),
|
||||
})
|
||||
|
||||
await expect(resolve('00 IWW LIBRARY')).resolves.toMatchObject({ folderId: 'folder-1' })
|
||||
})
|
||||
|
||||
it('refuses to guess when two sibling folders normalize identically', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
...sitesDrivesRoute,
|
||||
...rootChildren(DEFAULT_DRIVE_ID, [
|
||||
folder('a', '00 IWW Library'),
|
||||
folder('b', '00\u00a0IWW Library'),
|
||||
]),
|
||||
})
|
||||
|
||||
await expect(resolve('00 IWW Library')).rejects.toThrow(/matches more than one folder/)
|
||||
})
|
||||
|
||||
it('rejects a path that resolves to a file', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/root:/notes.txt`]: {
|
||||
body: { id: 'f1', name: 'notes.txt', file: { mimeType: 'text/plain' } },
|
||||
},
|
||||
})
|
||||
|
||||
await expect(resolve('notes.txt')).rejects.toThrow(/is not a folder/)
|
||||
})
|
||||
|
||||
it('reports the site, library, path and existing folders when nothing matches', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
...sitesDrivesRoute,
|
||||
...rootChildren(DEFAULT_DRIVE_ID, [folder('a', 'Archive'), folder('b', 'Reports')]),
|
||||
})
|
||||
|
||||
await expect(resolve('00 IWW Library')).rejects.toThrow(
|
||||
/Folder not found: "00 IWW Library"[\s\S]*Contoso[\s\S]*Documents[\s\S]*"Archive", "Reports"/
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a failure to open the default library rather than reporting not-found', async () => {
|
||||
mockGraph({
|
||||
[`${GRAPH}/sites/${SITE_ID}/drive?$select=id,name,webUrl`]: { status: 403 },
|
||||
})
|
||||
|
||||
await expect(resolve('00 IWW Library')).rejects.toThrow(
|
||||
/Failed to open the default document library/
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts an address-bar folder URL carrying the path in the id parameter', async () => {
|
||||
mockGraph({
|
||||
...defaultDriveRoute,
|
||||
...sitesDrivesRoute,
|
||||
[`${GRAPH}/drives/${DEFAULT_DRIVE_ID}/root:/00%20IWW%20Library`]: {
|
||||
body: folder('folder-1', '00 IWW Library'),
|
||||
},
|
||||
})
|
||||
|
||||
const url =
|
||||
'https://contoso.sharepoint.com/Shared%20Documents/Forms/AllItems.aspx' +
|
||||
'?id=%2FShared%20Documents%2F00%20IWW%20Library&viewid=abc'
|
||||
|
||||
await expect(resolve(url)).resolves.toMatchObject({ folderId: 'folder-1' })
|
||||
})
|
||||
|
||||
it('rejects a tokenized sharing link with actionable guidance', async () => {
|
||||
mockGraph({ ...defaultDriveRoute })
|
||||
|
||||
await expect(resolve('https://contoso.sharepoint.com/:f:/s/hr/Ei4xAbC?e=xyz')).rejects.toThrow(
|
||||
/address bar/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serverRelativePathFromUrl', () => {
|
||||
it('strips the site prefix from a site-scoped URL', () => {
|
||||
expect(
|
||||
serverRelativePathFromUrl(
|
||||
'https://contoso.sharepoint.com/sites/hr/Shared%20Documents/Reports',
|
||||
'contoso.sharepoint.com/sites/hr'
|
||||
)
|
||||
).toEqual(['Shared Documents', 'Reports'])
|
||||
})
|
||||
|
||||
it('drops the Forms view suffix', () => {
|
||||
expect(
|
||||
serverRelativePathFromUrl(
|
||||
'https://contoso.sharepoint.com/Shared%20Documents/Forms/AllItems.aspx',
|
||||
'contoso.sharepoint.com'
|
||||
)
|
||||
).toEqual(['Shared Documents'])
|
||||
})
|
||||
|
||||
it('returns null for a tokenized sharing link', () => {
|
||||
expect(
|
||||
serverRelativePathFromUrl(
|
||||
'https://contoso.sharepoint.com/:f:/s/hr/Ei4xAbC',
|
||||
'contoso.sharepoint.com'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeSegment', () => {
|
||||
it('folds non-breaking spaces, repeated whitespace and case', () => {
|
||||
expect(normalizeSegment('00\u00a0IWW LIBRARY ')).toBe('00 iww library')
|
||||
})
|
||||
|
||||
it('removes zero-width characters', () => {
|
||||
expect(normalizeSegment('Report\u200bs')).toBe('reports')
|
||||
})
|
||||
|
||||
it('leaves an ordinary name unchanged apart from case', () => {
|
||||
expect(normalizeSegment('Reports')).toBe('reports')
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,27 @@ interface DriveItemListResponse {
|
||||
'@odata.nextLink'?: string
|
||||
}
|
||||
|
||||
/** Microsoft Graph drive (document library) shape (subset of fields we use). */
|
||||
interface Drive {
|
||||
id: string
|
||||
name?: string
|
||||
webUrl?: string
|
||||
}
|
||||
|
||||
interface DriveListResponse {
|
||||
value: Drive[]
|
||||
}
|
||||
|
||||
/** A configured folder path resolved to a concrete drive and starting folder. */
|
||||
interface ResolvedFolderTarget {
|
||||
driveId: string
|
||||
driveName: string
|
||||
/** Undefined when the sync starts at the drive root. */
|
||||
folderId?: string
|
||||
}
|
||||
|
||||
type RetryOptions = Parameters<typeof fetchWithRetry>[2]
|
||||
|
||||
/**
|
||||
* Returns true when the file extension is in the supported text set.
|
||||
*/
|
||||
@@ -66,36 +87,15 @@ function isSupportedTextFile(name: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a SharePoint site URL like "contoso.sharepoint.com/sites/mysite"
|
||||
* into a Microsoft Graph siteId.
|
||||
* Issues an authenticated Graph GET. Non-OK responses are returned as-is so
|
||||
* callers can distinguish 404 (not found) from a genuine failure.
|
||||
*/
|
||||
async function resolveSiteId(
|
||||
function graphGet(
|
||||
url: string,
|
||||
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(
|
||||
retryOptions?: RetryOptions
|
||||
): Promise<Response> {
|
||||
return fetchWithRetry(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -106,6 +106,42 @@ async function resolveSiteId(
|
||||
},
|
||||
retryOptions
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a SharePoint site URL into its hostname and server-relative site path,
|
||||
* e.g. "contoso.sharepoint.com/sites/hr" → { hostname, serverRelativePath: "/sites/hr" }.
|
||||
* A root site yields an empty server-relative path.
|
||||
*/
|
||||
function splitSiteUrl(siteUrl: string): { hostname: string; serverRelativePath: string } {
|
||||
const cleaned = siteUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
||||
const firstSlash = cleaned.indexOf('/')
|
||||
if (firstSlash === -1) {
|
||||
return { hostname: cleaned, serverRelativePath: '' }
|
||||
}
|
||||
return {
|
||||
hostname: cleaned.slice(0, firstSlash),
|
||||
serverRelativePath: cleaned.slice(firstSlash),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a SharePoint site URL like "contoso.sharepoint.com/sites/mysite"
|
||||
* into a Microsoft Graph siteId.
|
||||
*/
|
||||
async function resolveSiteId(
|
||||
accessToken: string,
|
||||
siteUrl: string,
|
||||
retryOptions?: RetryOptions
|
||||
): Promise<{ id: string; displayName: string }> {
|
||||
const { hostname, serverRelativePath } = splitSiteUrl(siteUrl)
|
||||
|
||||
// Graph endpoint: GET /sites/{hostname}:/{path}
|
||||
const url = serverRelativePath
|
||||
? `${GRAPH_BASE}/sites/${hostname}:${serverRelativePath}`
|
||||
: `${GRAPH_BASE}/sites/${hostname}`
|
||||
|
||||
const response = await graphGet(url, accessToken, retryOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -128,11 +164,11 @@ async function resolveSiteId(
|
||||
*/
|
||||
async function downloadFileContent(
|
||||
accessToken: string,
|
||||
siteId: string,
|
||||
driveId: string,
|
||||
itemId: string,
|
||||
fileName: string
|
||||
): Promise<string> {
|
||||
const url = `${GRAPH_BASE}/sites/${siteId}/drive/items/${itemId}/content`
|
||||
const url = `${GRAPH_BASE}/drives/${driveId}/items/${itemId}/content`
|
||||
|
||||
const response = await fetchWithRetry(url, {
|
||||
method: 'GET',
|
||||
@@ -159,11 +195,11 @@ async function downloadFileContent(
|
||||
*/
|
||||
async function fetchFileContent(
|
||||
accessToken: string,
|
||||
siteId: string,
|
||||
driveId: string,
|
||||
itemId: string,
|
||||
fileName: string
|
||||
): Promise<string> {
|
||||
const raw = await downloadFileContent(accessToken, siteId, itemId, fileName)
|
||||
const raw = await downloadFileContent(accessToken, driveId, itemId, fileName)
|
||||
if (fileName.toLowerCase().endsWith('.html') || fileName.toLowerCase().endsWith('.htm')) {
|
||||
return htmlToPlainText(raw)
|
||||
}
|
||||
@@ -194,28 +230,21 @@ function itemToStub(item: DriveItem, siteName: string): ExternalDocument {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists items in a folder. When folderId is omitted the root of the default
|
||||
* document library is listed.
|
||||
* Lists items in a folder. When folderId is omitted the root of the drive is listed.
|
||||
*/
|
||||
async function listFolderItems(
|
||||
accessToken: string,
|
||||
siteId: string,
|
||||
driveId: 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`)
|
||||
? `${GRAPH_BASE}/drives/${driveId}/items/${folderId}/children?$top=200`
|
||||
: `${GRAPH_BASE}/drives/${driveId}/root/children?$top=200`)
|
||||
|
||||
const response = await fetchWithRetry(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
const response = await graphGet(url, accessToken)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -226,43 +255,370 @@ async function listFolderItems(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a slash-separated folder path (e.g. "Documents/Reports") to a
|
||||
* DriveItem ID by walking the path segments from root.
|
||||
* Folder-path resolution is layered, and every layer after the first only runs
|
||||
* once the previous one has returned 404. The first layer is byte-exact path
|
||||
* addressing against the site's default document library — identical to the
|
||||
* behaviour that shipped before drive-aware resolution existed — so any
|
||||
* configuration that resolves today keeps resolving to the same item.
|
||||
*/
|
||||
async function resolveFolderPath(
|
||||
|
||||
/** Bounds the children walk so a pathological library cannot spin forever. */
|
||||
const MAX_CHILD_PAGES_PER_SEGMENT = 50
|
||||
|
||||
/** Number of sibling names quoted back in a "folder not found" error. */
|
||||
const MAX_SUGGESTED_NAMES = 25
|
||||
|
||||
/**
|
||||
* Folds away the differences that make a visually-correct folder name fail
|
||||
* byte-exact path addressing: Unicode composition, invisible characters, and
|
||||
* whitespace variants (a non-breaking space renders identically to a space).
|
||||
* Case is folded because SharePoint item names are themselves case-insensitive —
|
||||
* two siblings cannot differ by case alone, so this cannot merge distinct items.
|
||||
*/
|
||||
export function normalizeSegment(value: string): string {
|
||||
return value
|
||||
.normalize('NFC')
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** Splits a slash-separated path into non-empty, trimmed segments. */
|
||||
function toPathSegments(path: string): string[] {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function encodePathSegments(segments: string[]): string {
|
||||
return segments.map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a drive item by its path relative to the drive root. Returns null on
|
||||
* 404 so callers can fall through to the next resolution layer. An empty
|
||||
* segment list addresses the drive root itself.
|
||||
*/
|
||||
async function getItemByPath(
|
||||
accessToken: string,
|
||||
driveId: string,
|
||||
segments: string[],
|
||||
retryOptions?: RetryOptions
|
||||
): Promise<DriveItem | null> {
|
||||
const url =
|
||||
segments.length === 0
|
||||
? `${GRAPH_BASE}/drives/${driveId}/root`
|
||||
: `${GRAPH_BASE}/drives/${driveId}/root:/${encodePathSegments(segments)}`
|
||||
|
||||
const response = await graphGet(url, accessToken, retryOptions)
|
||||
|
||||
if (response.status === 404) return null
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve folder path: ${response.status}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as DriveItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every child folder of a drive item, following pagination.
|
||||
*/
|
||||
async function listChildFolders(
|
||||
accessToken: string,
|
||||
driveId: string,
|
||||
parentId: string | undefined,
|
||||
retryOptions?: RetryOptions
|
||||
): Promise<DriveItem[]> {
|
||||
const folders: DriveItem[] = []
|
||||
let url = parentId
|
||||
? `${GRAPH_BASE}/drives/${driveId}/items/${parentId}/children?$top=200&$select=id,name,folder`
|
||||
: `${GRAPH_BASE}/drives/${driveId}/root/children?$top=200&$select=id,name,folder`
|
||||
|
||||
for (let page = 0; page < MAX_CHILD_PAGES_PER_SEGMENT; page++) {
|
||||
const response = await graphGet(url, accessToken, retryOptions)
|
||||
if (response.status === 404) break
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list folder contents: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as DriveItemListResponse
|
||||
for (const item of data.value) {
|
||||
if (item.folder) folders.push(item)
|
||||
}
|
||||
|
||||
const nextLink = data['@odata.nextLink']
|
||||
if (!nextLink) break
|
||||
url = nextLink
|
||||
}
|
||||
|
||||
return folders
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a path segment by segment, matching each against the child folder names
|
||||
* under normalization. Returns null when a segment has no match. Throws when a
|
||||
* segment matches more than one sibling, rather than silently picking one.
|
||||
*/
|
||||
async function walkPathByChildren(
|
||||
accessToken: string,
|
||||
driveId: string,
|
||||
segments: string[],
|
||||
retryOptions?: RetryOptions
|
||||
): Promise<DriveItem | null> {
|
||||
let current: DriveItem | null = null
|
||||
|
||||
for (const segment of segments) {
|
||||
const children = await listChildFolders(accessToken, driveId, current?.id, retryOptions)
|
||||
const target = normalizeSegment(segment)
|
||||
const matches = children.filter((child) => normalizeSegment(child.name) === target)
|
||||
|
||||
if (matches.length === 0) return null
|
||||
if (matches.length > 1) {
|
||||
const names = matches.map((match) => `"${match.name}"`).join(', ')
|
||||
throw new Error(
|
||||
`Folder path segment "${segment}" matches more than one folder (${names}). Rename one of them or use a more specific path.`
|
||||
)
|
||||
}
|
||||
|
||||
current = matches[0]
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a document-library-relative path from a SharePoint URL. Handles both
|
||||
* an address-bar library URL and the "?id=" form that SharePoint produces when
|
||||
* copying a link to a folder. Returns null when the URL is a tokenized sharing
|
||||
* link, whose target can only be resolved through an endpoint requiring write
|
||||
* scopes this connector deliberately does not request.
|
||||
*/
|
||||
export function serverRelativePathFromUrl(rawUrl: string, siteUrl: string): string[] | null {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(rawUrl)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`"${rawUrl}" is not a valid URL. Enter a folder path relative to the document library, or paste the folder URL from your browser's address bar.`
|
||||
)
|
||||
}
|
||||
|
||||
if (/^\/:[a-z]:\//i.test(url.pathname)) return null
|
||||
|
||||
const idParam = url.searchParams.get('id')
|
||||
const rawPath = idParam ?? decodeURIComponent(url.pathname)
|
||||
|
||||
let segments = toPathSegments(rawPath)
|
||||
|
||||
const { serverRelativePath } = splitSiteUrl(siteUrl)
|
||||
const siteSegments = toPathSegments(serverRelativePath)
|
||||
const sitePrefixMatches = siteSegments.every(
|
||||
(segment, index) => normalizeSegment(segments[index] ?? '') === normalizeSegment(segment)
|
||||
)
|
||||
if (siteSegments.length > 0 && sitePrefixMatches) {
|
||||
segments = segments.slice(siteSegments.length)
|
||||
}
|
||||
|
||||
const formsIndex = segments.findIndex((segment) => normalizeSegment(segment) === 'forms')
|
||||
if (formsIndex !== -1) {
|
||||
segments = segments.slice(0, formsIndex)
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the configured folder path to a concrete drive and folder.
|
||||
*
|
||||
* Layers, each attempted only after the previous returned no match:
|
||||
* 1. Byte-exact path addressing against the site's default document library.
|
||||
* 2. The same path re-interpreted with a leading document-library name (the
|
||||
* library is addressed directly; the remainder is the drive-relative path).
|
||||
* 3. A normalized, segment-by-segment children walk of both candidates, which
|
||||
* recovers names carrying invisible or non-breaking whitespace.
|
||||
*/
|
||||
export async function resolveFolderTarget(
|
||||
accessToken: string,
|
||||
siteId: string,
|
||||
folderPath: string
|
||||
): Promise<string> {
|
||||
const cleaned = folderPath.replace(/^\/+|\/+$/g, '')
|
||||
if (!cleaned) {
|
||||
throw new Error('Folder path is empty after normalisation')
|
||||
siteUrl: string,
|
||||
siteName: string,
|
||||
rawFolderPath: string | undefined,
|
||||
retryOptions?: RetryOptions
|
||||
): Promise<ResolvedFolderTarget> {
|
||||
const defaultDriveResponse = await graphGet(
|
||||
`${GRAPH_BASE}/sites/${siteId}/drive?$select=id,name,webUrl`,
|
||||
accessToken,
|
||||
retryOptions
|
||||
)
|
||||
if (!defaultDriveResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to open the default document library for site "${siteUrl}": ${defaultDriveResponse.status}`
|
||||
)
|
||||
}
|
||||
const defaultDrive = (await defaultDriveResponse.json()) as Drive
|
||||
const defaultDriveName = defaultDrive.name || 'Documents'
|
||||
|
||||
const trimmed = rawFolderPath?.trim()
|
||||
if (!trimmed) {
|
||||
return { driveId: defaultDrive.id, driveName: defaultDriveName }
|
||||
}
|
||||
|
||||
const encoded = cleaned.split('/').map(encodeURIComponent).join('/')
|
||||
const url = `${GRAPH_BASE}/sites/${siteId}/drive/root:/${encoded}`
|
||||
let segments: string[]
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
const fromUrl = serverRelativePathFromUrl(trimmed, siteUrl)
|
||||
if (fromUrl === null) {
|
||||
throw new Error(
|
||||
'That is a SharePoint sharing link, which cannot be resolved with read-only access. Open the folder in SharePoint and paste the URL from the browser address bar instead, or enter the folder path relative to the document library.'
|
||||
)
|
||||
}
|
||||
segments = fromUrl
|
||||
} else {
|
||||
segments = toPathSegments(trimmed)
|
||||
}
|
||||
|
||||
const response = await fetchWithRetry(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
if (segments.length === 0) {
|
||||
return { driveId: defaultDrive.id, driveName: defaultDriveName }
|
||||
}
|
||||
|
||||
const exact = await getItemByPath(accessToken, defaultDrive.id, segments, retryOptions)
|
||||
if (exact) {
|
||||
if (!exact.folder) throw new Error(`Path "${trimmed}" is not a folder`)
|
||||
return { driveId: defaultDrive.id, driveName: defaultDriveName, folderId: exact.id }
|
||||
}
|
||||
|
||||
logger.info('SharePoint folder path did not resolve by exact path; trying fallbacks', {
|
||||
siteUrl,
|
||||
folderPath: trimmed,
|
||||
defaultLibrary: defaultDriveName,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Folder not found: "${folderPath}"`)
|
||||
const drives = await listSiteDrives(accessToken, siteId, retryOptions)
|
||||
const libraryMatch = drives.find((drive) => matchesDriveName(drive, segments[0]))
|
||||
|
||||
if (libraryMatch) {
|
||||
const remainder = segments.slice(1)
|
||||
const driveName = libraryMatch.name || segments[0]
|
||||
|
||||
if (remainder.length === 0) {
|
||||
return { driveId: libraryMatch.id, driveName }
|
||||
}
|
||||
|
||||
const inLibrary = await getItemByPath(accessToken, libraryMatch.id, remainder, retryOptions)
|
||||
if (inLibrary) {
|
||||
if (!inLibrary.folder) throw new Error(`Path "${trimmed}" is not a folder`)
|
||||
return { driveId: libraryMatch.id, driveName, folderId: inLibrary.id }
|
||||
}
|
||||
|
||||
const walkedInLibrary = await walkPathByChildren(
|
||||
accessToken,
|
||||
libraryMatch.id,
|
||||
remainder,
|
||||
retryOptions
|
||||
)
|
||||
if (walkedInLibrary) {
|
||||
return { driveId: libraryMatch.id, driveName, folderId: walkedInLibrary.id }
|
||||
}
|
||||
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`)
|
||||
const walked = await walkPathByChildren(accessToken, defaultDrive.id, segments, retryOptions)
|
||||
if (walked) {
|
||||
return { driveId: defaultDrive.id, driveName: defaultDriveName, folderId: walked.id }
|
||||
}
|
||||
|
||||
return item.id
|
||||
throw new Error(
|
||||
await buildFolderNotFoundMessage(
|
||||
accessToken,
|
||||
{ id: defaultDrive.id, name: defaultDriveName },
|
||||
siteName || siteUrl,
|
||||
trimmed,
|
||||
segments,
|
||||
drives,
|
||||
retryOptions
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Lists the site's document libraries. */
|
||||
async function listSiteDrives(
|
||||
accessToken: string,
|
||||
siteId: string,
|
||||
retryOptions?: RetryOptions
|
||||
): Promise<Drive[]> {
|
||||
const response = await graphGet(
|
||||
`${GRAPH_BASE}/sites/${siteId}/drives?$select=id,name,webUrl`,
|
||||
accessToken,
|
||||
retryOptions
|
||||
)
|
||||
if (!response.ok) return []
|
||||
const data = (await response.json()) as DriveListResponse
|
||||
return data.value ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a path segment against a document library by display name or by the
|
||||
* trailing segment of its URL, which is where "Shared Documents" lives for a
|
||||
* library displayed as "Documents".
|
||||
*/
|
||||
function matchesDriveName(drive: Drive, segment: string): boolean {
|
||||
const target = normalizeSegment(segment)
|
||||
if (drive.name && normalizeSegment(drive.name) === target) return true
|
||||
if (!drive.webUrl) return false
|
||||
const urlLeaf = drive.webUrl.split('/').filter(Boolean).pop()
|
||||
return Boolean(urlLeaf && normalizeSegment(decodeURIComponent(urlLeaf)) === target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a diagnostic failure message naming the site, the library searched,
|
||||
* the path attempted, and the folders that actually exist at that level.
|
||||
*/
|
||||
async function buildFolderNotFoundMessage(
|
||||
accessToken: string,
|
||||
drive: { id: string; name: string },
|
||||
siteName: string,
|
||||
rawFolderPath: string,
|
||||
segments: string[],
|
||||
drives: Drive[],
|
||||
retryOptions?: RetryOptions
|
||||
): Promise<string> {
|
||||
const parts = [
|
||||
`Folder not found: "${rawFolderPath}".`,
|
||||
`Searched site "${siteName}", document library "${drive.name}", for the library-relative path "${segments.join('/')}".`,
|
||||
]
|
||||
|
||||
try {
|
||||
const topLevel = await listChildFolders(accessToken, drive.id, undefined, retryOptions)
|
||||
if (topLevel.length > 0) {
|
||||
const names = topLevel
|
||||
.slice(0, MAX_SUGGESTED_NAMES)
|
||||
.map((item) => `"${item.name}"`)
|
||||
.join(', ')
|
||||
const suffix = topLevel.length > MAX_SUGGESTED_NAMES ? ', …' : ''
|
||||
parts.push(`Folders in "${drive.name}": ${names}${suffix}.`)
|
||||
} else {
|
||||
parts.push(`"${drive.name}" has no top-level folders.`)
|
||||
}
|
||||
} catch {
|
||||
parts.push(`Could not list the contents of "${drive.name}".`)
|
||||
}
|
||||
|
||||
if (drives.length > 1) {
|
||||
const libraryNames = drives
|
||||
.map((item) => item.name)
|
||||
.filter(Boolean)
|
||||
.map((name) => `"${name}"`)
|
||||
.join(', ')
|
||||
if (libraryNames) {
|
||||
parts.push(`Document libraries on this site: ${libraryNames}.`)
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(
|
||||
'The folder path is relative to the document library root, so a leading "Documents" or "Shared Documents" should be omitted unless a folder by that name really exists.'
|
||||
)
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,15 +670,26 @@ export const sharepointConnector: ConnectorConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve starting folder if configured (cache in syncContext)
|
||||
// Resolve the target library and starting folder (cache in syncContext)
|
||||
let driveId: string
|
||||
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
|
||||
if (syncContext?.driveId) {
|
||||
driveId = syncContext.driveId as string
|
||||
rootFolderId = syncContext.rootFolderId as string | undefined
|
||||
} else {
|
||||
const target = await resolveFolderTarget(
|
||||
accessToken,
|
||||
siteId,
|
||||
siteUrl,
|
||||
siteName,
|
||||
sourceConfig.folderPath as string | undefined
|
||||
)
|
||||
driveId = target.driveId
|
||||
rootFolderId = target.folderId
|
||||
if (syncContext) {
|
||||
syncContext.driveId = target.driveId
|
||||
syncContext.driveName = target.driveName
|
||||
syncContext.rootFolderId = target.folderId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +709,7 @@ export const sharepointConnector: ConnectorConfig = {
|
||||
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)
|
||||
const data = await listFolderItems(accessToken, driveId, state.currentFolder, state.nextLink)
|
||||
|
||||
// Separate files and subfolders
|
||||
const subfolders: string[] = []
|
||||
@@ -429,14 +796,29 @@ export const sharepointConnector: ConnectorConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${GRAPH_BASE}/sites/${siteId}/drive/items/${externalId}`
|
||||
const response = await fetchWithRetry(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
/**
|
||||
* `listDocuments` caches the resolved library on the shared syncContext, so
|
||||
* this only re-resolves when a document is hydrated outside a listing pass.
|
||||
*/
|
||||
let driveId = syncContext?.driveId as string | undefined
|
||||
if (!driveId) {
|
||||
const target = await resolveFolderTarget(
|
||||
accessToken,
|
||||
siteId,
|
||||
siteUrl,
|
||||
siteName ?? siteUrl,
|
||||
sourceConfig.folderPath as string | undefined
|
||||
)
|
||||
driveId = target.driveId
|
||||
if (syncContext) {
|
||||
syncContext.driveId = target.driveId
|
||||
syncContext.driveName = target.driveName
|
||||
syncContext.rootFolderId = target.folderId
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${GRAPH_BASE}/drives/${driveId}/items/${externalId}`
|
||||
const response = await graphGet(url, accessToken)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null
|
||||
@@ -450,7 +832,7 @@ export const sharepointConnector: ConnectorConfig = {
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fetchFileContent(accessToken, siteId, item.id, item.name)
|
||||
const content = await fetchFileContent(accessToken, driveId, item.id, item.name)
|
||||
if (!content.trim()) return null
|
||||
|
||||
const stub = itemToStub(item, siteName ?? siteUrl)
|
||||
@@ -486,41 +868,19 @@ export const sharepointConnector: ConnectorConfig = {
|
||||
|
||||
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` }
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resolves through the same layered path as a sync, so a configuration
|
||||
* accepted here is one the sync can actually open.
|
||||
*/
|
||||
await resolveFolderTarget(
|
||||
accessToken,
|
||||
site.id,
|
||||
siteUrl,
|
||||
site.displayName,
|
||||
sourceConfig.folderPath as string | undefined,
|
||||
VALIDATE_RETRY_OPTIONS
|
||||
)
|
||||
|
||||
return { valid: true }
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user