chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelClearRangeParams,
|
||||
MicrosoftExcelClearRangeResponse,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import {
|
||||
buildWorksheetRangeUrl,
|
||||
getItemBasePath,
|
||||
getSpreadsheetWebUrl,
|
||||
} from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Clears the contents and/or formatting of a worksheet range.
|
||||
* Uses Microsoft Graph: POST /workbook/worksheets/{name}/range(address='...')/clear
|
||||
*/
|
||||
export const clearRangeTool: ToolConfig<
|
||||
MicrosoftExcelClearRangeParams,
|
||||
MicrosoftExcelClearRangeResponse
|
||||
> = {
|
||||
id: 'microsoft_excel_clear_range',
|
||||
name: 'Clear Microsoft Excel Range',
|
||||
description: 'Clear the values and/or formatting of a range in a Microsoft Excel worksheet',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the spreadsheet/workbook (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: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The name of the worksheet (e.g., "Sheet1"). If omitted, the range must use the combined "Sheet1!A1:B2" format.',
|
||||
},
|
||||
range: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The cell range to clear (e.g., "A1:D10" or "Sheet1!A1:D10")',
|
||||
},
|
||||
applyTo: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'What to clear: "All", "Formats", or "Contents". Defaults to "All".',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
return `${buildWorksheetRangeUrl(basePath, params.range, params.sheetName)}/clear`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => ({
|
||||
applyTo: params.applyTo || 'All',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (_response: Response, params?: MicrosoftExcelClearRangeParams) => {
|
||||
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, driveId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
cleared: true,
|
||||
range: params?.range ?? '',
|
||||
applyTo: params?.applyTo || 'All',
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
cleared: { type: 'boolean', description: 'Whether the range was cleared' },
|
||||
range: { type: 'string', description: 'The range that was cleared' },
|
||||
applyTo: { type: 'string', description: 'What was cleared (All, Formats, or Contents)' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Spreadsheet metadata',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The ID of the spreadsheet' },
|
||||
spreadsheetUrl: { type: 'string', description: 'URL to access the spreadsheet' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelCreateTableParams,
|
||||
MicrosoftExcelCreateTableResponse,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Creates a new table over a range of cells.
|
||||
* Uses Microsoft Graph: POST /workbook/tables/add with { address, hasHeaders }.
|
||||
*/
|
||||
export const createTableTool: ToolConfig<
|
||||
MicrosoftExcelCreateTableParams,
|
||||
MicrosoftExcelCreateTableResponse
|
||||
> = {
|
||||
id: 'microsoft_excel_create_table',
|
||||
name: 'Create Microsoft Excel Table',
|
||||
description: 'Create a new table over a range of cells in a Microsoft Excel workbook',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the spreadsheet/workbook (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.',
|
||||
},
|
||||
address: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The range address for the table data source (e.g., "Sheet1!A1:D5"). If no sheet name is included, the active sheet is used.',
|
||||
},
|
||||
hasHeaders: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether the first row of the range contains column headers. Defaults to true.',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
return `${basePath}/workbook/tables/add`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
const address = params.address?.trim()
|
||||
if (!address) {
|
||||
throw new Error('A range address is required (e.g., "Sheet1!A1:D5")')
|
||||
}
|
||||
return {
|
||||
address,
|
||||
hasHeaders: params.hasHeaders ?? true,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelCreateTableParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
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, driveId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
table: {
|
||||
id: data.id ?? '',
|
||||
name: data.name ?? '',
|
||||
showHeaders: data.showHeaders ?? true,
|
||||
showTotals: data.showTotals ?? false,
|
||||
style: data.style ?? null,
|
||||
},
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
table: {
|
||||
type: 'object',
|
||||
description: 'Details of the newly created table',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'The unique ID of the table' },
|
||||
name: { type: 'string', description: 'The name of the table' },
|
||||
showHeaders: { type: 'boolean', description: 'Whether the header row is shown' },
|
||||
showTotals: { type: 'boolean', description: 'Whether the totals row is shown' },
|
||||
style: { type: 'string', description: 'The table style name' },
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Spreadsheet metadata',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The ID of the spreadsheet' },
|
||||
spreadsheetUrl: { type: 'string', description: 'URL to access the spreadsheet' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelDeleteWorksheetParams,
|
||||
MicrosoftExcelDeleteWorksheetResponse,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import {
|
||||
escapeODataString,
|
||||
getItemBasePath,
|
||||
getSpreadsheetWebUrl,
|
||||
} from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Deletes a worksheet from a workbook.
|
||||
* Uses Microsoft Graph: DELETE /workbook/worksheets/{name}
|
||||
*/
|
||||
export const deleteWorksheetTool: ToolConfig<
|
||||
MicrosoftExcelDeleteWorksheetParams,
|
||||
MicrosoftExcelDeleteWorksheetResponse
|
||||
> = {
|
||||
id: 'microsoft_excel_delete_worksheet',
|
||||
name: 'Delete Microsoft Excel Worksheet',
|
||||
description: 'Delete a worksheet (sheet) from a Microsoft Excel workbook',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the spreadsheet/workbook (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,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the worksheet to delete (e.g., "Sheet1", "Old Data")',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
const worksheetName = params.worksheetName?.trim()
|
||||
if (!worksheetName) {
|
||||
throw new Error('Worksheet name is required')
|
||||
}
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
return `${basePath}/workbook/worksheets('${encodeURIComponent(escapeODataString(worksheetName))}')`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (_response: Response, params?: MicrosoftExcelDeleteWorksheetParams) => {
|
||||
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, driveId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
deleted: true,
|
||||
worksheetName: params?.worksheetName?.trim() ?? '',
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
deleted: { type: 'boolean', description: 'Whether the worksheet was deleted' },
|
||||
worksheetName: { type: 'string', description: 'The name of the deleted worksheet' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Spreadsheet metadata',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The ID of the spreadsheet' },
|
||||
spreadsheetUrl: { type: 'string', description: 'URL to access the spreadsheet' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelFormatRangeParams,
|
||||
MicrosoftExcelFormatRangeResponse,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import {
|
||||
buildWorksheetRangeUrl,
|
||||
getItemBasePath,
|
||||
getSpreadsheetWebUrl,
|
||||
parseGraphErrorMessage,
|
||||
} from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Builds the font PATCH body from the provided font params, omitting any unset fields.
|
||||
* Returns null when no font property was supplied.
|
||||
*/
|
||||
function buildFontBody(params: MicrosoftExcelFormatRangeParams): Record<string, unknown> | null {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (params.fontBold !== undefined) body.bold = params.fontBold
|
||||
if (params.fontItalic !== undefined) body.italic = params.fontItalic
|
||||
if (params.fontColor) body.color = params.fontColor
|
||||
if (params.fontSize !== undefined) body.size = params.fontSize
|
||||
if (params.fontName) body.name = params.fontName
|
||||
return Object.keys(body).length > 0 ? body : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a worksheet range by applying fill color and/or font properties.
|
||||
* Uses Microsoft Graph PATCH on range(...)/format/fill and range(...)/format/font.
|
||||
* The font update is the primary request; a fill update (when also requested) runs
|
||||
* as a follow-up call so a single tool invocation can set both.
|
||||
*/
|
||||
export const formatRangeTool: ToolConfig<
|
||||
MicrosoftExcelFormatRangeParams,
|
||||
MicrosoftExcelFormatRangeResponse
|
||||
> = {
|
||||
id: 'microsoft_excel_format_range',
|
||||
name: 'Format Microsoft Excel Range',
|
||||
description: 'Apply fill color and/or font formatting to a range in a Microsoft Excel worksheet',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the spreadsheet/workbook (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: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The name of the worksheet (e.g., "Sheet1"). If omitted, the range must use the combined "Sheet1!A1:B2" format.',
|
||||
},
|
||||
range: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The cell range to format (e.g., "A1:D10" or "Sheet1!A1:D10")',
|
||||
},
|
||||
fillColor: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Background fill color as an HTML hex code (e.g., "#FFFF00").',
|
||||
},
|
||||
fontBold: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether the font is bold.',
|
||||
},
|
||||
fontItalic: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether the font is italic.',
|
||||
},
|
||||
fontColor: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Font color as an HTML hex code (e.g., "#FF0000").',
|
||||
},
|
||||
fontSize: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Font size in points (e.g., 12).',
|
||||
},
|
||||
fontName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Font name (e.g., "Calibri").',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
|
||||
const fontBody = buildFontBody(params)
|
||||
const hasFill = Boolean(params.fillColor)
|
||||
if (!fontBody && !hasFill) {
|
||||
throw new Error('Provide at least a fill color or a font property to format the range')
|
||||
}
|
||||
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
const rangeUrl = buildWorksheetRangeUrl(basePath, params.range, params.sheetName)
|
||||
return fontBody ? `${rangeUrl}/format/font` : `${rangeUrl}/format/fill`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
const fontBody = buildFontBody(params)
|
||||
if (fontBody) return fontBody
|
||||
return { color: params.fillColor }
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelFormatRangeParams) => {
|
||||
if (!params) {
|
||||
throw new Error('Format parameters are required')
|
||||
}
|
||||
const accessToken = params.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
|
||||
const spreadsheetId = params.spreadsheetId?.trim() || ''
|
||||
const driveId = params.driveId
|
||||
|
||||
const fontBody = buildFontBody(params)
|
||||
const hasFill = Boolean(params.fillColor)
|
||||
|
||||
let fontResult: Record<string, unknown> | null = null
|
||||
let fillApplied = false
|
||||
|
||||
if (fontBody) {
|
||||
fontResult = await response.json().catch(() => null)
|
||||
|
||||
if (hasFill) {
|
||||
const basePath = getItemBasePath(spreadsheetId, driveId)
|
||||
const fillUrl = `${buildWorksheetRangeUrl(basePath, params.range, params.sheetName)}/format/fill`
|
||||
const fillResp = await fetch(fillUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ color: params.fillColor }),
|
||||
})
|
||||
if (!fillResp.ok) {
|
||||
const errorText = await fillResp.text().catch(() => '')
|
||||
const detail = parseGraphErrorMessage(fillResp.status, fillResp.statusText, errorText)
|
||||
throw new Error(
|
||||
`Font formatting was applied, but the fill color update failed: ${detail}. Re-run with only the fill color to finish.`
|
||||
)
|
||||
}
|
||||
fillApplied = true
|
||||
}
|
||||
} else if (hasFill) {
|
||||
fillApplied = true
|
||||
}
|
||||
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
formatted: true,
|
||||
range: params.range ?? '',
|
||||
fill: fillApplied ? { color: params.fillColor ?? null } : null,
|
||||
font: fontBody
|
||||
? {
|
||||
bold: (fontResult?.bold as boolean | undefined) ?? params.fontBold ?? null,
|
||||
italic: (fontResult?.italic as boolean | undefined) ?? params.fontItalic ?? null,
|
||||
color: (fontResult?.color as string | undefined) ?? params.fontColor ?? null,
|
||||
name: (fontResult?.name as string | undefined) ?? params.fontName ?? null,
|
||||
size: (fontResult?.size as number | undefined) ?? params.fontSize ?? null,
|
||||
}
|
||||
: null,
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
formatted: { type: 'boolean', description: 'Whether the formatting was applied' },
|
||||
range: { type: 'string', description: 'The range that was formatted' },
|
||||
fill: {
|
||||
type: 'object',
|
||||
description: 'The applied fill, or null if no fill was set',
|
||||
properties: {
|
||||
color: { type: 'string', description: 'The applied fill color' },
|
||||
},
|
||||
},
|
||||
font: {
|
||||
type: 'object',
|
||||
description: 'The applied font properties, or null if no font was set',
|
||||
properties: {
|
||||
bold: { type: 'boolean', description: 'Whether the font is bold' },
|
||||
italic: { type: 'boolean', description: 'Whether the font is italic' },
|
||||
color: { type: 'string', description: 'The font color' },
|
||||
name: { type: 'string', description: 'The font name' },
|
||||
size: { type: 'number', description: 'The font size in points' },
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Spreadsheet metadata',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The ID of the spreadsheet' },
|
||||
spreadsheetUrl: { type: 'string', description: 'URL to access the spreadsheet' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { clearRangeTool } from '@/tools/microsoft_excel/clear_range'
|
||||
import { createTableTool } from '@/tools/microsoft_excel/create_table'
|
||||
import { deleteWorksheetTool } from '@/tools/microsoft_excel/delete_worksheet'
|
||||
import { formatRangeTool } from '@/tools/microsoft_excel/format_range'
|
||||
import { readTool, readV2Tool } from '@/tools/microsoft_excel/read'
|
||||
import { sortRangeTool } from '@/tools/microsoft_excel/sort_range'
|
||||
import { tableAddTool } from '@/tools/microsoft_excel/table_add'
|
||||
import { worksheetAddTool } from '@/tools/microsoft_excel/worksheet_add'
|
||||
import { writeTool, writeV2Tool } from '@/tools/microsoft_excel/write'
|
||||
|
||||
// V1 exports
|
||||
export const microsoftExcelReadTool = readTool
|
||||
export const microsoftExcelTableAddTool = tableAddTool
|
||||
export const microsoftExcelWorksheetAddTool = worksheetAddTool
|
||||
export const microsoftExcelWriteTool = writeTool
|
||||
|
||||
// V2 exports
|
||||
export const microsoftExcelReadV2Tool = readV2Tool
|
||||
export const microsoftExcelWriteV2Tool = writeV2Tool
|
||||
|
||||
// Workbook operations
|
||||
export const microsoftExcelClearRangeTool = clearRangeTool
|
||||
export const microsoftExcelCreateTableTool = createTableTool
|
||||
export const microsoftExcelDeleteWorksheetTool = deleteWorksheetTool
|
||||
export const microsoftExcelFormatRangeTool = formatRangeTool
|
||||
export const microsoftExcelSortRangeTool = sortRangeTool
|
||||
|
||||
export * from './types'
|
||||
@@ -0,0 +1,351 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
ExcelCellValue,
|
||||
MicrosoftExcelReadResponse,
|
||||
MicrosoftExcelToolParams,
|
||||
MicrosoftExcelV2ReadResponse,
|
||||
MicrosoftExcelV2ToolParams,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import {
|
||||
escapeODataString,
|
||||
getItemBasePath,
|
||||
getSpreadsheetWebUrl,
|
||||
parseGraphErrorMessage,
|
||||
trimTrailingEmptyRowsAndColumns,
|
||||
} from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const EXCEL_RETRY_CONFIG = {
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 30000,
|
||||
retryIdempotentOnly: true,
|
||||
} as const
|
||||
|
||||
export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadResponse> = {
|
||||
id: 'microsoft_excel_read',
|
||||
name: 'Read from Microsoft Excel',
|
||||
description: 'Read data from a Microsoft Excel spreadsheet',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
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,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'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.',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
|
||||
if (!params.range) {
|
||||
return `${basePath}/workbook/worksheets?$select=name&$orderby=position&$top=1`
|
||||
}
|
||||
|
||||
const rangeInput = params.range.trim()
|
||||
|
||||
if (!rangeInput.includes('!')) {
|
||||
const sheetOnly = encodeURIComponent(escapeODataString(rangeInput))
|
||||
return `${basePath}/workbook/worksheets('${sheetOnly}')/usedRange(valuesOnly=true)`
|
||||
}
|
||||
|
||||
const match = rangeInput.match(/^([^!]+)!(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid range format: "${params.range}". Use "Sheet1!A1:B2" or just "Sheet1" to read the whole sheet`
|
||||
)
|
||||
}
|
||||
|
||||
const sheetName = encodeURIComponent(escapeODataString(match[1]))
|
||||
const address = encodeURIComponent(match[2])
|
||||
|
||||
return `${basePath}/workbook/worksheets('${sheetName}')/range(address='${address}')`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
retry: EXCEL_RETRY_CONFIG,
|
||||
},
|
||||
|
||||
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()
|
||||
const firstSheetName: string | undefined = listData?.value?.[0]?.name
|
||||
|
||||
if (!firstSheetName) {
|
||||
throw new Error('No worksheets found in the Excel workbook')
|
||||
}
|
||||
|
||||
const accessToken = params?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required to read Excel range')
|
||||
}
|
||||
|
||||
const basePath = getItemBasePath(spreadsheetId, driveId)
|
||||
const rangeUrl = `${basePath}/workbook/worksheets('${encodeURIComponent(escapeODataString(firstSheetName))}')/usedRange(valuesOnly=true)`
|
||||
|
||||
const rangeResp = await fetch(rangeUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!rangeResp.ok) {
|
||||
const errorText = await rangeResp.text().catch(() => '')
|
||||
const detail = parseGraphErrorMessage(rangeResp.status, rangeResp.statusText, errorText)
|
||||
throw new Error(
|
||||
`Failed to read worksheet "${firstSheetName}": ${detail}. Provide a range like "Sheet1!A1:B2" or just the sheet name to read the whole sheet.`
|
||||
)
|
||||
}
|
||||
|
||||
const data = await rangeResp.json()
|
||||
|
||||
const address: string = data.address || data.addressLocal || `${firstSheetName}!A1`
|
||||
const rawValues: ExcelCellValue[][] = data.values || []
|
||||
|
||||
const values = trimTrailingEmptyRowsAndColumns(rawValues)
|
||||
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
|
||||
|
||||
const result: MicrosoftExcelReadResponse = {
|
||||
success: true,
|
||||
output: {
|
||||
data: {
|
||||
range: address,
|
||||
values,
|
||||
},
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Normal path: caller supplied a range; just return the parsed result
|
||||
const data = await response.json()
|
||||
|
||||
const accessToken = params?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
const webUrl = await getSpreadsheetWebUrl(spreadsheetId, accessToken, driveId)
|
||||
|
||||
const address: string = data.address || data.addressLocal || data.range || ''
|
||||
const rawValues: ExcelCellValue[][] = data.values || []
|
||||
const values = trimTrailingEmptyRowsAndColumns(rawValues)
|
||||
|
||||
const result: MicrosoftExcelReadResponse = {
|
||||
success: true,
|
||||
output: {
|
||||
data: {
|
||||
range: address,
|
||||
values,
|
||||
},
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
outputs: {
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Range data from the spreadsheet',
|
||||
properties: {
|
||||
range: { type: 'string', description: 'The range that was read' },
|
||||
values: { type: 'array', description: 'Array of rows containing cell values' },
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Spreadsheet metadata',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The ID of the spreadsheet' },
|
||||
spreadsheetUrl: { type: 'string', description: 'URL to access the spreadsheet' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2ReadResponse> = {
|
||||
id: 'microsoft_excel_read_v2',
|
||||
name: 'Read from Microsoft Excel V2',
|
||||
description: 'Read data from a specific sheet in a Microsoft Excel spreadsheet',
|
||||
version: '2.0.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
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,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the sheet/tab to read from (e.g., "Sheet1", "Sales Data")',
|
||||
},
|
||||
cellRange: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The cell range to read (e.g., "A1:D10"). If not specified, reads the entire used range.',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
|
||||
const sheetName = params.sheetName?.trim()
|
||||
if (!sheetName) {
|
||||
throw new Error('Sheet name is required')
|
||||
}
|
||||
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
const encodedSheetName = encodeURIComponent(escapeODataString(sheetName))
|
||||
|
||||
if (!params.cellRange) {
|
||||
return `${basePath}/workbook/worksheets('${encodedSheetName}')/usedRange(valuesOnly=true)`
|
||||
}
|
||||
|
||||
const cellRange = params.cellRange.trim()
|
||||
const encodedAddress = encodeURIComponent(cellRange)
|
||||
|
||||
return `${basePath}/workbook/worksheets('${encodedSheetName}')/range(address='${encodedAddress}')`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
retry: EXCEL_RETRY_CONFIG,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelV2ToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
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, driveId)
|
||||
|
||||
const address: string = data.address || data.addressLocal || ''
|
||||
const rawValues: ExcelCellValue[][] = data.values || []
|
||||
const values = trimTrailingEmptyRowsAndColumns(rawValues)
|
||||
|
||||
const sheetName = params?.sheetName || address.split('!')[0] || ''
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
sheetName,
|
||||
range: address,
|
||||
values,
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
sheetName: { type: 'string', description: 'Name of the sheet that was read' },
|
||||
range: { type: 'string', description: 'The range that was read' },
|
||||
values: { type: 'array', description: 'Array of rows containing cell values' },
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Spreadsheet metadata including ID and URL',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'Microsoft Excel spreadsheet ID' },
|
||||
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelSortRangeParams,
|
||||
MicrosoftExcelSortRangeResponse,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import {
|
||||
buildWorksheetRangeUrl,
|
||||
escapeODataString,
|
||||
getItemBasePath,
|
||||
getSpreadsheetWebUrl,
|
||||
} from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Sorts a worksheet range or a table by a single column.
|
||||
* Uses Microsoft Graph:
|
||||
* - Range: POST /workbook/worksheets/{name}/range(address='...')/sort/apply
|
||||
* - Table: POST /workbook/tables('{name}')/sort/apply
|
||||
*/
|
||||
export const sortRangeTool: ToolConfig<
|
||||
MicrosoftExcelSortRangeParams,
|
||||
MicrosoftExcelSortRangeResponse
|
||||
> = {
|
||||
id: 'microsoft_excel_sort_range',
|
||||
name: 'Sort Microsoft Excel Range',
|
||||
description: 'Sort a range or table by a column in a Microsoft Excel worksheet',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the spreadsheet/workbook (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: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The name of the table to sort. When provided, the table is sorted and range/sheetName are ignored.',
|
||||
},
|
||||
sheetName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The name of the worksheet (e.g., "Sheet1"). Used for range sorts when the range does not include a sheet name.',
|
||||
},
|
||||
range: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The cell range to sort (e.g., "A1:D10" or "Sheet1!A1:D10"). Required when no table name is provided.',
|
||||
},
|
||||
sortColumn: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The zero-based column index within the range or table to sort on (0 = first column).',
|
||||
},
|
||||
sortAscending: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to sort in ascending order. Defaults to true.',
|
||||
},
|
||||
hasHeaders: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Whether the range has a header row that should be excluded from sorting. Only applies to range sorts. Defaults to false.',
|
||||
},
|
||||
matchCase: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether casing affects string ordering. Defaults to false.',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
|
||||
const tableName = params.tableName?.trim()
|
||||
if (tableName) {
|
||||
return `${basePath}/workbook/tables('${encodeURIComponent(escapeODataString(tableName))}')/sort/apply`
|
||||
}
|
||||
|
||||
return `${buildWorksheetRangeUrl(basePath, params.range, params.sheetName)}/sort/apply`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
const key =
|
||||
typeof params.sortColumn === 'number' ? params.sortColumn : Number(params.sortColumn)
|
||||
if (!Number.isInteger(key) || key < 0) {
|
||||
throw new Error('sortColumn must be a non-negative integer column index')
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
fields: [
|
||||
{
|
||||
key,
|
||||
ascending: params.sortAscending ?? true,
|
||||
sortOn: 'Value',
|
||||
},
|
||||
],
|
||||
matchCase: params.matchCase ?? false,
|
||||
}
|
||||
|
||||
if (!params.tableName?.trim()) {
|
||||
body.hasHeaders = params.hasHeaders ?? false
|
||||
body.orientation = 'Rows'
|
||||
}
|
||||
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (_response: Response, params?: MicrosoftExcelSortRangeParams) => {
|
||||
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, driveId)
|
||||
|
||||
const target = params?.tableName?.trim() || params?.range || ''
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
sorted: true,
|
||||
target,
|
||||
sortColumn: params?.sortColumn ?? 0,
|
||||
ascending: params?.sortAscending ?? true,
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
sorted: { type: 'boolean', description: 'Whether the sort was applied' },
|
||||
target: { type: 'string', description: 'The range or table name that was sorted' },
|
||||
sortColumn: { type: 'number', description: 'The zero-based column index that was sorted on' },
|
||||
ascending: { type: 'boolean', description: 'Whether the sort was ascending' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Spreadsheet metadata',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The ID of the spreadsheet' },
|
||||
spreadsheetUrl: { type: 'string', description: 'URL to access the spreadsheet' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelTableAddResponse,
|
||||
MicrosoftExcelTableToolParams,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import {
|
||||
escapeODataString,
|
||||
getItemBasePath,
|
||||
getSpreadsheetWebUrl,
|
||||
} from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const tableAddTool: ToolConfig<
|
||||
MicrosoftExcelTableToolParams,
|
||||
MicrosoftExcelTableAddResponse
|
||||
> = {
|
||||
id: 'microsoft_excel_table_add',
|
||||
name: 'Add to Microsoft Excel Table',
|
||||
description: 'Add new rows to a Microsoft Excel table',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
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,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the table to add rows to (e.g., "Table1", "SalesTable")',
|
||||
},
|
||||
values: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The data to add as a 2D array (e.g., [["Alice", 30], ["Bob", 25]]) or array of objects',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
const tableName = params.tableName?.trim()
|
||||
if (!tableName) {
|
||||
throw new Error('Table name is required')
|
||||
}
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
return `${basePath}/workbook/tables('${encodeURIComponent(escapeODataString(tableName))}')/rows/add`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
let processedValues: any = params.values || []
|
||||
|
||||
if (
|
||||
Array.isArray(processedValues) &&
|
||||
processedValues.length > 0 &&
|
||||
typeof processedValues[0] === 'object' &&
|
||||
!Array.isArray(processedValues[0])
|
||||
) {
|
||||
const allKeys = new Set<string>()
|
||||
processedValues.forEach((obj: any) => {
|
||||
if (obj && typeof obj === 'object') {
|
||||
Object.keys(obj).forEach((key) => allKeys.add(key))
|
||||
}
|
||||
})
|
||||
const headers = Array.from(allKeys)
|
||||
|
||||
processedValues = processedValues.map((obj: any) => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return Array(headers.length).fill('')
|
||||
}
|
||||
return headers.map((key) => {
|
||||
const value = obj[key]
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return value === undefined ? '' : value
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (!Array.isArray(processedValues) || processedValues.length === 0) {
|
||||
throw new Error('Values must be a non-empty array')
|
||||
}
|
||||
|
||||
if (!Array.isArray(processedValues[0])) {
|
||||
processedValues = [processedValues]
|
||||
}
|
||||
|
||||
return {
|
||||
values: processedValues,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelTableToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
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, driveId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
index: data.index || 0,
|
||||
values: data.values || [],
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
index: { type: 'number', description: 'Index of the first row that was added' },
|
||||
values: { type: 'array', description: 'Array of rows that were added to the table' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Spreadsheet metadata',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The ID of the spreadsheet' },
|
||||
spreadsheetUrl: { type: 'string', description: 'URL to access the spreadsheet' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
// Type for Excel cell values - covers all valid data types that Excel supports
|
||||
export type ExcelCellValue = string | number | boolean | null
|
||||
|
||||
interface MicrosoftExcelRange {
|
||||
range: string
|
||||
values: ExcelCellValue[][]
|
||||
}
|
||||
|
||||
interface MicrosoftExcelMetadata {
|
||||
spreadsheetId: string
|
||||
spreadsheetUrl?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelReadResponse extends ToolResponse {
|
||||
output: {
|
||||
data: MicrosoftExcelRange
|
||||
metadata: MicrosoftExcelMetadata
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelWriteResponse extends ToolResponse {
|
||||
output: {
|
||||
updatedRange: string
|
||||
updatedRows: number
|
||||
updatedColumns: number
|
||||
updatedCells: number
|
||||
metadata: MicrosoftExcelMetadata
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelTableAddResponse extends ToolResponse {
|
||||
output: {
|
||||
index: number
|
||||
values: ExcelCellValue[][]
|
||||
metadata: MicrosoftExcelMetadata
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelWorksheetAddResponse extends ToolResponse {
|
||||
output: {
|
||||
worksheet: {
|
||||
id: string
|
||||
name: string
|
||||
position: number
|
||||
visibility: string
|
||||
}
|
||||
metadata: MicrosoftExcelMetadata
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelToolParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
range?: string
|
||||
values?: ExcelCellValue[][]
|
||||
valueInputOption?: 'RAW' | 'USER_ENTERED'
|
||||
includeValuesInResponse?: boolean
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelTableToolParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
tableName: string
|
||||
values: ExcelCellValue[][]
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelWorksheetToolParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
worksheetName: string
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelClearRangeParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
sheetName?: string
|
||||
range: string
|
||||
applyTo?: 'All' | 'Formats' | 'Contents'
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelClearRangeResponse extends ToolResponse {
|
||||
output: {
|
||||
cleared: boolean
|
||||
range: string
|
||||
applyTo: string
|
||||
metadata: MicrosoftExcelMetadata
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelFormatRangeParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
sheetName?: string
|
||||
range: string
|
||||
fillColor?: string
|
||||
fontBold?: boolean
|
||||
fontItalic?: boolean
|
||||
fontColor?: string
|
||||
fontSize?: number
|
||||
fontName?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelFormatRangeResponse extends ToolResponse {
|
||||
output: {
|
||||
formatted: boolean
|
||||
range: string
|
||||
fill: { color: string | null } | null
|
||||
font: {
|
||||
bold: boolean | null
|
||||
italic: boolean | null
|
||||
color: string | null
|
||||
name: string | null
|
||||
size: number | null
|
||||
} | null
|
||||
metadata: MicrosoftExcelMetadata
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelCreateTableParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
address: string
|
||||
hasHeaders?: boolean
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelCreateTableResponse extends ToolResponse {
|
||||
output: {
|
||||
table: {
|
||||
id: string
|
||||
name: string
|
||||
showHeaders: boolean
|
||||
showTotals: boolean
|
||||
style: string | null
|
||||
}
|
||||
metadata: MicrosoftExcelMetadata
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelDeleteWorksheetParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
worksheetName: string
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelDeleteWorksheetResponse extends ToolResponse {
|
||||
output: {
|
||||
deleted: boolean
|
||||
worksheetName: string
|
||||
metadata: MicrosoftExcelMetadata
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelSortRangeParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
sheetName?: string
|
||||
range?: string
|
||||
tableName?: string
|
||||
sortColumn: number
|
||||
sortAscending?: boolean
|
||||
hasHeaders?: boolean
|
||||
matchCase?: boolean
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelSortRangeResponse extends ToolResponse {
|
||||
output: {
|
||||
sorted: boolean
|
||||
target: string
|
||||
sortColumn: number
|
||||
ascending: boolean
|
||||
metadata: MicrosoftExcelMetadata
|
||||
}
|
||||
}
|
||||
|
||||
export type MicrosoftExcelResponse =
|
||||
| MicrosoftExcelReadResponse
|
||||
| MicrosoftExcelWriteResponse
|
||||
| MicrosoftExcelTableAddResponse
|
||||
| MicrosoftExcelWorksheetAddResponse
|
||||
| MicrosoftExcelClearRangeResponse
|
||||
| MicrosoftExcelFormatRangeResponse
|
||||
| MicrosoftExcelCreateTableResponse
|
||||
| MicrosoftExcelDeleteWorksheetResponse
|
||||
| MicrosoftExcelSortRangeResponse
|
||||
|
||||
// V2 Types - with separate sheetName param
|
||||
export interface MicrosoftExcelV2ToolParams {
|
||||
accessToken: string
|
||||
spreadsheetId: string
|
||||
driveId?: string
|
||||
sheetName: string
|
||||
cellRange?: string
|
||||
values?: ExcelCellValue[][]
|
||||
valueInputOption?: 'RAW' | 'USER_ENTERED'
|
||||
includeValuesInResponse?: boolean
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelV2ReadResponse extends ToolResponse {
|
||||
output: {
|
||||
sheetName: string
|
||||
range: string
|
||||
values: ExcelCellValue[][]
|
||||
metadata: {
|
||||
spreadsheetId: string
|
||||
spreadsheetUrl: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftExcelV2WriteResponse extends ToolResponse {
|
||||
output: {
|
||||
updatedRange: string | null
|
||||
updatedRows: number
|
||||
updatedColumns: number
|
||||
updatedCells: number
|
||||
metadata: {
|
||||
spreadsheetId: string
|
||||
spreadsheetUrl: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type MicrosoftExcelV2Response = MicrosoftExcelV2ReadResponse | MicrosoftExcelV2WriteResponse
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseGraphErrorMessage } from '@/tools/microsoft_excel/utils'
|
||||
|
||||
describe('parseGraphErrorMessage', () => {
|
||||
it('extracts top-level error.message', () => {
|
||||
const body = JSON.stringify({
|
||||
error: { code: 'badRequest', message: 'Uploaded fragment overlaps with existing data.' },
|
||||
})
|
||||
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe(
|
||||
'Uploaded fragment overlaps with existing data.'
|
||||
)
|
||||
})
|
||||
|
||||
it('combines top-level and innerError messages with em-dash separator', () => {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
code: 'invalidRequest',
|
||||
message: 'The request is invalid.',
|
||||
innerError: { code: 'invalidRange', message: 'Range A1:Z9999 is out of bounds.' },
|
||||
},
|
||||
})
|
||||
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe(
|
||||
'The request is invalid. — Range A1:Z9999 is out of bounds.'
|
||||
)
|
||||
})
|
||||
|
||||
it('walks nested innerError chain (lowercase spec form)', () => {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
message: 'Outer message.',
|
||||
innererror: {
|
||||
message: 'Middle message.',
|
||||
innererror: { message: 'Innermost message.' },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(parseGraphErrorMessage(500, 'Internal Server Error', body)).toBe(
|
||||
'Outer message. — Middle message. — Innermost message.'
|
||||
)
|
||||
})
|
||||
|
||||
it('appends details[].message entries', () => {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
message: 'Multiple problems.',
|
||||
details: [{ message: 'Cell A1 invalid.' }, { message: 'Cell B2 invalid.' }],
|
||||
},
|
||||
})
|
||||
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe(
|
||||
'Multiple problems. — Cell A1 invalid. — Cell B2 invalid.'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to error.code when no messages present', () => {
|
||||
const body = JSON.stringify({ error: { code: 'itemNotFound' } })
|
||||
expect(parseGraphErrorMessage(404, 'Not Found', body)).toBe('itemNotFound (404 Not Found)')
|
||||
})
|
||||
|
||||
it('returns raw text when body is not JSON', () => {
|
||||
expect(parseGraphErrorMessage(502, 'Bad Gateway', 'upstream timeout')).toBe('upstream timeout')
|
||||
})
|
||||
|
||||
it('falls back to status text when body is empty', () => {
|
||||
expect(parseGraphErrorMessage(503, 'Service Unavailable', '')).toBe('503 Service Unavailable')
|
||||
})
|
||||
|
||||
it('handles deeply nested chain without infinite loop', () => {
|
||||
let nested: Record<string, unknown> = { message: 'leaf' }
|
||||
for (let i = 0; i < 50; i++) {
|
||||
nested = { message: `level-${i}`, innerError: nested }
|
||||
}
|
||||
const body = JSON.stringify({ error: nested })
|
||||
const result = parseGraphErrorMessage(500, 'Internal Server Error', body)
|
||||
// Should include outer plus capped nested messages, not blow up.
|
||||
expect(result.startsWith('level-49')).toBe(true)
|
||||
})
|
||||
|
||||
it('deduplicates identical inner messages', () => {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
message: 'Same message.',
|
||||
innerError: { message: 'Same message.' },
|
||||
},
|
||||
})
|
||||
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe('Same message.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
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')
|
||||
|
||||
/**
|
||||
* Extract a developer-readable message from a parsed Microsoft Graph error body.
|
||||
* Graph errors follow the documented shape:
|
||||
* { error: { code, message, innerError: { code, message, ... }, details: [...] } }
|
||||
* See https://learn.microsoft.com/en-us/graph/errors
|
||||
*
|
||||
* Walks the nested innerError chain (capped at depth 5) and appends details[].message.
|
||||
* Returns undefined when no message-like field is present so callers can fall back.
|
||||
*/
|
||||
export function parseGraphErrorFromData(data: unknown): string | undefined {
|
||||
if (!data || typeof data !== 'object') return undefined
|
||||
|
||||
const root = (
|
||||
data as {
|
||||
error?: {
|
||||
code?: unknown
|
||||
message?: unknown
|
||||
innerError?: unknown
|
||||
innererror?: unknown
|
||||
details?: unknown
|
||||
}
|
||||
}
|
||||
).error
|
||||
if (root && typeof root === 'object') {
|
||||
const messages: string[] = []
|
||||
if (typeof root.message === 'string' && root.message.trim()) {
|
||||
messages.push(root.message.trim())
|
||||
}
|
||||
|
||||
// Walk the (possibly nested) innerError chain. Spec uses `innererror`
|
||||
// but Graph commonly returns `innerError` — accept both.
|
||||
let inner: any = (root as any).innererror ?? (root as any).innerError
|
||||
let depth = 0
|
||||
while (inner && depth < 5) {
|
||||
if (typeof inner.message === 'string' && inner.message.trim()) {
|
||||
const msg = inner.message.trim()
|
||||
if (!messages.includes(msg)) messages.push(msg)
|
||||
}
|
||||
inner = inner.innererror ?? inner.innerError
|
||||
depth++
|
||||
}
|
||||
|
||||
if (Array.isArray((root as any).details)) {
|
||||
for (const detail of (root as any).details) {
|
||||
if (detail && typeof detail.message === 'string' && detail.message.trim()) {
|
||||
const msg = detail.message.trim()
|
||||
if (!messages.includes(msg)) messages.push(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length > 0) return messages.join(' — ')
|
||||
|
||||
if (typeof root.code === 'string' && root.code.trim()) {
|
||||
return root.code.trim()
|
||||
}
|
||||
}
|
||||
|
||||
const topMessage = (data as { message?: unknown }).message
|
||||
if (typeof topMessage === 'string' && topMessage.trim()) {
|
||||
return topMessage.trim()
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Microsoft Graph error response body into a string message.
|
||||
* Used by API routes that have a Response object rather than parsed data.
|
||||
*/
|
||||
export function parseGraphErrorMessage(
|
||||
status: number,
|
||||
statusText: string,
|
||||
errorText: string
|
||||
): string {
|
||||
try {
|
||||
const data = JSON.parse(errorText)
|
||||
const message = parseGraphErrorFromData(data)
|
||||
if (message) {
|
||||
// If the only thing we found was the bare error code, append status for context.
|
||||
const root = data?.error
|
||||
if (
|
||||
root &&
|
||||
message === root.code?.trim?.() &&
|
||||
!(typeof root.message === 'string' && root.message.trim())
|
||||
) {
|
||||
return `${message} (${status} ${statusText})`
|
||||
}
|
||||
return message
|
||||
}
|
||||
} catch {
|
||||
if (errorText?.trim()) return errorText.trim()
|
||||
}
|
||||
|
||||
return statusText ? `${status} ${statusText}` : `Microsoft Graph request failed (${status})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an error response body and produce a developer-readable message.
|
||||
* Safely handles non-JSON bodies and read failures. Used by internal API routes.
|
||||
*/
|
||||
export async function extractGraphError(response: Response): Promise<string> {
|
||||
const errorText = await response.text().catch(() => '')
|
||||
return parseGraphErrorMessage(response.status, response.statusText, errorText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for use inside an OData single-quoted literal (e.g. a
|
||||
* `worksheets('...')` or `tables('...')` key). OData escapes an embedded single
|
||||
* quote by doubling it, so a name like `O'Brien` becomes `O''Brien`; without this
|
||||
* the apostrophe terminates the literal early and breaks the request URL.
|
||||
* `encodeURIComponent` leaves apostrophes untouched, so the doubling must happen here.
|
||||
*/
|
||||
export function escapeODataString(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
/** Pattern for Microsoft Graph item/drive IDs: alphanumeric, hyphens, underscores, and ! (for SharePoint b!<base64> format) */
|
||||
export const GRAPH_ID_PATTERN = /^[a-zA-Z0-9!_-]+$/
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a worksheet name and cell address from either an explicit sheet name
|
||||
* plus address, or a combined "Sheet1!A1:B2" range string.
|
||||
*
|
||||
* - When `sheetName` is provided, `address` is treated as a bare cell address (e.g. "A1:B2").
|
||||
* - When `sheetName` is omitted, `address` must be a combined "Sheet1!A1:B2" range.
|
||||
*
|
||||
* Throws when a worksheet name cannot be determined or the combined format is invalid.
|
||||
*/
|
||||
export function resolveSheetAndAddress(
|
||||
address: string | undefined,
|
||||
sheetName?: string
|
||||
): { sheetName: string; address: string } {
|
||||
const trimmedAddress = address?.trim()
|
||||
if (!trimmedAddress) {
|
||||
throw new Error('A cell range is required (e.g., "A1:B2" or "Sheet1!A1:B2")')
|
||||
}
|
||||
|
||||
const trimmedSheet = sheetName?.trim()
|
||||
if (trimmedSheet) {
|
||||
return { sheetName: trimmedSheet, address: trimmedAddress }
|
||||
}
|
||||
|
||||
const match = trimmedAddress.match(/^([^!]+)!(.+)$/)
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid range format: "${address}". Provide a sheet name, or use the combined format "Sheet1!A1:B2"`
|
||||
)
|
||||
}
|
||||
|
||||
return { sheetName: match[1], address: match[2] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Graph API URL for a worksheet range object, resolving the sheet name
|
||||
* from either an explicit `sheetName` param or a combined "Sheet1!A1:B2" address.
|
||||
* The returned URL has no trailing segment, so callers append `/clear`, `/sort/apply`,
|
||||
* `/format/fill`, `/format/font`, or nothing for the range object itself.
|
||||
*/
|
||||
export function buildWorksheetRangeUrl(
|
||||
basePath: string,
|
||||
address: string | undefined,
|
||||
sheetName?: string
|
||||
): string {
|
||||
const resolved = resolveSheetAndAddress(address, sheetName)
|
||||
const encodedSheet = encodeURIComponent(escapeODataString(resolved.sheetName))
|
||||
const encodedAddress = encodeURIComponent(resolved.address)
|
||||
return `${basePath}/workbook/worksheets('${encodedSheet}')/range(address='${encodedAddress}')`
|
||||
}
|
||||
|
||||
export function trimTrailingEmptyRowsAndColumns(matrix: ExcelCellValue[][]): ExcelCellValue[][] {
|
||||
if (!Array.isArray(matrix) || matrix.length === 0) return []
|
||||
|
||||
const isEmptyValue = (v: ExcelCellValue) => v === null || v === ''
|
||||
|
||||
// Determine last non-empty row
|
||||
let lastNonEmptyRowIndex = -1
|
||||
for (let r = 0; r < matrix.length; r++) {
|
||||
const row = matrix[r] || []
|
||||
const hasData = row.some((cell: ExcelCellValue) => !isEmptyValue(cell))
|
||||
if (hasData) lastNonEmptyRowIndex = r
|
||||
}
|
||||
|
||||
if (lastNonEmptyRowIndex === -1) return []
|
||||
|
||||
const trimmedRows = matrix.slice(0, lastNonEmptyRowIndex + 1)
|
||||
|
||||
// Determine last non-empty column across trimmed rows
|
||||
let lastNonEmptyColIndex = -1
|
||||
for (let r = 0; r < trimmedRows.length; r++) {
|
||||
const row = trimmedRows[r] || []
|
||||
for (let c = 0; c < row.length; c++) {
|
||||
if (!isEmptyValue(row[c])) {
|
||||
if (c > lastNonEmptyColIndex) lastNonEmptyColIndex = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastNonEmptyColIndex === -1) return []
|
||||
|
||||
return trimmedRows.map((row) => (row || []).slice(0, lastNonEmptyColIndex + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the browser-accessible web URL for an Excel spreadsheet.
|
||||
* This URL can be opened in a browser if the user is logged into OneDrive/Microsoft,
|
||||
* unlike the Graph API URL which requires an access token.
|
||||
*/
|
||||
export async function getSpreadsheetWebUrl(
|
||||
spreadsheetId: string,
|
||||
accessToken: string,
|
||||
driveId?: string
|
||||
): Promise<string> {
|
||||
const basePath = getItemBasePath(spreadsheetId, driveId)
|
||||
try {
|
||||
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 basePath
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.webUrl || basePath
|
||||
} catch (error) {
|
||||
logger.warn('Error fetching spreadsheet webUrl, using Graph API URL as fallback', {
|
||||
spreadsheetId,
|
||||
error,
|
||||
})
|
||||
return basePath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelWorksheetAddResponse,
|
||||
MicrosoftExcelWorksheetToolParams,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Tool for adding a new worksheet to a Microsoft Excel workbook
|
||||
* Uses Microsoft Graph API endpoint: POST /me/drive/items/{id}/workbook/worksheets/add
|
||||
*/
|
||||
export const worksheetAddTool: ToolConfig<
|
||||
MicrosoftExcelWorksheetToolParams,
|
||||
MicrosoftExcelWorksheetAddResponse
|
||||
> = {
|
||||
id: 'microsoft_excel_worksheet_add',
|
||||
name: 'Add Worksheet to Microsoft Excel',
|
||||
description: 'Create a new worksheet (sheet) in a Microsoft Excel workbook',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
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,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The name of the new worksheet (e.g., "Sales Q1", "Data"). Must be unique within the workbook and cannot exceed 31 characters',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
return `${basePath}/workbook/worksheets/add`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
const worksheetName = params.worksheetName?.trim()
|
||||
|
||||
if (!worksheetName) {
|
||||
throw new Error('Worksheet name is required')
|
||||
}
|
||||
|
||||
// Validate worksheet name length (Excel limitation)
|
||||
if (worksheetName.length > 31) {
|
||||
throw new Error('Worksheet name cannot exceed 31 characters. Please provide a shorter name')
|
||||
}
|
||||
|
||||
// Validate worksheet name doesn't contain invalid characters
|
||||
const invalidChars = ['\\', '/', '?', '*', '[', ']', ':']
|
||||
for (const char of invalidChars) {
|
||||
if (worksheetName.includes(char)) {
|
||||
throw new Error(`Worksheet name cannot contain the following characters: \\ / ? * [ ] :`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: worksheetName,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelWorksheetToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
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, driveId)
|
||||
|
||||
const result: MicrosoftExcelWorksheetAddResponse = {
|
||||
success: true,
|
||||
output: {
|
||||
worksheet: {
|
||||
id: data.id || '',
|
||||
name: data.name || '',
|
||||
position: data.position ?? 0,
|
||||
visibility: data.visibility || 'Visible',
|
||||
},
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
outputs: {
|
||||
worksheet: {
|
||||
type: 'object',
|
||||
description: 'Details of the newly created worksheet',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'The unique ID of the worksheet' },
|
||||
name: { type: 'string', description: 'The name of the worksheet' },
|
||||
position: { type: 'number', description: 'The zero-based position of the worksheet' },
|
||||
visibility: {
|
||||
type: 'string',
|
||||
description: 'The visibility state of the worksheet (Visible/Hidden/VeryHidden)',
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Spreadsheet metadata',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The ID of the spreadsheet' },
|
||||
spreadsheetUrl: { type: 'string', description: 'URL to access the spreadsheet' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelToolParams,
|
||||
MicrosoftExcelV2ToolParams,
|
||||
MicrosoftExcelV2WriteResponse,
|
||||
MicrosoftExcelWriteResponse,
|
||||
} from '@/tools/microsoft_excel/types'
|
||||
import {
|
||||
escapeODataString,
|
||||
getItemBasePath,
|
||||
getSpreadsheetWebUrl,
|
||||
} from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Range writes (PATCH /workbook/.../range) are semantically idempotent —
|
||||
* the same payload produces the same result — so we permit retries on
|
||||
* transient 429/5xx even though PATCH is not in the default idempotent set.
|
||||
*/
|
||||
const EXCEL_RETRY_CONFIG = {
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 30000,
|
||||
retryIdempotentOnly: false,
|
||||
} as const
|
||||
|
||||
export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWriteResponse> = {
|
||||
id: 'microsoft_excel_write',
|
||||
name: 'Write to Microsoft Excel',
|
||||
description: 'Write data to a Microsoft Excel spreadsheet',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
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,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The range of cells to write to (e.g., "Sheet1!A1:B2")',
|
||||
},
|
||||
values: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The data to write as a 2D array (e.g., [["Name", "Age"], ["Alice", 30]]) or array of objects',
|
||||
},
|
||||
valueInputOption: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'The format of the data to write',
|
||||
},
|
||||
includeValuesInResponse: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Whether to include the written values in the response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
|
||||
const rangeInput = params.range?.trim()
|
||||
const match = rangeInput?.match(/^([^!]+)!(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid range format: "${params.range}". Use the format "Sheet1!A1:B2"`)
|
||||
}
|
||||
|
||||
const sheetName = encodeURIComponent(escapeODataString(match[1]))
|
||||
const address = encodeURIComponent(match[2])
|
||||
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
const url = new URL(
|
||||
`${basePath}/workbook/worksheets('${sheetName}')/range(address='${address}')`
|
||||
)
|
||||
|
||||
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
|
||||
url.searchParams.append('valueInputOption', valueInputOption)
|
||||
|
||||
if (params.includeValuesInResponse) {
|
||||
url.searchParams.append('includeValuesInResponse', 'true')
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
let processedValues: any = params.values || []
|
||||
|
||||
if (
|
||||
Array.isArray(processedValues) &&
|
||||
processedValues.length > 0 &&
|
||||
typeof processedValues[0] === 'object' &&
|
||||
!Array.isArray(processedValues[0])
|
||||
) {
|
||||
const allKeys = new Set<string>()
|
||||
processedValues.forEach((obj: any) => {
|
||||
if (obj && typeof obj === 'object') {
|
||||
Object.keys(obj).forEach((key) => allKeys.add(key))
|
||||
}
|
||||
})
|
||||
const headers = Array.from(allKeys)
|
||||
|
||||
const rows = processedValues.map((obj: any) => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return Array(headers.length).fill('')
|
||||
}
|
||||
return headers.map((key) => {
|
||||
const value = obj[key]
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return value === undefined ? '' : value
|
||||
})
|
||||
})
|
||||
|
||||
processedValues = [headers, ...rows]
|
||||
}
|
||||
|
||||
const body: Record<string, any> = {
|
||||
majorDimension: 'ROWS',
|
||||
values: processedValues,
|
||||
}
|
||||
|
||||
if (params.range) {
|
||||
body.range = params.range
|
||||
}
|
||||
|
||||
return body
|
||||
},
|
||||
retry: EXCEL_RETRY_CONFIG,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
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, driveId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updatedRange: data.address ?? '',
|
||||
updatedRows: data.rowCount ?? 0,
|
||||
updatedColumns: data.columnCount ?? 0,
|
||||
updatedCells: (data.rowCount ?? 0) * (data.columnCount ?? 0),
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
updatedRange: { type: 'string', description: 'The range that was updated' },
|
||||
updatedRows: { type: 'number', description: 'Number of rows that were updated' },
|
||||
updatedColumns: { type: 'number', description: 'Number of columns that were updated' },
|
||||
updatedCells: { type: 'number', description: 'Number of cells that were updated' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Spreadsheet metadata',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'The ID of the spreadsheet' },
|
||||
spreadsheetUrl: { type: 'string', description: 'URL to access the spreadsheet' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2WriteResponse> = {
|
||||
id: 'microsoft_excel_write_v2',
|
||||
name: 'Write to Microsoft Excel V2',
|
||||
description: 'Write data to a specific sheet in a Microsoft Excel spreadsheet',
|
||||
version: '2.0.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-excel',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Excel API',
|
||||
},
|
||||
spreadsheetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
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,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the sheet/tab to write to (e.g., "Sheet1", "Sales Data")',
|
||||
},
|
||||
cellRange: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The cell range to write to (e.g., "A1:D10", "A1"). Defaults to "A1" if not specified.',
|
||||
},
|
||||
values: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The data to write as a 2D array (e.g. [["Name", "Age"], ["Alice", 30], ["Bob", 25]]) or array of objects.',
|
||||
},
|
||||
valueInputOption: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'The format of the data to write',
|
||||
},
|
||||
includeValuesInResponse: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Whether to include the written values in the response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const spreadsheetId = params.spreadsheetId?.trim()
|
||||
if (!spreadsheetId) {
|
||||
throw new Error('Spreadsheet ID is required')
|
||||
}
|
||||
|
||||
const sheetName = params.sheetName?.trim()
|
||||
if (!sheetName) {
|
||||
throw new Error('Sheet name is required')
|
||||
}
|
||||
|
||||
const cellRange = params.cellRange?.trim() || 'A1'
|
||||
const encodedSheetName = encodeURIComponent(escapeODataString(sheetName))
|
||||
const encodedAddress = encodeURIComponent(cellRange)
|
||||
|
||||
const basePath = getItemBasePath(spreadsheetId, params.driveId)
|
||||
const url = new URL(
|
||||
`${basePath}/workbook/worksheets('${encodedSheetName}')/range(address='${encodedAddress}')`
|
||||
)
|
||||
|
||||
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
|
||||
url.searchParams.append('valueInputOption', valueInputOption)
|
||||
|
||||
if (params.includeValuesInResponse) {
|
||||
url.searchParams.append('includeValuesInResponse', 'true')
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
let processedValues: any = params.values || []
|
||||
|
||||
// Handle array of objects
|
||||
if (
|
||||
Array.isArray(processedValues) &&
|
||||
processedValues.length > 0 &&
|
||||
typeof processedValues[0] === 'object' &&
|
||||
!Array.isArray(processedValues[0])
|
||||
) {
|
||||
const allKeys = new Set<string>()
|
||||
processedValues.forEach((obj: any) => {
|
||||
if (obj && typeof obj === 'object') {
|
||||
Object.keys(obj).forEach((key) => allKeys.add(key))
|
||||
}
|
||||
})
|
||||
const headers = Array.from(allKeys)
|
||||
|
||||
const rows = processedValues.map((obj: any) => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return Array(headers.length).fill('')
|
||||
}
|
||||
return headers.map((key) => {
|
||||
const value = obj[key]
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return value === undefined ? '' : value
|
||||
})
|
||||
})
|
||||
|
||||
processedValues = [headers, ...rows]
|
||||
}
|
||||
|
||||
const body: Record<string, any> = {
|
||||
majorDimension: 'ROWS',
|
||||
values: processedValues,
|
||||
}
|
||||
|
||||
return body
|
||||
},
|
||||
retry: EXCEL_RETRY_CONFIG,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelV2ToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
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, driveId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updatedRange: data.address ?? null,
|
||||
updatedRows: data.rowCount ?? 0,
|
||||
updatedColumns: data.columnCount ?? 0,
|
||||
updatedCells: (data.rowCount ?? 0) * (data.columnCount ?? 0),
|
||||
metadata: {
|
||||
spreadsheetId,
|
||||
spreadsheetUrl: webUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
updatedRange: { type: 'string', description: 'Range of cells that were updated' },
|
||||
updatedRows: { type: 'number', description: 'Number of rows updated' },
|
||||
updatedColumns: { type: 'number', description: 'Number of columns updated' },
|
||||
updatedCells: { type: 'number', description: 'Number of cells updated' },
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Spreadsheet metadata including ID and URL',
|
||||
properties: {
|
||||
spreadsheetId: { type: 'string', description: 'Microsoft Excel spreadsheet ID' },
|
||||
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user