mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(tools): added microsoft excel tools/block (#441)
* got the read excel tool working * added read manual ID insertion * write working * tool finished * removed old logic * removed logic that was causing credential error * cleaned up * ran bun lint * removed logger * added back merging logic * fix: removed comments * fix: added greptile changes * ran bun lint * removed function * added microsoft excel docs * fix: icon format * fix: docs * ran lint --------- Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
This commit is contained in:
@@ -9,7 +9,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
type="linear"
|
||||
color="#5E6AD2"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 100 100"><path fill="#fff" d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"/></svg>`}
|
||||
iconSvg={`<svg className="block-icon"
|
||||
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='currentColor'
|
||||
|
||||
|
||||
viewBox='0 0 100 100'
|
||||
>
|
||||
<path
|
||||
fill='currentColor'
|
||||
d='M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z'
|
||||
/>
|
||||
</svg>`}
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"linkup",
|
||||
"mem0",
|
||||
"memory",
|
||||
"microsoft_excel",
|
||||
"microsoft_teams",
|
||||
"mistral_parse",
|
||||
"notion",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
title: Microsoft Excel
|
||||
description: Read, write, and update data
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="microsoft_excel"
|
||||
color="#E0E0E0"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon"
|
||||
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
version='1.1'
|
||||
id='Livello_1'
|
||||
x='0px'
|
||||
y='0px'
|
||||
viewBox='0 0 2289.75 2130'
|
||||
enableBackground='new 0 0 2289.75 2130'
|
||||
>
|
||||
<path
|
||||
fill='#185C37'
|
||||
d='M1437.75,1011.75L532.5,852v1180.393c0,53.907,43.7,97.607,97.607,97.607l0,0h1562.036 c53.907,0,97.607-43.7,97.607-97.607l0,0V1597.5L1437.75,1011.75z'
|
||||
/>
|
||||
<path
|
||||
fill='#21A366'
|
||||
d='M1437.75,0H630.107C576.2,0,532.5,43.7,532.5,97.607c0,0,0,0,0,0V532.5l905.25,532.5L1917,1224.75 L2289.75,1065V532.5L1437.75,0z'
|
||||
/>
|
||||
<path fill='#107C41' d='M532.5,532.5h905.25V1065H532.5V532.5z' />
|
||||
<path
|
||||
opacity='0.1'
|
||||
enableBackground='new'
|
||||
d='M1180.393,426H532.5v1331.25h647.893c53.834-0.175,97.432-43.773,97.607-97.607 V523.607C1277.825,469.773,1234.227,426.175,1180.393,426z'
|
||||
/>
|
||||
<path
|
||||
opacity='0.2'
|
||||
enableBackground='new'
|
||||
d='M1127.143,479.25H532.5V1810.5h594.643 c53.834-0.175,97.432-43.773,97.607-97.607V576.857C1224.575,523.023,1180.977,479.425,1127.143,479.25z'
|
||||
/>
|
||||
<path
|
||||
opacity='0.2'
|
||||
enableBackground='new'
|
||||
d='M1127.143,479.25H532.5V1704h594.643c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1224.575,523.023,1180.977,479.425,1127.143,479.25z'
|
||||
/>
|
||||
<path
|
||||
opacity='0.2'
|
||||
enableBackground='new'
|
||||
d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z'
|
||||
/>
|
||||
<linearGradient
|
||||
id='SVGID_1_'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='203.5132'
|
||||
y1='1729.0183'
|
||||
x2='967.9868'
|
||||
y2='404.9817'
|
||||
gradientTransform='matrix(1 0 0 -1 0 2132)'
|
||||
>
|
||||
<stop offset='0' style={{ stopColor: '#18884F' }} />
|
||||
<stop offset='0.5' style={{ stopColor: '#117E43' }} />
|
||||
<stop offset='1' style={{ stopColor: '#0B6631' }} />
|
||||
</linearGradient>
|
||||
<path
|
||||
fill='url(#SVGID_1_)'
|
||||
d='M97.607,479.25h976.285c53.907,0,97.607,43.7,97.607,97.607v976.285 c0,53.907-43.7,97.607-97.607,97.607H97.607C43.7,1650.75,0,1607.05,0,1553.143V576.857C0,522.95,43.7,479.25,97.607,479.25z'
|
||||
/>
|
||||
<path
|
||||
fill='#FFFFFF'
|
||||
d='M302.3,1382.264l205.332-318.169L319.5,747.683h151.336l102.666,202.35 c9.479,19.223,15.975,33.494,19.49,42.919h1.331c6.745-15.336,13.845-30.228,21.3-44.677L725.371,747.79h138.929l-192.925,314.548 L869.2,1382.263H721.378L602.79,1160.158c-5.586-9.45-10.326-19.376-14.164-29.66h-1.757c-3.474,10.075-8.083,19.722-13.739,28.755 l-122.102,223.011H302.3z'
|
||||
/>
|
||||
<path
|
||||
fill='#33C481'
|
||||
d='M2192.143,0H1437.75v532.5h852V97.607C2289.75,43.7,2246.05,0,2192.143,0L2192.143,0z'
|
||||
/>
|
||||
<path fill='#107C41' d='M1437.75,1065h852v532.5h-852V1065z' />
|
||||
</svg>`}
|
||||
/>
|
||||
|
||||
{/* 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`
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
+582
@@ -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<Credential[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedFileId, setSelectedFileId] = useState(value)
|
||||
const [selectedFile, setSelectedFile] = useState<MicrosoftFileInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false)
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false)
|
||||
const [availableFiles, setAvailableFiles] = useState<MicrosoftFileInfo[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
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<string>('pending_service_id', effectiveServiceId)
|
||||
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
|
||||
saveToStorage<string>('pending_oauth_return_url', window.location.href)
|
||||
saveToStorage<string>('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 <ExternalLink className='h-4 w-4' />
|
||||
}
|
||||
|
||||
// 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 <MicrosoftExcelIcon className={`${iconSize} text-green-600`} />
|
||||
}
|
||||
// if (file.mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
||||
// return <FileIcon className={`${iconSize} text-blue-600`} />
|
||||
// }
|
||||
// if (file.mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
|
||||
// return <FileIcon className={`${iconSize} text-orange-600`} />
|
||||
// }
|
||||
// return <FileIcon className={`${iconSize} text-muted-foreground`} />
|
||||
}
|
||||
|
||||
// Handle search input changes
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen)
|
||||
if (!isOpen) {
|
||||
setSearchQuery('') // Clear search when popover closes
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className='truncate font-normal'>{selectedFile.name}</span>
|
||||
</div>
|
||||
) : selectedFileId && (isLoadingSelectedFile || !selectedCredentialId) ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='text-muted-foreground'>Loading document...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search Excel files...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading || isLoadingFiles ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading...</span>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a {getProviderName(provider)} account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : availableFiles.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No Excel files found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No .xlsx files were found in your OneDrive.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(cred.provider)}
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Available Excel files - only show if we have credentials and files */}
|
||||
{credentials.length > 0 && selectedCredentialId && availableFiles.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Excel Files
|
||||
</div>
|
||||
{availableFiles.map((file) => (
|
||||
<CommandItem
|
||||
key={file.id}
|
||||
value={`file-${file.id}-${file.name}`}
|
||||
onSelect={() => handleFileSelect(file)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
{getFileIcon(file, 'sm')}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<span className='truncate font-normal'>{file.name}</span>
|
||||
{file.modifiedTime && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
Modified {new Date(file.modifiedTime).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{file.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
{getProviderIcon(provider)}
|
||||
<span>Connect {getProviderName(provider)} account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedFile.name}</h4>
|
||||
{selectedFile.modifiedTime && (
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedFile.webViewLink ? (
|
||||
<a
|
||||
href={selectedFile.webViewLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-primary text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in OneDrive</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={`https://graph.microsoft.com/v1.0/me/drive/items/${selectedFile.id}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-primary text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in OneDrive</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
<OAuthRequiredModal
|
||||
isOpen={showOAuthModal}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
provider={provider}
|
||||
toolName={getProviderName(provider)}
|
||||
requiredScopes={requiredScopes}
|
||||
serviceId={getServiceId()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+35
-3
@@ -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 (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={selectedFileId}
|
||||
onChange={handleFileChange}
|
||||
provider='microsoft-excel'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Excel file'}
|
||||
disabled={disabled || !credential}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Microsoft Excel credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft Teams selector
|
||||
if (isMicrosoftTeams) {
|
||||
// Get credential using the same pattern as other tools
|
||||
|
||||
@@ -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<MicrosoftExcelResponse> = {
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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<string, BlockConfig> = {
|
||||
google_drive: GoogleDriveBlock,
|
||||
google_search: GoogleSearchBlock,
|
||||
google_sheets: GoogleSheetsBlock,
|
||||
microsoft_excel: MicrosoftExcelBlock,
|
||||
microsoft_teams: MicrosoftTeamsBlock,
|
||||
// guesty: GuestyBlock,
|
||||
image_generator: ImageGeneratorBlock,
|
||||
|
||||
@@ -2689,3 +2689,74 @@ export function OutlookIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MicrosoftExcelIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
version='1.1'
|
||||
id='Livello_1'
|
||||
x='0px'
|
||||
y='0px'
|
||||
viewBox='0 0 2289.75 2130'
|
||||
enableBackground='new 0 0 2289.75 2130'
|
||||
>
|
||||
<path
|
||||
fill='#185C37'
|
||||
d='M1437.75,1011.75L532.5,852v1180.393c0,53.907,43.7,97.607,97.607,97.607l0,0h1562.036 c53.907,0,97.607-43.7,97.607-97.607l0,0V1597.5L1437.75,1011.75z'
|
||||
/>
|
||||
<path
|
||||
fill='#21A366'
|
||||
d='M1437.75,0H630.107C576.2,0,532.5,43.7,532.5,97.607c0,0,0,0,0,0V532.5l905.25,532.5L1917,1224.75 L2289.75,1065V532.5L1437.75,0z'
|
||||
/>
|
||||
<path fill='#107C41' d='M532.5,532.5h905.25V1065H532.5V532.5z' />
|
||||
<path
|
||||
opacity='0.1'
|
||||
enableBackground='new'
|
||||
d='M1180.393,426H532.5v1331.25h647.893c53.834-0.175,97.432-43.773,97.607-97.607 V523.607C1277.825,469.773,1234.227,426.175,1180.393,426z'
|
||||
/>
|
||||
<path
|
||||
opacity='0.2'
|
||||
enableBackground='new'
|
||||
d='M1127.143,479.25H532.5V1810.5h594.643 c53.834-0.175,97.432-43.773,97.607-97.607V576.857C1224.575,523.023,1180.977,479.425,1127.143,479.25z'
|
||||
/>
|
||||
<path
|
||||
opacity='0.2'
|
||||
enableBackground='new'
|
||||
d='M1127.143,479.25H532.5V1704h594.643c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1224.575,523.023,1180.977,479.425,1127.143,479.25z'
|
||||
/>
|
||||
<path
|
||||
opacity='0.2'
|
||||
enableBackground='new'
|
||||
d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z'
|
||||
/>
|
||||
<linearGradient
|
||||
id='SVGID_1_'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='203.5132'
|
||||
y1='1729.0183'
|
||||
x2='967.9868'
|
||||
y2='404.9817'
|
||||
gradientTransform='matrix(1 0 0 -1 0 2132)'
|
||||
>
|
||||
<stop offset='0' style={{ stopColor: '#18884F' }} />
|
||||
<stop offset='0.5' style={{ stopColor: '#117E43' }} />
|
||||
<stop offset='1' style={{ stopColor: '#0B6631' }} />
|
||||
</linearGradient>
|
||||
<path
|
||||
fill='url(#SVGID_1_)'
|
||||
d='M97.607,479.25h976.285c53.907,0,97.607,43.7,97.607,97.607v976.285 c0,53.907-43.7,97.607-97.607,97.607H97.607C43.7,1650.75,0,1607.05,0,1553.143V576.857C0,522.95,43.7,479.25,97.607,479.25z'
|
||||
/>
|
||||
<path
|
||||
fill='#FFFFFF'
|
||||
d='M302.3,1382.264l205.332-318.169L319.5,747.683h151.336l102.666,202.35 c9.479,19.223,15.975,33.494,19.49,42.919h1.331c6.745-15.336,13.845-30.228,21.3-44.677L725.371,747.79h138.929l-192.925,314.548 L869.2,1382.263H721.378L602.79,1160.158c-5.586-9.45-10.326-19.376-14.164-29.66h-1.757c-3.474,10.075-8.083,19.722-13.739,28.755 l-122.102,223.011H302.3z'
|
||||
/>
|
||||
<path
|
||||
fill='#33C481'
|
||||
d='M2192.143,0H1437.75v532.5h852V97.607C2289.75,43.7,2246.05,0,2192.143,0L2192.143,0z'
|
||||
/>
|
||||
<path fill='#107C41' d='M1437.75,1065h852v532.5h-852V1065z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, OAuthProviderConfig> = {
|
||||
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',
|
||||
|
||||
@@ -65,6 +65,12 @@ export function mergeSubblockState(
|
||||
{} as Record<string, SubBlockState>
|
||||
)
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ToolConfig } from '../types'
|
||||
import type { MicrosoftExcelReadResponse, MicrosoftExcelToolParams } from './types'
|
||||
|
||||
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',
|
||||
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'
|
||||
},
|
||||
}
|
||||
@@ -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<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) => {
|
||||
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'
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { ToolConfig } from '../types'
|
||||
import type { MicrosoftExcelToolParams, MicrosoftExcelWriteResponse } from './types'
|
||||
|
||||
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',
|
||||
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<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: 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'
|
||||
},
|
||||
}
|
||||
@@ -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<string, ToolConfig> = {
|
||||
outlook_draft: outlookDraftTool,
|
||||
linear_read_issues: linearReadIssuesTool,
|
||||
linear_create_issue: linearCreateIssueTool,
|
||||
microsoft_excel_read: microsoftExcelReadTool,
|
||||
microsoft_excel_write: microsoftExcelWriteTool,
|
||||
microsoft_excel_table_add: microsoftExcelTableAddTool,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user