From 8e304fa02cd12772589ba5bcc300d051a5b3a64c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 16:10:26 -0700 Subject: [PATCH] fix(connectors): resolve SharePoint folder paths against the right document library 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 --- apps/sim/connectors/sharepoint/meta.ts | 4 +- .../connectors/sharepoint/sharepoint.test.ts | 343 ++++++++++ apps/sim/connectors/sharepoint/sharepoint.ts | 598 ++++++++++++++---- 3 files changed, 825 insertions(+), 120 deletions(-) create mode 100644 apps/sim/connectors/sharepoint/sharepoint.test.ts diff --git a/apps/sim/connectors/sharepoint/meta.ts b/apps/sim/connectors/sharepoint/meta.ts index 1ee33ef12a4..38dbff4e259 100644 --- a/apps/sim/connectors/sharepoint/meta.ts +++ b/apps/sim/connectors/sharepoint/meta.ts @@ -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, }, { diff --git a/apps/sim/connectors/sharepoint/sharepoint.test.ts b/apps/sim/connectors/sharepoint/sharepoint.test.ts new file mode 100644 index 00000000000..8a6fcc59e25 --- /dev/null +++ b/apps/sim/connectors/sharepoint/sharepoint.test.ts @@ -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) { + 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') + }) +}) diff --git a/apps/sim/connectors/sharepoint/sharepoint.ts b/apps/sim/connectors/sharepoint/sharepoint.ts index 8d476c9b28d..875276eac60 100644 --- a/apps/sim/connectors/sharepoint/sharepoint.ts +++ b/apps/sim/connectors/sharepoint/sharepoint.ts @@ -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[2] + /** * Returns true when the file extension is in the supported text set. */ @@ -65,6 +86,45 @@ function isSupportedTextFile(name: string): boolean { return SUPPORTED_TEXT_EXTENSIONS.has(name.slice(dotIndex).toLowerCase()) } +/** + * Issues an authenticated Graph GET. Non-OK responses are returned as-is so + * callers can distinguish 404 (not found) from a genuine failure. + */ +function graphGet( + url: string, + accessToken: string, + retryOptions?: RetryOptions +): Promise { + return fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }, + 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. @@ -72,40 +132,16 @@ function isSupportedTextFile(name: string): boolean { async function resolveSiteId( accessToken: string, siteUrl: string, - retryOptions?: Parameters[2] + retryOptions?: RetryOptions ): 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) - } + 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 fetchWithRetry( - url, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }, - retryOptions - ) + 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 { - 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 { - 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 { 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. + */ + +/** 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 resolveFolderPath( +async function getItemByPath( + accessToken: string, + driveId: string, + segments: string[], + retryOptions?: RetryOptions +): Promise { + 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 { + 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 { + 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 { - 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 { + 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 encoded = cleaned.split('/').map(encodeURIComponent).join('/') - const url = `${GRAPH_BASE}/sites/${siteId}/drive/root:/${encoded}` + const trimmed = rawFolderPath?.trim() + if (!trimmed) { + return { driveId: defaultDrive.id, driveName: defaultDriveName } + } - const response = await fetchWithRetry(url, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, + 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) + } + + 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 } + } + } + + const walked = await walkPathByChildren(accessToken, defaultDrive.id, segments, retryOptions) + if (walked) { + return { driveId: defaultDrive.id, driveName: defaultDriveName, folderId: walked.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 { + 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 { + 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.`) } - throw new Error(`Failed to resolve folder path "${folderPath}": ${response.status}`) + } catch { + parts.push(`Could not list the contents of "${drive.name}".`) } - const item = (await response.json()) as DriveItem - if (!item.folder) { - throw new Error(`Path "${folderPath}" is not a folder`) + 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}.`) + } } - return item.id + 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) {