mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(microsoft-excel): add SharePoint drive support for Excel integration (#4162)
* feat(microsoft-excel): add SharePoint drive support for Excel integration * fix(microsoft-excel): address PR review comments - Validate siteId/driveId format in drives route to prevent path traversal - Use direct single-drive endpoint for fetchById instead of filtering full list - Fix dependsOn on sheet/spreadsheet selectors so driveId flows into context - Fix NextRequest type in drives route for build compatibility * fix(microsoft-excel): validate driveId in files route Add regex validation for driveId query param in the Microsoft OAuth files route to prevent path traversal, matching the drives route. * fix(microsoft-excel): unblock OneDrive users and validate driveId in sheets route - Add credential to any[] arrays so OneDrive users (no drive selected) still pass the dependsOn gate while driveSelector remains in the dependency list for context flow to SharePoint users - Add /^[\w-]+$/ validation for driveId in sheets API route * fix(microsoft-excel): validate driveId in getItemBasePath utility Add regex validation for driveId at the shared utility level to prevent path traversal through the tool execution path, which bypasses the API route validators. * fix(microsoft-excel): use centralized input validation Replace inline regex validation with platform validators from @/lib/core/security/input-validation: - validateSharePointSiteId for siteId in drives route - validateAlphanumericId for driveId in drives, sheets, files routes and getItemBasePath utility * lint * improvement(microsoft-excel): add File Source dropdown to control SharePoint visibility Replace always-visible optional SharePoint fields with a File Source dropdown (OneDrive/SharePoint) that conditionally shows site and drive selectors. OneDrive users see zero extra fields (default). SharePoint users switch the dropdown and get the full cascade. * fix(microsoft-excel): fix canonical param test failures Make fileSource dropdown mode:'both' so it appears in basic and advanced modes. Add condition to manualDriveId to match driveSelector's condition, satisfying the canonical pair consistency test. * fix(microsoft-excel): address PR review feedback for SharePoint drive support - Clear stale driveId/siteId/spreadsheetId when fileSource changes by adding fileSource to dependsOn arrays for siteSelector, driveSelector, and spreadsheetId selectors - Reorder manualDriveId before manualSpreadsheetId in advanced mode for logical top-down flow - Validate spreadsheetId with validateMicrosoftGraphId in getItemBasePath() and sheets route to close injection vector (uses permissive validator that accepts ! chars in OneDrive item IDs) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(microsoft-excel): use validateMicrosoftGraphId for driveId validation SharePoint drive IDs use the format b!<base64-string> which contains ! characters rejected by validateAlphanumericId. Switch all driveId validation to validateMicrosoftGraphId which blocks path traversal and control characters while accepting valid Microsoft Graph identifiers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(microsoft-excel): use validatePathSegment with strict pattern for driveId/spreadsheetId Replace validateMicrosoftGraphId with validatePathSegment using a custom pattern ^[a-zA-Z0-9!_-]+$ for all URL-interpolated IDs. validatePathSegment blocks /, \, path traversal, and null bytes before checking the pattern, preventing URL-modifying characters like ?, #, & from altering the Graph API endpoint. The pattern allows ! for SharePoint b!<base64> drive IDs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * lint * fix(microsoft-excel): reorder driveId before spreadsheetId in v1 block Move driveId subBlock before manualSpreadsheetId in the legacy v1 block to match the logical top-down flow (Drive ID → Spreadsheet ID), consistent with the v2 block ordering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(microsoft-excel): clear manualDriveId when fileSource changes Add dependsOn: ['fileSource'] to manualDriveId so its value is cleared when switching from SharePoint back to OneDrive. Without this, the stale driveId would still be serialized and forwarded to getItemBasePath, routing through the SharePoint drive path instead of me/drive. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(microsoft-excel): use getItemBasePath in sheets route to remove duplication Replace inline URL construction and validation logic with the shared getItemBasePath utility, eliminating duplicated GRAPH_ID_PATTERN regex and conditional URL building. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * lint --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
61b33e5978
commit
80095788fc
@@ -45,6 +45,7 @@ Read data from a specific sheet in a Microsoft Excel spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook to read from \(e.g., "01ABC123DEF456"\) |
|
||||
| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. |
|
||||
| `range` | string | No | The range of cells to read from. Accepts "SheetName!A1:B2" for explicit ranges or just "SheetName" to read the used range of that sheet. If omitted, reads the used range of the first sheet. |
|
||||
|
||||
#### Output
|
||||
@@ -67,6 +68,7 @@ Write data to a specific sheet in a Microsoft Excel spreadsheet
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `spreadsheetId` | string | Yes | The ID of the spreadsheet/workbook to write to \(e.g., "01ABC123DEF456"\) |
|
||||
| `driveId` | string | No | The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive. |
|
||||
| `range` | string | No | The range of cells to write to \(e.g., "Sheet1!A1:B2"\) |
|
||||
| `values` | array | Yes | The data to write as a 2D array \(e.g., \[\["Name", "Age"\], \["Alice", 30\]\]\) or array of objects |
|
||||
| `valueInputOption` | string | No | The format of the data to write |
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validatePathSegment } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -19,6 +20,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const query = searchParams.get('query') || ''
|
||||
const driveId = searchParams.get('driveId') || undefined
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
@@ -72,8 +74,21 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
searchParams_new.append('$top', '50')
|
||||
|
||||
// When driveId is provided (SharePoint), search within that specific drive.
|
||||
// Otherwise, search the user's personal OneDrive.
|
||||
if (driveId) {
|
||||
const driveIdValidation = validatePathSegment(driveId, {
|
||||
paramName: 'driveId',
|
||||
customPattern: /^[a-zA-Z0-9!_-]+$/,
|
||||
})
|
||||
if (!driveIdValidation.isValid) {
|
||||
return NextResponse.json({ error: driveIdValidation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
const drivePath = driveId ? `drives/${driveId}` : 'me/drive'
|
||||
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`,
|
||||
`https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validatePathSegment, validateSharePointSiteId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('MicrosoftExcelDrivesAPI')
|
||||
|
||||
interface GraphDrive {
|
||||
id: string
|
||||
name: string
|
||||
driveType: string
|
||||
webUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* List document libraries (drives) for a SharePoint site.
|
||||
* Used by the microsoft.excel.drives selector to let users pick
|
||||
* which drive contains their Excel file.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, siteId, driveId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.warn(`[${requestId}] Missing credential in request`)
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!siteId) {
|
||||
logger.warn(`[${requestId}] Missing siteId in request`)
|
||||
return NextResponse.json({ error: 'Site ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const siteIdValidation = validateSharePointSiteId(siteId, 'siteId')
|
||||
if (!siteIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid siteId format`)
|
||||
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.warn(`[${requestId}] Failed to obtain valid access token`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to obtain valid access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Single-drive lookup when driveId is provided (used by fetchById)
|
||||
if (driveId) {
|
||||
const driveIdValidation = validatePathSegment(driveId, {
|
||||
paramName: 'driveId',
|
||||
customPattern: /^[a-zA-Z0-9!_-]+$/,
|
||||
})
|
||||
if (!driveIdValidation.isValid) {
|
||||
return NextResponse.json({ error: driveIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://graph.microsoft.com/v1.0/sites/${siteId}/drives/${driveId}?$select=id,name,driveType,webUrl`
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ error: { message: 'Unknown error' } }))
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error?.message || 'Failed to fetch drive' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data: GraphDrive = await response.json()
|
||||
return NextResponse.json(
|
||||
{ drive: { id: data.id, name: data.name, driveType: data.driveType } },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// List all drives for the site
|
||||
const url = `https://graph.microsoft.com/v1.0/sites/${siteId}/drives?$select=id,name,driveType,webUrl`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
|
||||
logger.error(`[${requestId}] Microsoft Graph API error fetching drives`, {
|
||||
status: response.status,
|
||||
error: errorData.error?.message,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error?.message || 'Failed to fetch drives' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const drives = (data.value || []).map((drive: GraphDrive) => ({
|
||||
id: drive.id,
|
||||
name: drive.name,
|
||||
driveType: drive.driveType,
|
||||
}))
|
||||
|
||||
logger.info(`[${requestId}] Successfully fetched ${drives.length} drives for site ${siteId}`)
|
||||
return NextResponse.json({ drives }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching drives`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getItemBasePath } from '@/tools/microsoft_excel/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -30,6 +31,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const spreadsheetId = searchParams.get('spreadsheetId')
|
||||
const driveId = searchParams.get('driveId') || undefined
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
@@ -61,17 +63,23 @@ export async function GET(request: NextRequest) {
|
||||
`[${requestId}] Fetching worksheets from Microsoft Graph API for workbook ${spreadsheetId}`
|
||||
)
|
||||
|
||||
// Fetch worksheets from Microsoft Graph API
|
||||
const worksheetsResponse = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
let basePath: string
|
||||
try {
|
||||
basePath = getItemBasePath(spreadsheetId, driveId)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Invalid parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const worksheetsResponse = await fetch(`${basePath}/workbook/worksheets`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!worksheetsResponse.ok) {
|
||||
const errorData = await worksheetsResponse
|
||||
|
||||
@@ -68,6 +68,13 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'driveId',
|
||||
title: 'Drive ID (SharePoint)',
|
||||
type: 'short-input',
|
||||
placeholder: 'Leave empty for OneDrive, or enter drive ID for SharePoint',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'manualSpreadsheetId',
|
||||
title: 'Spreadsheet ID',
|
||||
@@ -249,9 +256,17 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { oauthCredential, values, spreadsheetId, tableName, worksheetName, ...rest } = params
|
||||
const {
|
||||
oauthCredential,
|
||||
values,
|
||||
spreadsheetId,
|
||||
tableName,
|
||||
worksheetName,
|
||||
driveId,
|
||||
siteId: _siteId,
|
||||
...rest
|
||||
} = params
|
||||
|
||||
// Use canonical param ID (raw subBlock IDs are deleted after serialization)
|
||||
const effectiveSpreadsheetId = spreadsheetId ? String(spreadsheetId).trim() : ''
|
||||
|
||||
let parsedValues
|
||||
@@ -276,6 +291,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
const baseParams = {
|
||||
...rest,
|
||||
spreadsheetId: effectiveSpreadsheetId,
|
||||
driveId: driveId ? String(driveId).trim() : undefined,
|
||||
values: parsedValues,
|
||||
oauthCredential,
|
||||
}
|
||||
@@ -302,6 +318,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
oauthCredential: { type: 'string', description: 'Microsoft Excel access token' },
|
||||
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
|
||||
driveId: { type: 'string', description: 'Drive ID for SharePoint document libraries' },
|
||||
range: { type: 'string', description: 'Cell range' },
|
||||
tableName: { type: 'string', description: 'Table name' },
|
||||
worksheetName: { type: 'string', description: 'Worksheet name' },
|
||||
@@ -377,6 +394,47 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// File Source selector (both modes)
|
||||
{
|
||||
id: 'fileSource',
|
||||
title: 'File Source',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'OneDrive', id: 'onedrive' },
|
||||
{ label: 'SharePoint', id: 'sharepoint' },
|
||||
],
|
||||
value: () => 'onedrive',
|
||||
},
|
||||
// SharePoint Site Selector (basic mode, only when SharePoint is selected)
|
||||
{
|
||||
id: 'siteSelector',
|
||||
title: 'SharePoint Site',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'siteId',
|
||||
serviceId: 'sharepoint',
|
||||
selectorKey: 'sharepoint.sites',
|
||||
requiredScopes: [],
|
||||
placeholder: 'Select a SharePoint site',
|
||||
dependsOn: ['credential', 'fileSource'],
|
||||
condition: { field: 'fileSource', value: 'sharepoint' },
|
||||
required: { field: 'fileSource', value: 'sharepoint' },
|
||||
mode: 'basic',
|
||||
},
|
||||
// SharePoint Drive Selector (basic mode, only when SharePoint is selected)
|
||||
{
|
||||
id: 'driveSelector',
|
||||
title: 'Document Library',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'driveId',
|
||||
serviceId: 'microsoft-excel',
|
||||
selectorKey: 'microsoft.excel.drives',
|
||||
selectorAllowSearch: false,
|
||||
placeholder: 'Select a document library',
|
||||
dependsOn: ['credential', 'siteSelector', 'fileSource'],
|
||||
condition: { field: 'fileSource', value: 'sharepoint' },
|
||||
required: { field: 'fileSource', value: 'sharepoint' },
|
||||
mode: 'basic',
|
||||
},
|
||||
// Spreadsheet Selector (basic mode)
|
||||
{
|
||||
id: 'spreadsheetId',
|
||||
@@ -388,9 +446,20 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
|
||||
requiredScopes: [],
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
placeholder: 'Select a spreadsheet',
|
||||
dependsOn: ['credential'],
|
||||
dependsOn: { all: ['credential', 'fileSource'], any: ['credential', 'driveSelector'] },
|
||||
mode: 'basic',
|
||||
},
|
||||
// Drive ID for SharePoint (advanced mode, only when SharePoint is selected)
|
||||
{
|
||||
id: 'manualDriveId',
|
||||
title: 'Drive ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'driveId',
|
||||
placeholder: 'Enter the SharePoint drive ID',
|
||||
condition: { field: 'fileSource', value: 'sharepoint' },
|
||||
dependsOn: ['fileSource'],
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Manual Spreadsheet ID (advanced mode)
|
||||
{
|
||||
id: 'manualSpreadsheetId',
|
||||
@@ -398,7 +467,7 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'spreadsheetId',
|
||||
placeholder: 'Enter spreadsheet ID',
|
||||
dependsOn: ['credential'],
|
||||
dependsOn: { all: ['credential'], any: ['credential', 'manualDriveId'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Sheet Name Selector (basic mode)
|
||||
@@ -412,7 +481,10 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
|
||||
selectorAllowSearch: false,
|
||||
placeholder: 'Select a sheet',
|
||||
required: true,
|
||||
dependsOn: { all: ['credential'], any: ['spreadsheetId', 'manualSpreadsheetId'] },
|
||||
dependsOn: {
|
||||
all: ['credential'],
|
||||
any: ['spreadsheetId', 'manualSpreadsheetId', 'driveSelector'],
|
||||
},
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual Sheet Name (advanced mode)
|
||||
@@ -423,7 +495,10 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
|
||||
canonicalParamId: 'sheetName',
|
||||
placeholder: 'Name of the sheet/tab (e.g., Sheet1)',
|
||||
required: true,
|
||||
dependsOn: ['credential'],
|
||||
dependsOn: {
|
||||
all: ['credential'],
|
||||
any: ['credential', 'manualDriveId'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Cell Range (optional for read/write)
|
||||
@@ -514,11 +589,20 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
fallbackToolId: 'microsoft_excel_read_v2',
|
||||
}),
|
||||
params: (params) => {
|
||||
const { oauthCredential, values, spreadsheetId, sheetName, cellRange, ...rest } = params
|
||||
const {
|
||||
oauthCredential,
|
||||
values,
|
||||
spreadsheetId,
|
||||
sheetName,
|
||||
cellRange,
|
||||
driveId,
|
||||
siteId: _siteId,
|
||||
fileSource: _fileSource,
|
||||
...rest
|
||||
} = params
|
||||
|
||||
const parsedValues = values ? JSON.parse(values as string) : undefined
|
||||
|
||||
// Use canonical param IDs (raw subBlock IDs are deleted after serialization)
|
||||
const effectiveSpreadsheetId = spreadsheetId ? String(spreadsheetId).trim() : ''
|
||||
const effectiveSheetName = sheetName ? String(sheetName).trim() : ''
|
||||
|
||||
@@ -535,6 +619,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
spreadsheetId: effectiveSpreadsheetId,
|
||||
sheetName: effectiveSheetName,
|
||||
cellRange: cellRange ? (cellRange as string).trim() : undefined,
|
||||
driveId: driveId ? String(driveId).trim() : undefined,
|
||||
values: parsedValues,
|
||||
oauthCredential,
|
||||
}
|
||||
@@ -543,7 +628,10 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
fileSource: { type: 'string', description: 'File source (onedrive or sharepoint)' },
|
||||
oauthCredential: { type: 'string', description: 'Microsoft Excel access token' },
|
||||
siteId: { type: 'string', description: 'SharePoint site ID (used for drive/file browsing)' },
|
||||
driveId: { type: 'string', description: 'Drive ID for SharePoint document libraries' },
|
||||
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
|
||||
sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' },
|
||||
cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' },
|
||||
|
||||
@@ -1504,6 +1504,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
'microsoft.excel.sheets',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.spreadsheetId ?? 'none',
|
||||
context.driveId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
@@ -1517,6 +1518,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
searchParams: {
|
||||
credentialId,
|
||||
spreadsheetId: context.spreadsheetId,
|
||||
driveId: context.driveId,
|
||||
workflowId: context.workflowId,
|
||||
},
|
||||
}
|
||||
@@ -1527,6 +1529,54 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
}))
|
||||
},
|
||||
},
|
||||
'microsoft.excel.drives': {
|
||||
key: 'microsoft.excel.drives',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'microsoft.excel.drives',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.siteId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'microsoft.excel.drives')
|
||||
if (!context.siteId) {
|
||||
throw new Error('Missing site ID for microsoft.excel.drives selector')
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
siteId: context.siteId,
|
||||
})
|
||||
const data = await fetchJson<{ drives: { id: string; name: string }[] }>(
|
||||
'/api/tools/microsoft_excel/drives',
|
||||
{ method: 'POST', body }
|
||||
)
|
||||
return (data.drives || []).map((drive) => ({
|
||||
id: drive.id,
|
||||
label: drive.name,
|
||||
}))
|
||||
},
|
||||
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId || !context.siteId) return null
|
||||
const credentialId = ensureCredential(context, 'microsoft.excel.drives')
|
||||
const data = await fetchJson<{ drive: { id: string; name: string } }>(
|
||||
'/api/tools/microsoft_excel/drives',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
siteId: context.siteId,
|
||||
driveId: detailId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!data.drive) return null
|
||||
return { id: data.drive.id, label: data.drive.name }
|
||||
},
|
||||
},
|
||||
'microsoft.excel': {
|
||||
key: 'microsoft.excel',
|
||||
staleTime: SELECTOR_STALE,
|
||||
@@ -1534,6 +1584,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
'selectors',
|
||||
'microsoft.excel',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.driveId ?? 'none',
|
||||
search ?? '',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential),
|
||||
@@ -1545,6 +1596,7 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
searchParams: {
|
||||
credentialId,
|
||||
query: search,
|
||||
driveId: context.driveId,
|
||||
workflowId: context.workflowId,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export type SelectorKey =
|
||||
| 'onedrive.folders'
|
||||
| 'sharepoint.sites'
|
||||
| 'microsoft.excel'
|
||||
| 'microsoft.excel.drives'
|
||||
| 'microsoft.excel.sheets'
|
||||
| 'microsoft.word'
|
||||
| 'microsoft.planner'
|
||||
@@ -75,6 +76,7 @@ export interface SelectorContext {
|
||||
siteId?: string
|
||||
collectionId?: string
|
||||
spreadsheetId?: string
|
||||
driveId?: string
|
||||
excludeWorkflowId?: string
|
||||
baseId?: string
|
||||
datasetId?: string
|
||||
|
||||
@@ -17,6 +17,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
|
||||
'siteId',
|
||||
'collectionId',
|
||||
'spreadsheetId',
|
||||
'driveId',
|
||||
'fileId',
|
||||
'baseId',
|
||||
'datasetId',
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
MicrosoftExcelV2ToolParams,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import {
|
||||
getItemBasePath,
|
||||
getSpreadsheetWebUrl,
|
||||
trimTrailingEmptyRowsAndColumns,
|
||||
} from '@/tools/microsoft_excel/utils'
|
||||
@@ -35,6 +36,13 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the spreadsheet/workbook to read from (e.g., "01ABC123DEF456")',
|
||||
},
|
||||
driveId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
|
||||
},
|
||||
range: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
@@ -51,18 +59,17 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
|
||||
if (!params.range) {
|
||||
// When no range is provided, first fetch the first worksheet name (to avoid hardcoding "Sheet1")
|
||||
// We'll read its default range after in transformResponse
|
||||
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets?$select=name&$orderby=position&$top=1`
|
||||
return `${basePath}/workbook/worksheets?$select=name&$orderby=position&$top=1`
|
||||
}
|
||||
|
||||
const rangeInput = params.range.trim()
|
||||
|
||||
// If the input contains no '!', treat it as a sheet name only and fetch usedRange
|
||||
if (!rangeInput.includes('!')) {
|
||||
const sheetOnly = encodeURIComponent(rangeInput)
|
||||
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${sheetOnly}')/usedRange(valuesOnly=true)`
|
||||
return `${basePath}/workbook/worksheets('${sheetOnly}')/usedRange(valuesOnly=true)`
|
||||
}
|
||||
|
||||
const match = rangeInput.match(/^([^!]+)!(.+)$/)
|
||||
@@ -76,7 +83,7 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
const sheetName = encodeURIComponent(match[1])
|
||||
const address = encodeURIComponent(match[2])
|
||||
|
||||
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${sheetName}')/range(address='${address}')`
|
||||
return `${basePath}/workbook/worksheets('${sheetName}')/range(address='${address}')`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
@@ -91,6 +98,9 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelToolParams) => {
|
||||
const spreadsheetId = params?.spreadsheetId?.trim() || ''
|
||||
const driveId = params?.driveId
|
||||
|
||||
// If we came from the worksheets listing (no range provided), resolve first sheet name then fetch range
|
||||
if (response.url.includes('/workbook/worksheets?')) {
|
||||
const listData = await response.json()
|
||||
@@ -100,23 +110,19 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
throw new Error('No worksheets found in the Excel workbook')
|
||||
}
|
||||
|
||||
const spreadsheetIdFromUrl = response.url.split('/drive/items/')[1]?.split('/')[0] || ''
|
||||
const accessToken = params?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required to read Excel range')
|
||||
}
|
||||
|
||||
// Use usedRange(valuesOnly=true) to fetch only populated cells, avoiding thousands of empty rows
|
||||
const rangeUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(
|
||||
spreadsheetIdFromUrl
|
||||
)}/workbook/worksheets('${encodeURIComponent(firstSheetName)}')/usedRange(valuesOnly=true)`
|
||||
const basePath = getItemBasePath(spreadsheetId, driveId)
|
||||
const rangeUrl = `${basePath}/workbook/worksheets('${encodeURIComponent(firstSheetName)}')/usedRange(valuesOnly=true)`
|
||||
|
||||
const rangeResp = await fetch(rangeUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!rangeResp.ok) {
|
||||
// Normalize Microsoft Graph sheet/range errors to a friendly message
|
||||
throw new Error(
|
||||
'Invalid range provided or worksheet not found. Provide a range like "Sheet1!A1:B2" or just the sheet name to read the whole sheet'
|
||||
)
|
||||
@@ -124,20 +130,12 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
|
||||
const data = await rangeResp.json()
|
||||
|
||||
// usedRange returns an address (A1 notation) and values matrix
|
||||
const address: string = data.address || data.addressLocal || `${firstSheetName}!A1`
|
||||
const rawValues: ExcelCellValue[][] = data.values || []
|
||||
|
||||
const values = trimTrailingEmptyRowsAndColumns(rawValues)
|
||||
|
||||
// Fetch the browser-accessible web URL
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetIdFromUrl, accessToken)
|
||||
|
||||
const metadata = {
|
||||
spreadsheetId: spreadsheetIdFromUrl,
|
||||
properties: {},
|
||||
spreadsheetUrl: webUrl,
|
||||
}
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
|
||||
|
||||
const result: MicrosoftExcelReadResponse = {
|
||||
success: true,
|
||||
@@ -147,8 +145,8 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
values,
|
||||
},
|
||||
metadata: {
|
||||
spreadsheetId: metadata.spreadsheetId,
|
||||
spreadsheetUrl: metadata.spreadsheetUrl,
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -159,21 +157,11 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
// Normal path: caller supplied a range; just return the parsed result
|
||||
const data = await response.json()
|
||||
|
||||
const urlParts = response.url.split('/drive/items/')
|
||||
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
|
||||
|
||||
// Fetch the browser-accessible web URL
|
||||
const accessToken = params?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
|
||||
|
||||
const metadata = {
|
||||
spreadsheetId,
|
||||
properties: {},
|
||||
spreadsheetUrl: webUrl,
|
||||
}
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
|
||||
|
||||
const address: string = data.address || data.addressLocal || data.range || ''
|
||||
const rawValues: ExcelCellValue[][] = data.values || []
|
||||
@@ -187,8 +175,8 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
values,
|
||||
},
|
||||
metadata: {
|
||||
spreadsheetId: metadata.spreadsheetId,
|
||||
spreadsheetUrl: metadata.spreadsheetUrl,
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -240,6 +228,13 @@ export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the spreadsheet/workbook to read from (e.g., "01ABC123DEF456")',
|
||||
},
|
||||
driveId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
|
||||
},
|
||||
sheetName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
@@ -267,17 +262,17 @@ export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2
|
||||
throw new Error('Sheet name is required')
|
||||
}
|
||||
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
const encodedSheetName = encodeURIComponent(sheetName)
|
||||
|
||||
// If no cell range specified, fetch usedRange
|
||||
if (!params.cellRange) {
|
||||
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${encodedSheetName}')/usedRange(valuesOnly=true)`
|
||||
return `${basePath}/workbook/worksheets('${encodedSheetName}')/usedRange(valuesOnly=true)`
|
||||
}
|
||||
|
||||
const cellRange = params.cellRange.trim()
|
||||
const encodedAddress = encodeURIComponent(cellRange)
|
||||
|
||||
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${encodedSheetName}')/range(address='${encodedAddress}')`
|
||||
return `${basePath}/workbook/worksheets('${encodedSheetName}')/range(address='${encodedAddress}')`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
@@ -294,20 +289,19 @@ export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelV2ToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const urlParts = response.url.split('/drive/items/')
|
||||
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
|
||||
const spreadsheetId = params?.spreadsheetId?.trim() || ''
|
||||
const driveId = params?.driveId
|
||||
|
||||
const accessToken = params?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
|
||||
|
||||
const address: string = data.address || data.addressLocal || ''
|
||||
const rawValues: ExcelCellValue[][] = data.values || []
|
||||
const values = trimTrailingEmptyRowsAndColumns(rawValues)
|
||||
|
||||
// Extract sheet name from address (format: SheetName!A1:B2)
|
||||
const sheetName = params?.sheetName || address.split('!')[0] || ''
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
MicrosoftExcelTableAddResponse,
|
||||
MicrosoftExcelTableToolParams,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import { getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
|
||||
import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const tableAddTool: ToolConfig<
|
||||
@@ -33,6 +33,13 @@ export const tableAddTool: ToolConfig<
|
||||
description:
|
||||
'The ID of the spreadsheet/workbook containing the table (e.g., "01ABC123DEF456")',
|
||||
},
|
||||
driveId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
|
||||
},
|
||||
tableName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
@@ -51,7 +58,8 @@ export const tableAddTool: ToolConfig<
|
||||
request: {
|
||||
url: (params) => {
|
||||
const tableName = encodeURIComponent(params.tableName)
|
||||
return `https://graph.microsoft.com/v1.0/me/drive/items/${params.spreadsheetId}/workbook/tables('${tableName}')/rows/add`
|
||||
const basePath = getItemBasePath(params.spreadsheetId, params.driveId)
|
||||
return `${basePath}/workbook/tables('${tableName}')/rows/add`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
@@ -106,34 +114,26 @@ export const tableAddTool: ToolConfig<
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelTableToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const urlParts = response.url.split('/drive/items/')
|
||||
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
|
||||
const spreadsheetId = params?.spreadsheetId?.trim() || ''
|
||||
const driveId = params?.driveId
|
||||
|
||||
// Fetch the browser-accessible web URL
|
||||
const accessToken = params?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
|
||||
|
||||
const metadata = {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
}
|
||||
|
||||
const result = {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
index: data.index || 0,
|
||||
values: data.values || [],
|
||||
metadata: {
|
||||
spreadsheetId: metadata.spreadsheetId,
|
||||
spreadsheetUrl: metadata.spreadsheetUrl,
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
outputs: {
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface MicrosoftExcelWorksheetAddResponse extends ToolResponse {
|
||||
export interface MicrosoftExcelToolParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
range?: string
|
||||
values?: ExcelCellValue[][]
|
||||
valueInputOption?: 'RAW' | 'USER_ENTERED'
|
||||
@@ -75,6 +76,7 @@ export interface MicrosoftExcelToolParams {
|
||||
export interface MicrosoftExcelTableToolParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
tableName: string
|
||||
values: ExcelCellValue[][]
|
||||
rowIndex?: number
|
||||
@@ -83,6 +85,7 @@ export interface MicrosoftExcelTableToolParams {
|
||||
export interface MicrosoftExcelWorksheetToolParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
worksheetName: string
|
||||
}
|
||||
|
||||
@@ -96,6 +99,7 @@ export type MicrosoftExcelResponse =
|
||||
export interface MicrosoftExcelV2ToolParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
sheetName: string
|
||||
cellRange?: string
|
||||
values?: ExcelCellValue[][]
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { validatePathSegment } from '@/lib/core/security/input-validation'
|
||||
import type { ExcelCellValue } from '@/tools/microsoft_excel/types'
|
||||
|
||||
const logger = createLogger('MicrosoftExcelUtils')
|
||||
|
||||
/**
|
||||
* Returns the Graph API base path for an Excel item.
|
||||
* When driveId is provided, uses /drives/{driveId}/items/{itemId} (SharePoint/shared drives).
|
||||
* When driveId is omitted, uses /me/drive/items/{itemId} (personal OneDrive).
|
||||
*/
|
||||
/** Pattern for Microsoft Graph item/drive IDs: alphanumeric, hyphens, underscores, and ! (for SharePoint b!<base64> format) */
|
||||
const GRAPH_ID_PATTERN = /^[a-zA-Z0-9!_-]+$/
|
||||
|
||||
export function getItemBasePath(spreadsheetId: string, driveId?: string): string {
|
||||
const spreadsheetValidation = validatePathSegment(spreadsheetId, {
|
||||
paramName: 'spreadsheetId',
|
||||
customPattern: GRAPH_ID_PATTERN,
|
||||
})
|
||||
if (!spreadsheetValidation.isValid) {
|
||||
throw new Error(spreadsheetValidation.error)
|
||||
}
|
||||
|
||||
if (driveId) {
|
||||
const driveValidation = validatePathSegment(driveId, {
|
||||
paramName: 'driveId',
|
||||
customPattern: GRAPH_ID_PATTERN,
|
||||
})
|
||||
if (!driveValidation.isValid) {
|
||||
throw new Error(driveValidation.error)
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${spreadsheetId}`
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`
|
||||
}
|
||||
|
||||
export function trimTrailingEmptyRowsAndColumns(matrix: ExcelCellValue[][]): ExcelCellValue[][] {
|
||||
if (!Array.isArray(matrix) || matrix.length === 0) return []
|
||||
|
||||
@@ -43,33 +74,32 @@ export function trimTrailingEmptyRowsAndColumns(matrix: ExcelCellValue[][]): Exc
|
||||
*/
|
||||
export async function getSpreadsheetWebUrl(
|
||||
spreadsheetId: string,
|
||||
accessToken: string
|
||||
accessToken: string,
|
||||
driveId?: string
|
||||
): Promise<string> {
|
||||
const basePath = getItemBasePath(spreadsheetId, driveId)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}?$select=id,webUrl`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const response = await fetch(`${basePath}?$select=id,webUrl`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to fetch spreadsheet webUrl, using Graph API URL as fallback', {
|
||||
spreadsheetId,
|
||||
status: response.status,
|
||||
})
|
||||
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`
|
||||
return basePath
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.webUrl || `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`
|
||||
return data.webUrl || basePath
|
||||
} catch (error) {
|
||||
logger.warn('Error fetching spreadsheet webUrl, using Graph API URL as fallback', {
|
||||
spreadsheetId,
|
||||
error,
|
||||
})
|
||||
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`
|
||||
return basePath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
MicrosoftExcelWorksheetAddResponse,
|
||||
MicrosoftExcelWorksheetToolParams,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import { getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
|
||||
import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
@@ -36,6 +36,13 @@ export const worksheetAddTool: ToolConfig<
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the Excel workbook to add the worksheet to (e.g., "01ABC123DEF456")',
|
||||
},
|
||||
driveId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
|
||||
},
|
||||
worksheetName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
@@ -51,7 +58,8 @@ export const worksheetAddTool: ToolConfig<
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets/add`
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
return `${basePath}/workbook/worksheets/add`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
@@ -106,15 +114,14 @@ export const worksheetAddTool: ToolConfig<
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const urlParts = response.url.split('/drive/items/')
|
||||
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
|
||||
const spreadsheetId = params?.spreadsheetId?.trim() || ''
|
||||
const driveId = params?.driveId
|
||||
|
||||
// Fetch the browser-accessible web URL
|
||||
const accessToken = params?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
|
||||
|
||||
const result: MicrosoftExcelWorksheetAddResponse = {
|
||||
success: true,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
MicrosoftExcelV2WriteResponse,
|
||||
MicrosoftExcelWriteResponse,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import { getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
|
||||
import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWriteResponse> = {
|
||||
@@ -31,6 +31,13 @@ export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWrite
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the spreadsheet/workbook to write to (e.g., "01ABC123DEF456")',
|
||||
},
|
||||
driveId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
|
||||
},
|
||||
range: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
@@ -70,8 +77,9 @@ export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWrite
|
||||
const sheetName = encodeURIComponent(match[1])
|
||||
const address = encodeURIComponent(match[2])
|
||||
|
||||
const basePath = getItemBasePath(params.spreadsheetId!, params.driveId)
|
||||
const url = new URL(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/items/${params.spreadsheetId}/workbook/worksheets('${sheetName}')/range(address='${address}')`
|
||||
`${basePath}/workbook/worksheets('${sheetName}')/range(address='${address}')`
|
||||
)
|
||||
|
||||
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
|
||||
@@ -137,23 +145,16 @@ export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWrite
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const urlParts = response.url.split('/drive/items/')
|
||||
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
|
||||
const spreadsheetId = params?.spreadsheetId?.trim() || ''
|
||||
const driveId = params?.driveId
|
||||
|
||||
// Fetch the browser-accessible web URL
|
||||
const accessToken = params?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
|
||||
|
||||
const metadata = {
|
||||
spreadsheetId,
|
||||
properties: {},
|
||||
spreadsheetUrl: webUrl,
|
||||
}
|
||||
|
||||
const result = {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updatedRange: data.updatedRange,
|
||||
@@ -161,13 +162,11 @@ export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWrite
|
||||
updatedColumns: data.updatedColumns,
|
||||
updatedCells: data.updatedCells,
|
||||
metadata: {
|
||||
spreadsheetId: metadata.spreadsheetId,
|
||||
spreadsheetUrl: metadata.spreadsheetUrl,
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
outputs: {
|
||||
@@ -210,6 +209,13 @@ export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the spreadsheet/workbook to write to (e.g., "01ABC123DEF456")',
|
||||
},
|
||||
driveId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the drive containing the spreadsheet. Required for SharePoint files. If omitted, uses personal OneDrive.',
|
||||
},
|
||||
sheetName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
@@ -260,8 +266,9 @@ export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV
|
||||
const encodedSheetName = encodeURIComponent(sheetName)
|
||||
const encodedAddress = encodeURIComponent(cellRange)
|
||||
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
const url = new URL(
|
||||
`https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${encodedSheetName}')/range(address='${encodedAddress}')`
|
||||
`${basePath}/workbook/worksheets('${encodedSheetName}')/range(address='${encodedAddress}')`
|
||||
)
|
||||
|
||||
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
|
||||
@@ -324,14 +331,14 @@ export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelV2ToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const urlParts = response.url.split('/drive/items/')
|
||||
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
|
||||
const spreadsheetId = params?.spreadsheetId?.trim() || ''
|
||||
const driveId = params?.driveId
|
||||
|
||||
const accessToken = params?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken)
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
Reference in New Issue
Block a user