diff --git a/apps/docs/content/docs/tools/linear.mdx b/apps/docs/content/docs/tools/linear.mdx index f6464c14a4..13a9b55c67 100644 --- a/apps/docs/content/docs/tools/linear.mdx +++ b/apps/docs/content/docs/tools/linear.mdx @@ -9,7 +9,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" type="linear" color="#5E6AD2" icon={true} - iconSvg={``} + iconSvg={` + + `} /> {/* MANUAL-CONTENT-START:intro */} diff --git a/apps/docs/content/docs/tools/meta.json b/apps/docs/content/docs/tools/meta.json index 634fd7481e..4569ab2165 100644 --- a/apps/docs/content/docs/tools/meta.json +++ b/apps/docs/content/docs/tools/meta.json @@ -25,6 +25,7 @@ "linkup", "mem0", "memory", + "microsoft_excel", "microsoft_teams", "mistral_parse", "notion", diff --git a/apps/docs/content/docs/tools/microsoft_excel.mdx b/apps/docs/content/docs/tools/microsoft_excel.mdx new file mode 100644 index 0000000000..1d44c2112f --- /dev/null +++ b/apps/docs/content/docs/tools/microsoft_excel.mdx @@ -0,0 +1,197 @@ +--- +title: Microsoft Excel +description: Read, write, and update data +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + + + + + + + + + + + + + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +[Microsoft Teams](https://www.microsoft.com/en-us/microsoft-365/excel) is a powerful spreadsheet application that enables data management, analysis, and visualization. Through the Microsoft Excel integration in Sim Studio, you can programmatically read, write, and manipulate spreadsheet data to support your workflow automation needs. + +With Microsoft Excel integration, you can: + +- **Read Spreadsheet Data**: Access data from specific ranges, sheets, and cells +- **Write and Update Data**: Add new data or modify existing spreadsheet content +- **Manage Tables**: Create and manipulate tabular data structures +- **Handle Multiple Sheets**: Work with multiple worksheets in a workbook +- **Process Data**: Import, export, and transform spreadsheet data + +In Sim Studio, the Microsoft Excel integration provides seamless access to spreadsheet functionality through OAuth authentication. You can read data from specific ranges, write new information, update existing cells, and handle various data formats. The integration supports both reading and writing operations with flexible input and output options. This enables you to build workflows that can effectively manage spreadsheet data, whether you're extracting information for analysis, updating records automatically, or maintaining data consistency across your applications. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Microsoft Excel functionality to manage spreadsheet data. Read data from specific ranges, write new data, update existing cells, and manipulate table data using OAuth authentication. Supports various input and output formats for flexible data handling. + + + +## Tools + +### `microsoft_excel_read` + +Read data from a Microsoft Excel spreadsheet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the Microsoft Excel API | +| `spreadsheetId` | string | Yes | The ID of the spreadsheet to read from | +| `range` | string | No | The range of cells to read from | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `data` | json | + +### `microsoft_excel_write` + +Write data to a Microsoft Excel spreadsheet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the Microsoft Excel API | +| `spreadsheetId` | string | Yes | The ID of the spreadsheet to write to | +| `range` | string | No | The range of cells to write to | +| `values` | array | Yes | The data to write to the spreadsheet | +| `valueInputOption` | string | No | The format of the data to write | +| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `updatedRange` | string | +| `updatedRows` | string | +| `updatedColumns` | string | +| `updatedCells` | string | +| `metadata` | string | +| `spreadsheetId` | string | +| `spreadsheetUrl` | string | + +### `microsoft_excel_table_add` + +Add new rows to a Microsoft Excel table + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the Microsoft Excel API | +| `spreadsheetId` | string | Yes | The ID of the spreadsheet containing the table | +| `tableName` | string | Yes | The name of the table to add rows to | +| `values` | array | Yes | The data to add to the table \(array of arrays or array of objects\) | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `data` | json | + + + +## Block Configuration + +### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `operation` | string | Yes | Operation | + + + +### Outputs + +| Output | Type | Description | +| ------ | ---- | ----------- | +| `response` | object | Output from response | +| ↳ `data` | json | data of the response | +| ↳ `metadata` | json | metadata of the response | +| ↳ `updatedRange` | string | updatedRange of the response | +| ↳ `updatedRows` | number | updatedRows of the response | +| ↳ `updatedColumns` | number | updatedColumns of the response | +| ↳ `updatedCells` | number | updatedCells of the response | +| ↳ `index` | number | index of the response | +| ↳ `values` | json | values of the response | + + +## Notes + +- Category: `tools` +- Type: `microsoft_excel` diff --git a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts new file mode 100644 index 0000000000..b5e3c3aed5 --- /dev/null +++ b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts @@ -0,0 +1,110 @@ +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { account } from '@/db/schema' +import { refreshAccessTokenIfNeeded } from '../../utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('MicrosoftFileAPI') + +/** + * Get a single file from Microsoft OneDrive + */ +export async function GET(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + try { + // Get the session + const session = await getSession() + + // Check if the user is authenticated + if (!session?.user?.id) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + // Get the credential ID and file ID from the query params + const { searchParams } = new URL(request.url) + const credentialId = searchParams.get('credentialId') + const fileId = searchParams.get('fileId') + + if (!credentialId || !fileId) { + return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 }) + } + + // Get the credential from the database + const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + + if (!credentials.length) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const credential = credentials[0] + + // Check if the credential belongs to the user + if (credential.userId !== session.user.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + // Refresh access token if needed using the utility function + const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + const response = await fetch( + `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}?$select=id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) + logger.error(`[${requestId}] Microsoft Graph API error`, { + status: response.status, + error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive', + }) + return NextResponse.json( + { + error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive', + }, + { status: response.status } + ) + } + + const file = await response.json() + + // Transform the response to match expected format + const transformedFile = { + id: file.id, + name: file.name, + mimeType: + file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + iconLink: file.thumbnails?.[0]?.small?.url, + webViewLink: file.webUrl, + thumbnailLink: file.thumbnails?.[0]?.medium?.url, + createdTime: file.createdDateTime, + modifiedTime: file.lastModifiedDateTime, + size: file.size?.toString(), + owners: file.createdBy + ? [ + { + displayName: file.createdBy.user?.displayName || 'Unknown', + emailAddress: file.createdBy.user?.email || '', + }, + ] + : [], + downloadUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${file.id}/content`, + } + + return NextResponse.json({ file: transformedFile }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching file from Microsoft OneDrive`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts new file mode 100644 index 0000000000..2bc76a1cde --- /dev/null +++ b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts @@ -0,0 +1,138 @@ +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { account } from '@/db/schema' +import { refreshAccessTokenIfNeeded } from '../../utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('MicrosoftFilesAPI') + +/** + * Get Excel files from Microsoft OneDrive + */ +export async function GET(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) // Generate a short request ID for correlation + + try { + // Get the session + const session = await getSession() + + // Check if the user is authenticated + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthenticated request rejected`) + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + // Get the credential ID from the query params + const { searchParams } = new URL(request.url) + const credentialId = searchParams.get('credentialId') + const query = searchParams.get('query') || '' + + if (!credentialId) { + logger.warn(`[${requestId}] Missing credential ID`) + return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + } + + // Get the credential from the database + const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + + if (!credentials.length) { + logger.warn(`[${requestId}] Credential not found`, { credentialId }) + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const credential = credentials[0] + + // Check if the credential belongs to the user + if (credential.userId !== session.user.id) { + logger.warn(`[${requestId}] Unauthorized credential access attempt`, { + credentialUserId: credential.userId, + requestUserId: session.user.id, + }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + // Refresh access token if needed using the utility function + const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + // Build search query for Excel files + let searchQuery = '.xlsx' + if (query) { + searchQuery = `${query} .xlsx` + } + + // Build the query parameters for Microsoft Graph API + const searchParams_new = new URLSearchParams() + searchParams_new.append( + '$select', + 'id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy' + ) + searchParams_new.append('$top', '50') + + const response = await fetch( + `https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) + logger.error(`[${requestId}] Microsoft Graph API error`, { + status: response.status, + error: errorData.error?.message || 'Failed to fetch Excel files from Microsoft OneDrive', + }) + return NextResponse.json( + { + error: errorData.error?.message || 'Failed to fetch Excel files from Microsoft OneDrive', + }, + { status: response.status } + ) + } + + const data = await response.json() + let files = data.value || [] + + // Transform Microsoft Graph response to match expected format and filter for Excel files + files = files + .filter( + (file: any) => + file.name?.toLowerCase().endsWith('.xlsx') || + file.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + .map((file: any) => ({ + id: file.id, + name: file.name, + mimeType: + file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + iconLink: file.thumbnails?.[0]?.small?.url, + webViewLink: file.webUrl, + thumbnailLink: file.thumbnails?.[0]?.medium?.url, + createdTime: file.createdDateTime, + modifiedTime: file.lastModifiedDateTime, + size: file.size?.toString(), + owners: file.createdBy + ? [ + { + displayName: file.createdBy.user?.displayName || 'Unknown', + emailAddress: file.createdBy.user?.email || '', + }, + ] + : [], + })) + + return NextResponse.json({ files }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching Excel files from Microsoft OneDrive`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx new file mode 100644 index 0000000000..a04f7e01c6 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx @@ -0,0 +1,582 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react' +import { MicrosoftExcelIcon } from '@/components/icons' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { createLogger } from '@/lib/logs/console-logger' +import { + type Credential, + getProviderIdFromServiceId, + getServiceByProviderAndId, + getServiceIdFromScopes, + OAUTH_PROVIDERS, + type OAuthProvider, + parseProvider, +} from '@/lib/oauth' +import { saveToStorage } from '@/stores/workflows/persistence' +import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' + +const logger = createLogger('MicrosoftFileSelector') + +export interface MicrosoftFileInfo { + id: string + name: string + mimeType: string + iconLink?: string + webViewLink?: string + thumbnailLink?: string + createdTime?: string + modifiedTime?: string + size?: string + owners?: { displayName: string; emailAddress: string }[] +} + +interface MicrosoftFileSelectorProps { + value: string + onChange: (value: string, fileInfo?: MicrosoftFileInfo) => void + provider: OAuthProvider + requiredScopes?: string[] + label?: string + disabled?: boolean + serviceId?: string + showPreview?: boolean + onFileInfoChange?: (fileInfo: MicrosoftFileInfo | null) => void +} + +export function MicrosoftFileSelector({ + value, + onChange, + provider, + requiredScopes = [], + label = 'Select file', + disabled = false, + serviceId, + showPreview = true, + onFileInfoChange, +}: MicrosoftFileSelectorProps) { + const [open, setOpen] = useState(false) + const [credentials, setCredentials] = useState([]) + const [selectedCredentialId, setSelectedCredentialId] = useState('') + const [selectedFileId, setSelectedFileId] = useState(value) + const [selectedFile, setSelectedFile] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false) + const [isLoadingFiles, setIsLoadingFiles] = useState(false) + const [availableFiles, setAvailableFiles] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [showOAuthModal, setShowOAuthModal] = useState(false) + const initialFetchRef = useRef(false) + + // Determine the appropriate service ID based on provider and scopes + const getServiceId = (): string => { + if (serviceId) return serviceId + return getServiceIdFromScopes(provider, requiredScopes) + } + + // Determine the appropriate provider ID based on service and scopes + const getProviderId = (): string => { + const effectiveServiceId = getServiceId() + return getProviderIdFromServiceId(effectiveServiceId) + } + + // Fetch available credentials for this provider + const fetchCredentials = useCallback(async () => { + setIsLoading(true) + try { + const providerId = getProviderId() + const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`) + + if (response.ok) { + const data = await response.json() + setCredentials(data.credentials) + + // Auto-select logic for credentials + if (data.credentials.length > 0) { + // If we already have a selected credential ID, check if it's valid + if ( + selectedCredentialId && + data.credentials.some((cred: Credential) => cred.id === selectedCredentialId) + ) { + // Keep the current selection + } else { + // Otherwise, select the default or first credential + const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault) + if (defaultCred) { + setSelectedCredentialId(defaultCred.id) + } else if (data.credentials.length === 1) { + setSelectedCredentialId(data.credentials[0].id) + } + } + } + } + } catch (error) { + logger.error('Error fetching credentials:', { error }) + } finally { + setIsLoading(false) + } + }, [provider, getProviderId, selectedCredentialId]) + + // Fetch available Excel files for the selected credential + const fetchAvailableFiles = useCallback(async () => { + if (!selectedCredentialId) return + + setIsLoadingFiles(true) + try { + const queryParams = new URLSearchParams({ + credentialId: selectedCredentialId, + }) + + // Add search query if provided + if (searchQuery.trim()) { + queryParams.append('query', searchQuery.trim()) + } + + const response = await fetch(`/api/auth/oauth/microsoft/files?${queryParams.toString()}`) + + if (response.ok) { + const data = await response.json() + setAvailableFiles(data.files || []) + } else { + logger.error('Error fetching available files:', { + error: await response.text(), + }) + setAvailableFiles([]) + } + } catch (error) { + logger.error('Error fetching available files:', { error }) + setAvailableFiles([]) + } finally { + setIsLoadingFiles(false) + } + }, [selectedCredentialId, searchQuery]) + + // Fetch a single file by ID when we have a selectedFileId but no metadata + const fetchFileById = useCallback( + async (fileId: string) => { + if (!selectedCredentialId || !fileId) return null + + setIsLoadingSelectedFile(true) + try { + // Construct query parameters + const queryParams = new URLSearchParams({ + credentialId: selectedCredentialId, + fileId: fileId, + }) + + const response = await fetch(`/api/auth/oauth/microsoft/file?${queryParams.toString()}`) + + if (response.ok) { + const data = await response.json() + if (data.file) { + setSelectedFile(data.file) + onFileInfoChange?.(data.file) + return data.file + } + } else { + logger.error('Error fetching file by ID:', { + error: await response.text(), + }) + } + return null + } catch (error) { + logger.error('Error fetching file by ID:', { error }) + return null + } finally { + setIsLoadingSelectedFile(false) + } + }, + [selectedCredentialId, onFileInfoChange] + ) + + // Fetch credentials on initial mount + useEffect(() => { + if (!initialFetchRef.current) { + fetchCredentials() + initialFetchRef.current = true + } + }, [fetchCredentials]) + + // Fetch available files when credential changes + useEffect(() => { + if (selectedCredentialId) { + fetchAvailableFiles() + } + }, [selectedCredentialId, fetchAvailableFiles]) + + // Refetch files when search query changes + useEffect(() => { + if (selectedCredentialId && searchQuery !== undefined) { + const timeoutId = setTimeout(() => { + fetchAvailableFiles() + }, 300) // Debounce search + + return () => clearTimeout(timeoutId) + } + }, [searchQuery, selectedCredentialId, fetchAvailableFiles]) + + // Fetch the selected file metadata once credentials are loaded or changed + useEffect(() => { + // If we have a file ID selected and credentials are ready but we still don't have the file info, fetch it + if (value && selectedCredentialId && !selectedFile) { + fetchFileById(value) + } + }, [value, selectedCredentialId, selectedFile, fetchFileById]) + + // Keep internal selectedFileId in sync with the value prop + useEffect(() => { + if (value !== selectedFileId) { + setSelectedFileId(value) + } + }, [value]) + + // Handle selecting a file from the available files + const handleFileSelect = (file: MicrosoftFileInfo) => { + setSelectedFileId(file.id) + setSelectedFile(file) + onChange(file.id, file) + onFileInfoChange?.(file) + setOpen(false) + setSearchQuery('') // Clear search when file is selected + } + + // Handle adding a new credential + const handleAddCredential = () => { + const effectiveServiceId = getServiceId() + const providerId = getProviderId() + + // Store information about the required connection + saveToStorage('pending_service_id', effectiveServiceId) + saveToStorage('pending_oauth_scopes', requiredScopes) + saveToStorage('pending_oauth_return_url', window.location.href) + saveToStorage('pending_oauth_provider_id', providerId) + + // Show the OAuth modal + setShowOAuthModal(true) + setOpen(false) + setSearchQuery('') // Clear search when closing + } + + // Clear selection + const handleClearSelection = () => { + setSelectedFileId('') + setSelectedFile(null) + onChange('', undefined) + onFileInfoChange?.(null) + } + + // Get provider icon + const getProviderIcon = (providerName: OAuthProvider) => { + const { baseProvider } = parseProvider(providerName) + const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] + + if (!baseProviderConfig) { + return + } + + // For compound providers, find the specific service + if (providerName.includes('-')) { + for (const service of Object.values(baseProviderConfig.services)) { + if (service.providerId === providerName) { + return service.icon({ className: 'h-4 w-4' }) + } + } + } + + // Fallback to base provider icon + return baseProviderConfig.icon({ className: 'h-4 w-4' }) + } + + // Get provider name + const getProviderName = (providerName: OAuthProvider) => { + const effectiveServiceId = getServiceId() + try { + // First try to get the service by provider and service ID + const service = getServiceByProviderAndId(providerName, effectiveServiceId) + return service.name + } catch (_error) { + // If that fails, try to get the service by parsing the provider + try { + const { baseProvider } = parseProvider(providerName) + const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] + + // For compound providers like 'google-drive', try to find the specific service + if (providerName.includes('-')) { + const serviceKey = providerName.split('-')[1] || '' + for (const [key, service] of Object.entries(baseProviderConfig?.services || {})) { + if (key === serviceKey || key === providerName || service.providerId === providerName) { + return service.name + } + } + } + + // Fallback to provider name if service not found + if (baseProviderConfig) { + return baseProviderConfig.name + } + } catch (_parseError) { + // Ignore parse error and continue to final fallback + } + + // Final fallback: capitalize the provider name + return providerName + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') + } + } + + // Get file icon based on mime type + const getFileIcon = (file: MicrosoftFileInfo, size: 'sm' | 'md' = 'sm') => { + const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5' + + if (file.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') { + return + } + // if (file.mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + // return + // } + // if (file.mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') { + // return + // } + // return + } + + // Handle search input changes + const handleSearch = (query: string) => { + setSearchQuery(query) + } + + return ( + <> + + { + setOpen(isOpen) + if (!isOpen) { + setSearchQuery('') // Clear search when popover closes + } + }} + > + + + {selectedFile ? ( + + {getFileIcon(selectedFile, 'sm')} + {selectedFile.name} + + ) : selectedFileId && (isLoadingSelectedFile || !selectedCredentialId) ? ( + + + Loading document... + + ) : ( + + {getProviderIcon(provider)} + {label} + + )} + + + + + {/* Current account indicator */} + {selectedCredentialId && credentials.length > 0 && ( + + + {getProviderIcon(provider)} + + {credentials.find((cred) => cred.id === selectedCredentialId)?.name || + 'Unknown'} + + + {credentials.length > 1 && ( + setOpen(true)} + > + Switch + + )} + + )} + + + + + + {isLoading || isLoadingFiles ? ( + + + Loading... + + ) : credentials.length === 0 ? ( + + No accounts connected. + + Connect a {getProviderName(provider)} account to continue. + + + ) : availableFiles.length === 0 ? ( + + No Excel files found. + + No .xlsx files were found in your OneDrive. + + + ) : null} + + + {/* Account selection - only show if we have multiple accounts */} + {credentials.length > 1 && ( + + + Switch Account + + {credentials.map((cred) => ( + setSelectedCredentialId(cred.id)} + > + + {getProviderIcon(cred.provider)} + {cred.name} + + {cred.id === selectedCredentialId && } + + ))} + + )} + + {/* Available Excel files - only show if we have credentials and files */} + {credentials.length > 0 && selectedCredentialId && availableFiles.length > 0 && ( + + + Excel Files + + {availableFiles.map((file) => ( + handleFileSelect(file)} + > + + {getFileIcon(file, 'sm')} + + {file.name} + {file.modifiedTime && ( + + Modified {new Date(file.modifiedTime).toLocaleDateString()} + + )} + + + {file.id === selectedFileId && } + + ))} + + )} + + {/* Connect account option - only show if no credentials */} + {credentials.length === 0 && ( + + + + {getProviderIcon(provider)} + Connect {getProviderName(provider)} account + + + + )} + + + + + + {/* File preview */} + {showPreview && selectedFile && ( + + + + + + + + + {getFileIcon(selectedFile, 'sm')} + + + + {selectedFile.name} + {selectedFile.modifiedTime && ( + + {new Date(selectedFile.modifiedTime).toLocaleDateString()} + + )} + + {selectedFile.webViewLink ? ( + e.stopPropagation()} + > + Open in OneDrive + + + ) : ( + e.stopPropagation()} + > + Open in OneDrive + + + )} + + + + )} + + + {showOAuthModal && ( + setShowOAuthModal(false)} + provider={provider} + toolName={getProviderName(provider)} + requiredScopes={requiredScopes} + serviceId={getServiceId()} + /> + )} + > + ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx index 1cea95426d..f71abcfdce 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx @@ -23,7 +23,7 @@ import { import { saveToStorage } from '@/stores/workflows/persistence' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' -const logger = new Logger('teams_message_selector') +const logger = new Logger('TeamsMessageSelector') export interface TeamsMessageInfo { id: string diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx index 5b2a74f71e..9f42f5c63b 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx @@ -14,6 +14,8 @@ import type { FileInfo } from './components/google-drive-picker' import { GoogleDrivePicker } from './components/google-drive-picker' import type { JiraIssueInfo } from './components/jira-issue-selector' import { JiraIssueSelector } from './components/jira-issue-selector' +import type { MicrosoftFileInfo } from './components/microsoft-file-selector' +import { MicrosoftFileSelector } from './components/microsoft-file-selector' import type { TeamsMessageInfo } from './components/teams-message-selector' import { TeamsMessageSelector } from './components/teams-message-selector' @@ -41,11 +43,9 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS const isJira = provider === 'jira' const isDiscord = provider === 'discord' const isMicrosoftTeams = provider === 'microsoft-teams' - + const isMicrosoftExcel = provider === 'microsoft-excel' // For Confluence and Jira, we need the domain and credentials const domain = isConfluence || isJira ? (getValue(blockId, 'domain') as string) || '' : '' - const _credentials = - isConfluence || isJira ? (getValue(blockId, 'credential') as string) || '' : '' // For Discord, we need the bot token and server ID const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : '' const serverId = isDiscord ? (getValue(blockId, 'serverId') as string) || '' : '' @@ -186,6 +186,38 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS ) } + if (isMicrosoftExcel) { + // Get credential using the same pattern as other tools + const credential = (getValue(blockId, 'credential') as string) || '' + + return ( + + + + + void} + /> + + + {!credential && ( + + Please select Microsoft Excel credentials first + + )} + + + ) + } + // Handle Microsoft Teams selector if (isMicrosoftTeams) { // Get credential using the same pattern as other tools diff --git a/apps/sim/blocks/blocks/microsoft_excel.ts b/apps/sim/blocks/blocks/microsoft_excel.ts new file mode 100644 index 0000000000..4eb2939a8e --- /dev/null +++ b/apps/sim/blocks/blocks/microsoft_excel.ts @@ -0,0 +1,215 @@ +import { MicrosoftExcelIcon } from '@/components/icons' +import type { + MicrosoftExcelReadResponse, + MicrosoftExcelTableAddResponse, + MicrosoftExcelWriteResponse, +} from '@/tools/microsoft_excel/types' +import type { BlockConfig } from '../types' + +type MicrosoftExcelResponse = + | MicrosoftExcelReadResponse + | MicrosoftExcelWriteResponse + | MicrosoftExcelTableAddResponse + +export const MicrosoftExcelBlock: BlockConfig = { + type: 'microsoft_excel', + name: 'Microsoft Excel', + description: 'Read, write, and update data', + longDescription: + 'Integrate Microsoft Excel functionality to manage spreadsheet data. Read data from specific ranges, write new data, update existing cells, and manipulate table data using OAuth authentication. Supports various input and output formats for flexible data handling.', + docsLink: 'https://docs.simstudio.ai/tools/microsoft_excel', + category: 'tools', + bgColor: '#E0E0E0', + icon: MicrosoftExcelIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Read Data', id: 'read' }, + { label: 'Write/Update Data', id: 'write' }, + { label: 'Add to Table', id: 'table_add' }, + ], + }, + { + id: 'credential', + title: 'Microsoft Account', + type: 'oauth-input', + layout: 'full', + provider: 'microsoft-excel', + serviceId: 'microsoft-excel', + requiredScopes: [], + placeholder: 'Select Microsoft account', + }, + { + id: 'spreadsheetId', + title: 'Select Sheet', + type: 'file-selector', + layout: 'full', + provider: 'microsoft-excel', + serviceId: 'microsoft-excel', + requiredScopes: [], + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + placeholder: 'Select a spreadsheet', + }, + { + id: 'manualSpreadsheetId', + title: 'Or Enter Spreadsheet ID Manually', + type: 'short-input', + layout: 'full', + placeholder: 'ID of the spreadsheet (from URL)', + condition: { field: 'spreadsheetId', value: '' }, + }, + { + id: 'range', + title: 'Range', + type: 'short-input', + layout: 'full', + placeholder: 'Sheet name and cell range (e.g., Sheet1!A1:D10)', + condition: { field: 'operation', value: ['read', 'write', 'update'] }, + }, + { + id: 'tableName', + title: 'Table Name', + type: 'short-input', + layout: 'full', + placeholder: 'Name of the Excel table', + condition: { field: 'operation', value: ['table_add'] }, + }, + { + 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 (e.g., [{"name":"John", "age":30}, {"name":"Jane", "age":25}])', + condition: { field: 'operation', value: 'write' }, + }, + { + id: 'valueInputOption', + title: 'Value Input Option', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' }, + { label: "Raw (Don't parse formulas)", id: 'RAW' }, + ], + condition: { field: 'operation', value: 'write' }, + }, + { + 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 (e.g., [{"name":"John", "age":30}, {"name":"Jane", "age":25}])', + condition: { field: 'operation', value: 'update' }, + }, + { + id: 'valueInputOption', + title: 'Value Input Option', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' }, + { label: "Raw (Don't parse formulas)", id: 'RAW' }, + ], + condition: { field: 'operation', value: 'update' }, + }, + { + 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 (e.g., [{"name":"John", "age":30}, {"name":"Jane", "age":25}])', + condition: { field: 'operation', value: 'table_add' }, + }, + ], + tools: { + access: ['microsoft_excel_read', 'microsoft_excel_write', 'microsoft_excel_table_add'], + config: { + tool: (params) => { + switch (params.operation) { + case 'read': + return 'microsoft_excel_read' + case 'write': + return 'microsoft_excel_write' + case 'table_add': + return 'microsoft_excel_table_add' + default: + throw new Error(`Invalid Microsoft Excel operation: ${params.operation}`) + } + }, + params: (params) => { + const { credential, values, spreadsheetId, manualSpreadsheetId, tableName, ...rest } = + params + + // Parse values from JSON string to array if it exists + let parsedValues + try { + parsedValues = values ? JSON.parse(values as string) : undefined + } catch (error) { + throw new Error('Invalid JSON format for values') + } + + // Use the selected spreadsheet ID or the manually entered one + const effectiveSpreadsheetId = (spreadsheetId || manualSpreadsheetId || '').trim() + + if (!effectiveSpreadsheetId) { + throw new Error( + 'Spreadsheet ID is required. Please select a spreadsheet or enter an ID manually.' + ) + } + + // For table operations, ensure tableName is provided + if (params.operation === 'table_add' && !tableName) { + throw new Error('Table name is required for table operations.') + } + + const baseParams = { + ...rest, + spreadsheetId: effectiveSpreadsheetId, + values: parsedValues, + credential, + } + + // Add table-specific parameters + if (params.operation === 'table_add') { + return { + ...baseParams, + tableName, + } + } + + return baseParams + }, + }, + }, + inputs: { + operation: { type: 'string', required: true }, + credential: { type: 'string', required: true }, + spreadsheetId: { type: 'string', required: false }, + manualSpreadsheetId: { type: 'string', required: false }, + range: { type: 'string', required: false }, + tableName: { type: 'string', required: false }, + values: { type: 'string', required: false }, + valueInputOption: { type: 'string', required: false }, + }, + outputs: { + response: { + type: { + data: 'json', + metadata: 'json', + updatedRange: 'string', + updatedRows: 'number', + updatedColumns: 'number', + updatedCells: 'number', + index: 'number', + values: 'json', + }, + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 448fcce52b..a20d9532b4 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -33,6 +33,7 @@ import { LinkupBlock } from './blocks/linkup' import { Mem0Block } from './blocks/mem0' // import { GuestyBlock } from './blocks/guesty' import { MemoryBlock } from './blocks/memory' +import { MicrosoftExcelBlock } from './blocks/microsoft_excel' import { MicrosoftTeamsBlock } from './blocks/microsoft_teams' import { MistralParseBlock } from './blocks/mistral_parse' import { NotionBlock } from './blocks/notion' @@ -84,6 +85,7 @@ export const registry: Record = { google_drive: GoogleDriveBlock, google_search: GoogleSearchBlock, google_sheets: GoogleSheetsBlock, + microsoft_excel: MicrosoftExcelBlock, microsoft_teams: MicrosoftTeamsBlock, // guesty: GuestyBlock, image_generator: ImageGeneratorBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index cb3b8405d7..5ba6ff1980 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2689,3 +2689,74 @@ export function OutlookIcon(props: SVGProps) { ) } + +export function MicrosoftExcelIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index cdf181e97d..7350f1ea65 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -396,6 +396,22 @@ export const auth = betterAuth({ redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/microsoft-teams`, }, + { + providerId: 'microsoft-excel', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'], + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + pkce: true, + redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/microsoft-excel`, + }, + { providerId: 'outlook', clientId: env.MICROSOFT_CLIENT_ID as string, diff --git a/apps/sim/lib/oauth.ts b/apps/sim/lib/oauth.ts index 8cf52e9348..994b86bb0d 100644 --- a/apps/sim/lib/oauth.ts +++ b/apps/sim/lib/oauth.ts @@ -12,6 +12,7 @@ import { GoogleSheetsIcon, JiraIcon, LinearIcon, + MicrosoftExcelIcon, MicrosoftIcon, MicrosoftTeamsIcon, NotionIcon, @@ -53,6 +54,7 @@ export type OAuthService = | 'notion' | 'jira' | 'discord' + | 'microsoft-excel' | 'microsoft-teams' | 'outlook' | 'linear' @@ -144,6 +146,15 @@ export const OAUTH_PROVIDERS: Record = { name: 'Microsoft', icon: (props) => MicrosoftIcon(props), services: { + 'microsoft-excel': { + id: 'microsoft-excel', + name: 'Microsoft Excel', + description: 'Connect to Microsoft Excel and manage spreadsheets.', + providerId: 'microsoft-excel', + icon: (props) => MicrosoftExcelIcon(props), + baseProviderIcon: (props) => MicrosoftIcon(props), + scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'], + }, 'microsoft-teams': { id: 'microsoft-teams', name: 'Microsoft Teams', diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 225cb51419..d793f48c1a 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -65,6 +65,12 @@ export function mergeSubblockState( {} as Record ) + // Return the full block state with updated subBlocks + acc[id] = { + ...block, + subBlocks: mergedSubBlocks, + } + // Add any values that exist in the store but aren't in the block structure // This handles cases where block config has been updated but values still exist Object.entries(blockValues).forEach(([subBlockId, value]) => { @@ -78,7 +84,7 @@ export function mergeSubblockState( } }) - // Return the full block state with updated subBlocks + // Update the block with the final merged subBlocks (including orphaned values) acc[id] = { ...block, subBlocks: mergedSubBlocks, diff --git a/apps/sim/tools/microsoft_excel/index.ts b/apps/sim/tools/microsoft_excel/index.ts new file mode 100644 index 0000000000..032f340b9a --- /dev/null +++ b/apps/sim/tools/microsoft_excel/index.ts @@ -0,0 +1,7 @@ +import { readTool } from './read' +import { tableAddTool } from './table_add' +import { writeTool } from './write' + +export const microsoftExcelReadTool = readTool +export const microsoftExcelTableAddTool = tableAddTool +export const microsoftExcelWriteTool = writeTool diff --git a/apps/sim/tools/microsoft_excel/read.ts b/apps/sim/tools/microsoft_excel/read.ts new file mode 100644 index 0000000000..63a1575d93 --- /dev/null +++ b/apps/sim/tools/microsoft_excel/read.ts @@ -0,0 +1,127 @@ +import type { ToolConfig } from '../types' +import type { MicrosoftExcelReadResponse, MicrosoftExcelToolParams } from './types' + +export const readTool: ToolConfig = { + id: 'microsoft_excel_read', + name: 'Read from Microsoft Excel', + description: 'Read data from a Microsoft Excel spreadsheet', + version: '1.0', + oauth: { + required: true, + provider: 'microsoft-excel', + additionalScopes: [], + }, + params: { + accessToken: { + type: 'string', + required: true, + description: 'The access token for the Microsoft Excel API', + }, + spreadsheetId: { + type: 'string', + required: true, + description: 'The ID of the spreadsheet to read from', + }, + range: { type: 'string', required: false, description: 'The range of cells to read from' }, + }, + request: { + url: (params) => { + const spreadsheetId = params.spreadsheetId?.trim() + if (!spreadsheetId) { + throw new Error('Spreadsheet ID is required') + } + + if (!params.range) { + return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('Sheet1')/range(address='A1:Z1000')` + } + + 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(match[1]) + const address = encodeURIComponent(match[2]) + + return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/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}`, + } + }, + }, + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorJson = await response.json().catch(() => ({ error: response.statusText })) + const errorText = + errorJson.error && typeof errorJson.error === 'object' + ? errorJson.error.message || JSON.stringify(errorJson.error) + : errorJson.error || response.statusText + throw new Error(`Failed to read Microsoft Excel data: ${errorText}`) + } + + const data = await response.json() + + const urlParts = response.url.split('/drive/items/') + const spreadsheetId = urlParts[1]?.split('/')[0] || '' + + const metadata = { + spreadsheetId, + properties: {}, + spreadsheetUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`, + } + + const result: MicrosoftExcelReadResponse = { + success: true, + output: { + data: { + range: data.range || '', + values: data.values || [], + }, + metadata: { + spreadsheetId: metadata.spreadsheetId, + spreadsheetUrl: metadata.spreadsheetUrl, + }, + }, + } + + return result + }, + transformError: (error) => { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'object' && error !== null) { + if (error.error) { + if (typeof error.error === 'string') { + return error.error + } + if (typeof error.error === 'object' && error.error.message) { + return error.error.message + } + return JSON.stringify(error.error) + } + + if (error.message) { + return error.message + } + + try { + return `Microsoft Excel API error: ${JSON.stringify(error)}` + } catch (_e) { + return 'Microsoft Excel API error: Unable to parse error details' + } + } + + return 'An error occurred while reading from Microsoft Excel' + }, +} diff --git a/apps/sim/tools/microsoft_excel/table_add.ts b/apps/sim/tools/microsoft_excel/table_add.ts new file mode 100644 index 0000000000..2ad474fbbb --- /dev/null +++ b/apps/sim/tools/microsoft_excel/table_add.ts @@ -0,0 +1,139 @@ +import type { ToolConfig } from '../types' +import type { MicrosoftExcelTableAddResponse, MicrosoftExcelTableToolParams } from './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', + oauth: { + required: true, + provider: 'microsoft-excel', + additionalScopes: [], + }, + params: { + accessToken: { + type: 'string', + required: true, + description: 'The access token for the Microsoft Excel API', + }, + spreadsheetId: { + type: 'string', + required: true, + description: 'The ID of the spreadsheet containing the table', + }, + tableName: { + type: 'string', + required: true, + description: 'The name of the table to add rows to', + }, + values: { + type: 'array', + required: true, + description: 'The data to add to the table (array of arrays or array of objects)', + }, + }, + request: { + url: (params) => { + const tableName = encodeURIComponent(params.tableName) + return `https://graph.microsoft.com/v1.0/me/drive/items/${params.spreadsheetId}/workbook/tables('${tableName}')/rows/add` + }, + method: 'POST', + 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() + 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) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Failed to add rows to Microsoft Excel table: ${errorText}`) + } + + const data = await response.json() + + const urlParts = response.url.split('/drive/items/') + const spreadsheetId = urlParts[1]?.split('/')[0] || '' + + const metadata = { + spreadsheetId, + spreadsheetUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`, + } + + const result = { + success: true, + output: { + index: data.index || 0, + values: data.values || [], + metadata: { + spreadsheetId: metadata.spreadsheetId, + spreadsheetUrl: metadata.spreadsheetUrl, + }, + }, + } + + return result + }, + transformError: (error) => { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'object' && error !== null) { + if (error.error) { + return typeof error.error === 'string' ? error.error : JSON.stringify(error.error) + } + if (error.message) { + return error.message + } + } + + return 'An error occurred while adding rows to Microsoft Excel table' + }, +} diff --git a/apps/sim/tools/microsoft_excel/types.ts b/apps/sim/tools/microsoft_excel/types.ts new file mode 100644 index 0000000000..ad4c8f354d --- /dev/null +++ b/apps/sim/tools/microsoft_excel/types.ts @@ -0,0 +1,69 @@ +import type { ToolResponse } from '../types' + +// Type for Excel cell values - covers all valid data types that Excel supports +export type ExcelCellValue = string | number | boolean | null + +export interface MicrosoftExcelRange { + sheetId?: number + sheetName?: string + range: string + values: ExcelCellValue[][] +} + +export interface MicrosoftExcelMetadata { + spreadsheetId: string + spreadsheetUrl?: string + title?: string + sheets?: { + sheetId: number + title: string + index: number + rowCount?: number + columnCount?: number + }[] +} + +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 MicrosoftExcelToolParams { + accessToken: string + spreadsheetId: string + range?: string + values?: ExcelCellValue[][] + valueInputOption?: 'RAW' | 'USER_ENTERED' + insertDataOption?: 'OVERWRITE' | 'INSERT_ROWS' + includeValuesInResponse?: boolean + responseValueRenderOption?: 'FORMATTED_VALUE' | 'UNFORMATTED_VALUE' | 'FORMULA' + majorDimension?: 'ROWS' | 'COLUMNS' +} + +export interface MicrosoftExcelTableToolParams { + accessToken: string + spreadsheetId: string + tableName: string + values: ExcelCellValue[][] + rowIndex?: number +} diff --git a/apps/sim/tools/microsoft_excel/write.ts b/apps/sim/tools/microsoft_excel/write.ts new file mode 100644 index 0000000000..83e74eed70 --- /dev/null +++ b/apps/sim/tools/microsoft_excel/write.ts @@ -0,0 +1,162 @@ +import type { ToolConfig } from '../types' +import type { MicrosoftExcelToolParams, MicrosoftExcelWriteResponse } from './types' + +export const writeTool: ToolConfig = { + id: 'microsoft_excel_write', + name: 'Write to Microsoft Excel', + description: 'Write data to a Microsoft Excel spreadsheet', + version: '1.0', + oauth: { + required: true, + provider: 'microsoft-excel', + additionalScopes: [], + }, + params: { + accessToken: { + type: 'string', + required: true, + description: 'The access token for the Microsoft Excel API', + }, + spreadsheetId: { + type: 'string', + required: true, + description: 'The ID of the spreadsheet to write to', + }, + range: { type: 'string', required: false, description: 'The range of cells to write to' }, + values: { type: 'array', required: true, description: 'The data to write to the spreadsheet' }, + valueInputOption: { + type: 'string', + required: false, + description: 'The format of the data to write', + }, + includeValuesInResponse: { + type: 'boolean', + required: false, + description: 'Whether to include the written values in the response', + }, + }, + request: { + url: (params) => { + 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(match[1]) + const address = encodeURIComponent(match[2]) + + const url = new URL( + `https://graph.microsoft.com/v1.0/me/drive/items/${params.spreadsheetId}/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() + 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 = { + majorDimension: params.majorDimension || 'ROWS', + values: processedValues, + } + + if (params.range) { + body.range = params.range + } + + return body + }, + }, + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Failed to write data to Microsoft Excel: ${errorText}`) + } + + const data = await response.json() + + const urlParts = response.url.split('/drive/items/') + const spreadsheetId = urlParts[1]?.split('/')[0] || '' + + const metadata = { + spreadsheetId, + properties: {}, + spreadsheetUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`, + } + + const result = { + success: true, + output: { + updatedRange: data.updatedRange, + updatedRows: data.updatedRows, + updatedColumns: data.updatedColumns, + updatedCells: data.updatedCells, + metadata: { + spreadsheetId: metadata.spreadsheetId, + spreadsheetUrl: metadata.spreadsheetUrl, + }, + }, + } + + return result + }, + transformError: (error) => { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'object' && error !== null) { + if (error.error) { + return typeof error.error === 'string' ? error.error : JSON.stringify(error.error) + } + if (error.message) { + return error.message + } + } + + return 'An error occurred while writing to Microsoft Excel' + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2f701703d7..1b56d26370 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -49,6 +49,11 @@ import { linearCreateIssueTool, linearReadIssuesTool } from './linear' import { linkupSearchTool } from './linkup' import { mem0AddMemoriesTool, mem0GetMemoriesTool, mem0SearchMemoriesTool } from './mem0' import { memoryAddTool, memoryDeleteTool, memoryGetAllTool, memoryGetTool } from './memory' +import { + microsoftExcelReadTool, + microsoftExcelTableAddTool, + microsoftExcelWriteTool, +} from './microsoft_excel' import { microsoftTeamsReadChannelTool, microsoftTeamsReadChatTool, @@ -190,4 +195,7 @@ export const tools: Record = { outlook_draft: outlookDraftTool, linear_read_issues: linearReadIssuesTool, linear_create_issue: linearCreateIssueTool, + microsoft_excel_read: microsoftExcelReadTool, + microsoft_excel_write: microsoftExcelWriteTool, + microsoft_excel_table_add: microsoftExcelTableAddTool, }
No accounts connected.
+ Connect a {getProviderName(provider)} account to continue. +
No Excel files found.
+ No .xlsx files were found in your OneDrive. +
Please select Microsoft Excel credentials first