diff --git a/apps/docs/content/docs/en/tools/onedrive.mdx b/apps/docs/content/docs/en/tools/onedrive.mdx index 9eab54cd06..5d5bbf123f 100644 --- a/apps/docs/content/docs/en/tools/onedrive.mdx +++ b/apps/docs/content/docs/en/tools/onedrive.mdx @@ -51,7 +51,7 @@ In Sim, the OneDrive integration enables your agents to directly interact with y ## Usage Instructions -Integrate OneDrive into the workflow. Can create, upload, and list files. +Integrate OneDrive into the workflow. Can create text and Excel files, upload files, and list files. @@ -68,6 +68,7 @@ Upload a file to OneDrive | `fileName` | string | Yes | The name of the file to upload | | `file` | file | No | The file to upload \(binary\) | | `content` | string | No | The text content to upload \(if no file is provided\) | +| `mimeType` | string | No | The MIME type of the file to create \(e.g., text/plain for .txt, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet for .xlsx\) | | `folderSelector` | string | No | Select the folder to upload the file to | | `manualFolderId` | string | No | Manually entered folder ID \(advanced mode\) | diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 38437bf58d..3857bc5ac0 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -1,4 +1,5 @@ import { type NextRequest, NextResponse } from 'next/server' +import * as XLSX from 'xlsx' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' @@ -14,8 +15,11 @@ const MICROSOFT_GRAPH_BASE = 'https://graph.microsoft.com/v1.0' const OneDriveUploadSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), fileName: z.string().min(1, 'File name is required'), - file: z.any(), // UserFile object + file: z.any().optional(), // UserFile object (optional for blank Excel creation) folderId: z.string().optional().nullable(), + mimeType: z.string().optional(), + // Optional Excel write-after-create inputs + values: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional(), }) export async function POST(request: NextRequest) { @@ -42,17 +46,30 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = OneDriveUploadSchema.parse(body) - logger.info(`[${requestId}] Uploading file to OneDrive`, { - fileName: validatedData.fileName, - folderId: validatedData.folderId || 'root', - }) + let fileBuffer: Buffer + let mimeType: string + + // Check if we're creating a blank Excel file + const isExcelCreation = + validatedData.mimeType === + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' && !validatedData.file + + if (isExcelCreation) { + // Create a blank Excel workbook - // Handle array or single file - const rawFile = validatedData.file - let fileToProcess + const workbook = XLSX.utils.book_new() + const worksheet = XLSX.utils.aoa_to_sheet([[]]) + XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1') - if (Array.isArray(rawFile)) { - if (rawFile.length === 0) { + // Generate XLSX file as buffer + const xlsxBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) + fileBuffer = Buffer.from(xlsxBuffer) + mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + } else { + // Handle regular file upload + const rawFile = validatedData.file + + if (!rawFile) { return NextResponse.json( { success: false, @@ -61,40 +78,51 @@ export async function POST(request: NextRequest) { { status: 400 } ) } - fileToProcess = rawFile[0] - } else { - fileToProcess = rawFile - } - // Convert to UserFile format - let userFile - try { - userFile = processSingleFileToUserFile(fileToProcess, requestId, logger) - } catch (error) { - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to process file', - }, - { status: 400 } - ) - } + let fileToProcess + if (Array.isArray(rawFile)) { + if (rawFile.length === 0) { + return NextResponse.json( + { + success: false, + error: 'No file provided', + }, + { status: 400 } + ) + } + fileToProcess = rawFile[0] + } else { + fileToProcess = rawFile + } - logger.info(`[${requestId}] Downloading file from storage: ${userFile.key}`) + // Convert to UserFile format + let userFile + try { + userFile = processSingleFileToUserFile(fileToProcess, requestId, logger) + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to process file', + }, + { status: 400 } + ) + } - let fileBuffer: Buffer + try { + fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) + } catch (error) { + logger.error(`[${requestId}] Failed to download file from storage:`, error) + return NextResponse.json( + { + success: false, + error: `Failed to download file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 500 } + ) + } - try { - fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) - } catch (error) { - logger.error(`[${requestId}] Failed to download file from storage:`, error) - return NextResponse.json( - { - success: false, - error: `Failed to download file: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) + mimeType = userFile.type || 'application/octet-stream' } const maxSize = 250 * 1024 * 1024 // 250MB @@ -110,7 +138,11 @@ export async function POST(request: NextRequest) { ) } - const fileName = validatedData.fileName || userFile.name + // Ensure file name has correct extension for Excel files + let fileName = validatedData.fileName + if (isExcelCreation && !fileName.endsWith('.xlsx')) { + fileName = `${fileName.replace(/\.[^.]*$/, '')}.xlsx` + } let uploadUrl: string const folderId = validatedData.folderId?.trim() @@ -121,10 +153,6 @@ export async function POST(request: NextRequest) { uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content` } - logger.info(`[${requestId}] Uploading to OneDrive: ${uploadUrl}`) - - const mimeType = userFile.type || 'application/octet-stream' - const uploadResponse = await fetch(uploadUrl, { method: 'PUT', headers: { @@ -136,11 +164,6 @@ export async function POST(request: NextRequest) { if (!uploadResponse.ok) { const errorText = await uploadResponse.text() - logger.error(`[${requestId}] OneDrive upload failed:`, { - status: uploadResponse.status, - statusText: uploadResponse.statusText, - error: errorText, - }) return NextResponse.json( { success: false, @@ -153,11 +176,174 @@ export async function POST(request: NextRequest) { const fileData = await uploadResponse.json() - logger.info(`[${requestId}] File uploaded successfully to OneDrive`, { - fileId: fileData.id, - fileName: fileData.name, - size: fileData.size, - }) + // If this is an Excel creation and values were provided, write them using the Excel API + let excelWriteResult: any | undefined + const shouldWriteExcelContent = + isExcelCreation && Array.isArray(validatedData.values) && validatedData.values.length > 0 + + if (shouldWriteExcelContent) { + try { + // Create a workbook session to ensure reliability and persistence of changes + let workbookSessionId: string | undefined + const sessionResp = await fetch( + `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/createSession`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ persistChanges: true }), + } + ) + + if (sessionResp.ok) { + const sessionData = await sessionResp.json() + workbookSessionId = sessionData?.id + } + + // Determine the first worksheet name + let sheetName = 'Sheet1' + try { + const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( + fileData.id + )}/workbook/worksheets?$select=name&$orderby=position&$top=1` + const listResp = await fetch(listUrl, { + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + ...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}), + }, + }) + if (listResp.ok) { + const listData = await listResp.json() + const firstSheetName = listData?.value?.[0]?.name + if (firstSheetName) { + sheetName = firstSheetName + } + } else { + const listErr = await listResp.text() + logger.warn(`[${requestId}] Failed to list worksheets, using default Sheet1`, { + status: listResp.status, + error: listErr, + }) + } + } catch (listError) { + logger.warn(`[${requestId}] Error listing worksheets, using default Sheet1`, listError) + } + + let processedValues: any = validatedData.values || [] + + if ( + Array.isArray(processedValues) && + processedValues.length > 0 && + typeof processedValues[0] === 'object' && + !Array.isArray(processedValues[0]) + ) { + const ws = XLSX.utils.json_to_sheet(processedValues) + processedValues = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) + } + + const rowsCount = processedValues.length + const colsCount = Math.max(...processedValues.map((row: any[]) => row.length), 0) + processedValues = processedValues.map((row: any[]) => { + const paddedRow = [...row] + while (paddedRow.length < colsCount) paddedRow.push('') + return paddedRow + }) + + // Compute concise end range from A1 and matrix size (no network round-trip) + const indexToColLetters = (index: number): string => { + let n = index + let s = '' + while (n > 0) { + const rem = (n - 1) % 26 + s = String.fromCharCode(65 + rem) + s + n = Math.floor((n - 1) / 26) + } + return s + } + + const endColLetters = colsCount > 0 ? indexToColLetters(colsCount) : 'A' + const endRow = rowsCount > 0 ? rowsCount : 1 + const computedRangeAddress = `A1:${endColLetters}${endRow}` + + const url = new URL( + `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent( + fileData.id + )}/workbook/worksheets('${encodeURIComponent( + sheetName + )}')/range(address='${encodeURIComponent(computedRangeAddress)}')` + ) + + const excelWriteResponse = await fetch(url.toString(), { + method: 'PATCH', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'Content-Type': 'application/json', + ...(workbookSessionId ? { 'workbook-session-id': workbookSessionId } : {}), + }, + body: JSON.stringify({ values: processedValues }), + }) + + if (!excelWriteResponse || !excelWriteResponse.ok) { + const errorText = excelWriteResponse ? await excelWriteResponse.text() : 'no response' + logger.error(`[${requestId}] Excel content write failed`, { + status: excelWriteResponse?.status, + statusText: excelWriteResponse?.statusText, + error: errorText, + }) + // Do not fail the entire request; return upload success with write error details + excelWriteResult = { + success: false, + error: `Excel write failed: ${excelWriteResponse?.statusText || 'unknown'}`, + details: errorText, + } + } else { + const writeData = await excelWriteResponse.json() + // The Range PATCH returns a Range object; log address and values length + const addr = writeData.address || writeData.addressLocal + const v = writeData.values || [] + excelWriteResult = { + success: true, + updatedRange: addr, + updatedRows: Array.isArray(v) ? v.length : undefined, + updatedColumns: Array.isArray(v) && v[0] ? v[0].length : undefined, + updatedCells: Array.isArray(v) && v[0] ? v.length * (v[0] as any[]).length : undefined, + } + } + + // Attempt to close the workbook session if one was created + if (workbookSessionId) { + try { + const closeResp = await fetch( + `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/closeSession`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${validatedData.accessToken}`, + 'workbook-session-id': workbookSessionId, + }, + } + ) + if (!closeResp.ok) { + const closeText = await closeResp.text() + logger.warn(`[${requestId}] Failed to close Excel session`, { + status: closeResp.status, + error: closeText, + }) + } + } catch (closeErr) { + logger.warn(`[${requestId}] Error closing Excel session`, closeErr) + } + } + } catch (err) { + logger.error(`[${requestId}] Exception during Excel content write`, err) + excelWriteResult = { + success: false, + error: err instanceof Error ? err.message : 'Unknown error during Excel write', + } + } + } return NextResponse.json({ success: true, @@ -173,6 +359,7 @@ export async function POST(request: NextRequest) { modifiedTime: fileData.lastModifiedDateTime, parentReference: fileData.parentReference, }, + ...(excelWriteResult ? { excelWriteResult } : {}), }, }) } catch (error) { diff --git a/apps/sim/blocks/blocks/onedrive.ts b/apps/sim/blocks/blocks/onedrive.ts index 0c6b5600bd..bd3268de0d 100644 --- a/apps/sim/blocks/blocks/onedrive.ts +++ b/apps/sim/blocks/blocks/onedrive.ts @@ -8,7 +8,8 @@ export const OneDriveBlock: BlockConfig = { name: 'OneDrive', description: 'Create, upload, and list files', authMode: AuthMode.OAuth, - longDescription: 'Integrate OneDrive into the workflow. Can create, upload, and list files.', + longDescription: + 'Integrate OneDrive into the workflow. Can create text and Excel files, upload files, and list files.', docsLink: 'https://docs.sim.ai/tools/onedrive', category: 'tools', bgColor: '#E0E0E0', @@ -51,10 +52,45 @@ export const OneDriveBlock: BlockConfig = { title: 'File Name', type: 'short-input', layout: 'full', - placeholder: 'Name of the file (e.g., document.txt)', + placeholder: 'Name of the file', condition: { field: 'operation', value: ['create_file', 'upload'] }, required: true, }, + // File Type selector for create_file operation + { + id: 'mimeType', + title: 'File Type', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Text File (.txt)', id: 'text/plain' }, + { + label: 'Excel File (.xlsx)', + id: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + ], + placeholder: 'Select file type', + condition: { field: 'operation', value: 'create_file' }, + required: true, + }, + // Excel values input when creating an .xlsx file + { + id: 'values', + title: 'Values', + type: 'long-input', + layout: 'full', + placeholder: + 'Enter values as JSON array of arrays (e.g., [["A1","B1"],["A2","B2"]]) or an array of objects', + condition: { + field: 'operation', + value: 'create_file', + and: { + field: 'mimeType', + value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + }, + required: false, + }, // File upload (basic mode) { id: 'file', @@ -86,7 +122,14 @@ export const OneDriveBlock: BlockConfig = { type: 'long-input', layout: 'full', placeholder: 'Text content for the file', - condition: { field: 'operation', value: 'create_file' }, + condition: { + field: 'operation', + value: 'create_file', + and: { + field: 'mimeType', + value: 'text/plain', + }, + }, required: true, }, @@ -234,14 +277,22 @@ export const OneDriveBlock: BlockConfig = { } }, params: (params) => { - const { credential, folderSelector, manualFolderId, mimeType, ...rest } = params + const { credential, folderSelector, manualFolderId, mimeType, values, ...rest } = params // Use folderSelector if provided, otherwise use manualFolderId const effectiveFolderId = (folderSelector || manualFolderId || '').trim() + let parsedValues + try { + parsedValues = values ? JSON.parse(values as string) : undefined + } catch (error) { + throw new Error('Invalid JSON format for values') + } + return { credential, ...rest, + values: parsedValues, folderId: effectiveFolderId || undefined, pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined, mimeType: mimeType, @@ -257,6 +308,8 @@ export const OneDriveBlock: BlockConfig = { file: { type: 'json', description: 'File to upload (UserFile object)' }, fileReference: { type: 'json', description: 'File reference from previous block' }, content: { type: 'string', description: 'Text content to upload' }, + mimeType: { type: 'string', description: 'MIME type of file to create' }, + values: { type: 'string', description: 'Cell values for new Excel as JSON' }, // Get Content operation inputs // fileId: { type: 'string', required: false }, // List operation inputs diff --git a/apps/sim/tools/onedrive/types.ts b/apps/sim/tools/onedrive/types.ts index e58e18d7eb..9f4ca181f1 100644 --- a/apps/sim/tools/onedrive/types.ts +++ b/apps/sim/tools/onedrive/types.ts @@ -43,6 +43,15 @@ export interface OneDriveListResponse extends ToolResponse { export interface OneDriveUploadResponse extends ToolResponse { output: { file: OneDriveFile + excelWriteResult?: { + success: boolean + updatedRange?: string + updatedRows?: number + updatedColumns?: number + updatedCells?: number + error?: string + details?: string + } } } @@ -60,6 +69,8 @@ export interface OneDriveToolParams { pageSize?: number pageToken?: string exportMimeType?: string + // Optional Excel write parameters (used when creating an .xlsx without file content) + values?: (string | number | boolean | null)[][] } export type OneDriveResponse = OneDriveUploadResponse | OneDriveListResponse diff --git a/apps/sim/tools/onedrive/upload.ts b/apps/sim/tools/onedrive/upload.ts index e0d78591c2..f21bd4265e 100644 --- a/apps/sim/tools/onedrive/upload.ts +++ b/apps/sim/tools/onedrive/upload.ts @@ -48,6 +48,13 @@ export const uploadTool: ToolConfig visibility: 'user-or-llm', description: 'The text content to upload (if no file is provided)', }, + mimeType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The MIME type of the file to create (e.g., text/plain for .txt, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet for .xlsx)', + }, folderSelector: { type: 'string', required: false, @@ -64,15 +71,17 @@ export const uploadTool: ToolConfig request: { url: (params) => { - // If file is provided, use custom API route for binary upload - if (params.file) { + // If file is provided OR Excel file is being created, use custom API route + const isExcelFile = + params.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + if (params.file || isExcelFile) { return '/api/tools/onedrive/upload' } - // Text-only upload - use direct Microsoft Graph API + // Direct upload for text files - use Microsoft Graph API let fileName = params.fileName || 'untitled' - // Always create .txt files for text content + // For text files, ensure .txt extension if (!fileName.endsWith('.txt')) { // Remove any existing extensions and add .txt fileName = `${fileName.replace(/\.[^.]*$/, '')}.txt` @@ -87,32 +96,44 @@ export const uploadTool: ToolConfig return `https://graph.microsoft.com/v1.0/me/drive/root:/${fileName}:/content` }, method: (params) => { - // Use POST for custom API route, PUT for direct upload - return params.file ? 'POST' : 'PUT' + // Use POST for custom API route (file uploads or Excel creation), PUT for direct text upload + const isExcelFile = + params.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + return params.file || isExcelFile ? 'POST' : 'PUT' }, headers: (params) => { const headers: Record = {} - // For file uploads via custom API, send JSON - if (params.file) { + const isExcelFile = + params.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + + // For file uploads or Excel creation via custom API, send JSON + if (params.file || isExcelFile) { headers['Content-Type'] = 'application/json' } else { - // For text-only uploads, use direct PUT with access token + // For direct text uploads, use direct PUT with access token headers.Authorization = `Bearer ${params.accessToken}` headers['Content-Type'] = 'text/plain' } return headers }, body: (params) => { - // For file uploads, send all params as JSON to custom API route - if (params.file) { + const isExcelFile = + params.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + + // For file uploads or Excel creation, send all params as JSON to custom API route + if (params.file || isExcelFile) { return { accessToken: params.accessToken, fileName: params.fileName, file: params.file, folderId: params.manualFolderId || params.folderSelector, + mimeType: params.mimeType, + // Optional Excel content write-after-create + values: params.values, } } - // For text-only uploads, send content directly + + // For text files, send content directly return (params.content || '') as unknown as Record }, }, @@ -120,8 +141,11 @@ export const uploadTool: ToolConfig transformResponse: async (response: Response, params?: OneDriveToolParams) => { const data = await response.json() - // Handle response from custom API route (for file uploads) - if (params?.file && data.success !== undefined) { + const isExcelFile = + params?.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + + // Handle response from custom API route (for file uploads or Excel creation) + if ((params?.file || isExcelFile) && data.success !== undefined) { if (!data.success) { throw new Error(data.error || 'Failed to upload file') }