chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+424
View File
@@ -0,0 +1,424 @@
import type {
GoogleSheetsAppendResponse,
GoogleSheetsToolParams,
GoogleSheetsV2AppendResponse,
GoogleSheetsV2ToolParams,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const appendTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsAppendResponse> = {
id: 'google_sheets_append',
name: 'Append to Google Sheets',
description: 'Append data to the end of a Google Sheets spreadsheet',
version: '1.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the spreadsheet to append to',
},
range: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The A1 notation range to append after (e.g. "Sheet1", "Sheet1!A:D")',
},
values: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description:
'The data to append as a 2D array (e.g. [["Alice", 30], ["Bob", 25]]) or array of objects.',
},
valueInputOption: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The format of the data to append',
},
insertDataOption: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'How to insert the data (OVERWRITE or INSERT_ROWS)',
},
includeValuesInResponse: {
type: 'boolean',
required: false,
visibility: 'hidden',
description: 'Whether to include the appended values in the response',
},
},
request: {
url: (params) => {
// If range is not provided, use a default range for the first sheet
const range = params.range || 'Sheet1'
const url = new URL(
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(range)}:append`
)
// Default to USER_ENTERED if not specified
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
url.searchParams.append('valueInputOption', valueInputOption)
// Only send insertDataOption when the user provides it; the API defaults to OVERWRITE
if (params.insertDataOption) {
url.searchParams.append('insertDataOption', params.insertDataOption)
}
if (params.includeValuesInResponse) {
url.searchParams.append('includeValuesInResponse', 'true')
}
return url.toString()
},
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
let processedValues: any = params.values || []
// Handle case where values might be a string (potentially JSON string)
if (typeof processedValues === 'string') {
try {
// Try to parse it as JSON
processedValues = JSON.parse(processedValues)
} catch (_error) {
// If the input contains literal newlines causing JSON parse to fail,
// try a more robust approach
try {
// Replace literal newlines with escaped newlines for JSON parsing
const sanitizedInput = (processedValues as string)
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
// Try to parse again with sanitized input
processedValues = JSON.parse(sanitizedInput)
} catch (_secondError) {
// If all parsing attempts fail, wrap as a single cell value
processedValues = [[processedValues]]
}
}
}
// New logic to handle array of objects
if (
Array.isArray(processedValues) &&
processedValues.length > 0 &&
typeof processedValues[0] === 'object' &&
!Array.isArray(processedValues[0])
) {
// It's an array of objects
// First, extract all unique keys from all objects to create headers
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)
// Then create rows with object values in the order of headers
const rows = processedValues.map((obj: any) => {
if (!obj || typeof obj !== 'object') {
// Handle non-object items by creating an array with empty values
return Array(headers.length).fill('')
}
return headers.map((key) => {
const value = obj[key]
// Handle nested objects/arrays by converting to JSON string
if (value !== null && typeof value === 'object') {
return JSON.stringify(value)
}
return value === undefined ? '' : value
})
})
// Add headers as the first row, then add data rows
processedValues = [headers, ...rows]
}
// Continue with existing logic for other array types
else if (!Array.isArray(processedValues)) {
processedValues = [[String(processedValues)]]
} else if (!processedValues.every((item: any) => Array.isArray(item))) {
// If it's an array but not all elements are arrays, wrap each element
processedValues = (processedValues as any[]).map((row: any) =>
Array.isArray(row) ? row : [String(row)]
)
}
const body: Record<string, any> = {
majorDimension: params.majorDimension || 'ROWS',
values: processedValues,
}
// Only include range if it's provided
if (params.range) {
body.range = params.range
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
// Extract spreadsheet ID from the URL (guard if url is missing)
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
// Create a simple metadata object with just the ID and URL
const metadata = {
spreadsheetId,
properties: {},
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
}
const result = {
success: true,
output: {
tableRange: data.tableRange || '',
updatedRange: data.updates?.updatedRange || '',
updatedRows: data.updates?.updatedRows || 0,
updatedColumns: data.updates?.updatedColumns || 0,
updatedCells: data.updates?.updatedCells || 0,
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
},
},
}
return result
},
outputs: {
tableRange: { type: 'string', description: 'Range of the table where data was appended' },
updatedRange: { type: 'string', description: 'Range of cells that were updated' },
updatedRows: { type: 'number', description: 'Number of rows updated' },
updatedColumns: { type: 'number', description: 'Number of columns updated' },
updatedCells: { type: 'number', description: 'Number of cells updated' },
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
export const appendV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2AppendResponse> = {
id: 'google_sheets_append_v2',
name: 'Append to Google Sheets V2',
description: 'Append data to the end of a specific sheet in a Google Sheets spreadsheet',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
sheetName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The name of the sheet/tab to append to',
},
values: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description:
'The data to append as a 2D array (e.g. [["Alice", 30], ["Bob", 25]]) or array of objects.',
},
valueInputOption: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The format of the data to append',
},
insertDataOption: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'How to insert the data (OVERWRITE or INSERT_ROWS)',
},
includeValuesInResponse: {
type: 'boolean',
required: false,
visibility: 'hidden',
description: 'Whether to include the appended values in the response',
},
},
request: {
url: (params) => {
const sheetName = params.sheetName?.trim()
if (!sheetName) {
throw new Error('Sheet name is required')
}
const url = new URL(
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(sheetName)}:append`
)
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
url.searchParams.append('valueInputOption', valueInputOption)
if (params.insertDataOption) {
url.searchParams.append('insertDataOption', params.insertDataOption)
}
if (params.includeValuesInResponse) {
url.searchParams.append('includeValuesInResponse', 'true')
}
return url.toString()
},
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
let processedValues: any = params.values || []
if (typeof processedValues === 'string') {
try {
processedValues = JSON.parse(processedValues)
} catch (_error) {
try {
const sanitizedInput = (processedValues as string)
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
processedValues = JSON.parse(sanitizedInput)
} catch (_secondError) {
processedValues = [[processedValues]]
}
}
}
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]
} else if (!Array.isArray(processedValues)) {
processedValues = [[String(processedValues)]]
} else if (!processedValues.every((item: any) => Array.isArray(item))) {
processedValues = (processedValues as any[]).map((row: any) =>
Array.isArray(row) ? row : [String(row)]
)
}
const body: Record<string, any> = {
majorDimension: params.majorDimension || 'ROWS',
values: processedValues,
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const metadata = {
spreadsheetId,
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
}
return {
success: true,
output: {
tableRange: data.tableRange ?? '',
updatedRange: data.updates?.updatedRange ?? '',
updatedRows: data.updates?.updatedRows ?? 0,
updatedColumns: data.updates?.updatedColumns ?? 0,
updatedCells: data.updates?.updatedCells ?? 0,
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
},
},
}
},
outputs: {
tableRange: { type: 'string', description: 'Range of the table where data was appended' },
updatedRange: { type: 'string', description: 'Range of cells that were updated' },
updatedRows: { type: 'number', description: 'Number of rows updated' },
updatedColumns: { type: 'number', description: 'Number of columns updated' },
updatedCells: { type: 'number', description: 'Number of cells updated' },
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
+112
View File
@@ -0,0 +1,112 @@
import type {
GoogleSheetsV2BatchClearParams,
GoogleSheetsV2BatchClearResponse,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const batchClearV2Tool: ToolConfig<
GoogleSheetsV2BatchClearParams,
GoogleSheetsV2BatchClearResponse
> = {
id: 'google_sheets_batch_clear_v2',
name: 'Batch Clear Google Sheets V2',
description: 'Clear multiple ranges in a Google Sheets spreadsheet in a single request',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
ranges: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description:
'Array of ranges to clear (e.g., ["Sheet1!A1:D10", "Sheet2!A1:B5"]). Each range should include sheet name.',
},
},
request: {
url: (params) => {
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values:batchClear`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const ranges = params.ranges
if (!ranges || !Array.isArray(ranges) || ranges.length === 0) {
throw new Error('At least one range is required')
}
return {
ranges,
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const spreadsheetId = data.spreadsheetId ?? ''
const clearedRanges = data.clearedRanges ?? []
return {
success: true,
output: {
spreadsheetId,
clearedRanges,
metadata: {
spreadsheetId,
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
},
},
}
},
outputs: {
spreadsheetId: { type: 'string', description: 'The spreadsheet ID' },
clearedRanges: {
type: 'array',
description: 'Array of ranges that were cleared',
items: {
type: 'string',
},
},
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
+143
View File
@@ -0,0 +1,143 @@
import type {
GoogleSheetsV2BatchGetParams,
GoogleSheetsV2BatchGetResponse,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const batchGetV2Tool: ToolConfig<
GoogleSheetsV2BatchGetParams,
GoogleSheetsV2BatchGetResponse
> = {
id: 'google_sheets_batch_get_v2',
name: 'Batch Read Google Sheets V2',
description: 'Read multiple ranges from a Google Sheets spreadsheet in a single request',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
ranges: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description:
'Array of ranges to read (e.g., ["Sheet1!A1:D10", "Sheet2!A1:B5"]). Each range should include sheet name.',
},
majorDimension: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The major dimension of values: "ROWS" (default) or "COLUMNS"',
},
valueRenderOption: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'How values should be rendered: "FORMATTED_VALUE" (default), "UNFORMATTED_VALUE", or "FORMULA"',
},
},
request: {
url: (params) => {
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
const ranges = params.ranges
if (!ranges || !Array.isArray(ranges) || ranges.length === 0) {
throw new Error('At least one range is required')
}
const queryParams = new URLSearchParams()
ranges.forEach((range: string) => {
queryParams.append('ranges', range)
})
if (params.majorDimension) {
queryParams.append('majorDimension', params.majorDimension)
}
if (params.valueRenderOption) {
queryParams.append('valueRenderOption', params.valueRenderOption)
}
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values:batchGet?${queryParams.toString()}`
},
method: 'GET',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const valueRanges =
data.valueRanges?.map((vr: any) => ({
range: vr.range ?? '',
majorDimension: vr.majorDimension ?? 'ROWS',
values: vr.values ?? [],
})) ?? []
const spreadsheetId = data.spreadsheetId ?? ''
return {
success: true,
output: {
spreadsheetId,
valueRanges,
metadata: {
spreadsheetId,
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
},
},
}
},
outputs: {
spreadsheetId: { type: 'string', description: 'The spreadsheet ID' },
valueRanges: {
type: 'array',
description: 'Array of value ranges read from the spreadsheet',
items: {
type: 'object',
properties: {
range: { type: 'string', description: 'The range that was read' },
majorDimension: { type: 'string', description: 'Major dimension (ROWS or COLUMNS)' },
values: { type: 'array', description: 'The cell values as a 2D array' },
},
},
},
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
@@ -0,0 +1,149 @@
import type {
GoogleSheetsV2BatchUpdateParams,
GoogleSheetsV2BatchUpdateResponse,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const batchUpdateV2Tool: ToolConfig<
GoogleSheetsV2BatchUpdateParams,
GoogleSheetsV2BatchUpdateResponse
> = {
id: 'google_sheets_batch_update_v2',
name: 'Batch Update Google Sheets V2',
description: 'Update multiple ranges in a Google Sheets spreadsheet in a single request',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
data: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description:
'Array of value ranges to update. Each item should have "range" (e.g., "Sheet1!A1:D10") and "values" (2D array).',
},
valueInputOption: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'How input data should be interpreted: "RAW" or "USER_ENTERED" (default). USER_ENTERED parses formulas.',
},
},
request: {
url: (params) => {
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const data = params.data
if (!data || !Array.isArray(data) || data.length === 0) {
throw new Error('At least one data range is required')
}
return {
valueInputOption: params.valueInputOption ?? 'USER_ENTERED',
data: data.map((item: any) => ({
range: item.range,
values: item.values,
})),
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const responses =
data.responses?.map((r: any) => ({
spreadsheetId: r.spreadsheetId ?? '',
updatedRange: r.updatedRange ?? '',
updatedRows: r.updatedRows ?? 0,
updatedColumns: r.updatedColumns ?? 0,
updatedCells: r.updatedCells ?? 0,
})) ?? []
const spreadsheetId = data.spreadsheetId ?? ''
return {
success: true,
output: {
spreadsheetId,
totalUpdatedRows: data.totalUpdatedRows ?? 0,
totalUpdatedColumns: data.totalUpdatedColumns ?? 0,
totalUpdatedCells: data.totalUpdatedCells ?? 0,
totalUpdatedSheets: data.totalUpdatedSheets ?? 0,
responses,
metadata: {
spreadsheetId,
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
},
},
}
},
outputs: {
spreadsheetId: { type: 'string', description: 'The spreadsheet ID' },
totalUpdatedRows: { type: 'number', description: 'Total number of rows updated' },
totalUpdatedColumns: { type: 'number', description: 'Total number of columns updated' },
totalUpdatedCells: { type: 'number', description: 'Total number of cells updated' },
totalUpdatedSheets: { type: 'number', description: 'Total number of sheets updated' },
responses: {
type: 'array',
description: 'Array of update responses for each range',
items: {
type: 'object',
properties: {
spreadsheetId: { type: 'string', description: 'The spreadsheet ID' },
updatedRange: { type: 'string', description: 'The range that was updated' },
updatedRows: { type: 'number', description: 'Number of rows updated in this range' },
updatedColumns: {
type: 'number',
description: 'Number of columns updated in this range',
},
updatedCells: { type: 'number', description: 'Number of cells updated in this range' },
},
},
},
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
+110
View File
@@ -0,0 +1,110 @@
import type {
GoogleSheetsV2ClearParams,
GoogleSheetsV2ClearResponse,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const clearV2Tool: ToolConfig<GoogleSheetsV2ClearParams, GoogleSheetsV2ClearResponse> = {
id: 'google_sheets_clear_v2',
name: 'Clear Google Sheets Range V2',
description: 'Clear values from a specific range in a Google Sheets spreadsheet',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
sheetName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The name of the sheet/tab to clear',
},
cellRange: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The cell range to clear (e.g. "A1:D10"). Clears entire sheet if not specified.',
},
},
request: {
url: (params) => {
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
const sheetName = params.sheetName?.trim()
if (!sheetName) {
throw new Error('Sheet name is required')
}
const cellRange = params.cellRange?.trim()
const fullRange = cellRange ? `${sheetName}!${cellRange}` : sheetName
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(fullRange)}:clear`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: () => ({}),
},
transformResponse: async (response: Response, params?: GoogleSheetsV2ClearParams) => {
const data = await response.json()
const spreadsheetId = params?.spreadsheetId ?? ''
const metadata = {
spreadsheetId,
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
}
return {
success: true,
output: {
clearedRange: data.clearedRange ?? '',
sheetName: params?.sheetName ?? '',
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
},
},
}
},
outputs: {
clearedRange: { type: 'string', description: 'The range that was cleared' },
sheetName: { type: 'string', description: 'Name of the sheet that was cleared' },
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
+119
View File
@@ -0,0 +1,119 @@
import type {
GoogleSheetsV2CopySheetParams,
GoogleSheetsV2CopySheetResponse,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const copySheetV2Tool: ToolConfig<
GoogleSheetsV2CopySheetParams,
GoogleSheetsV2CopySheetResponse
> = {
id: 'google_sheets_copy_sheet_v2',
name: 'Copy Sheet V2',
description: 'Copy a sheet from one spreadsheet to another',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
sourceSpreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Source Google Sheets spreadsheet ID',
},
sheetId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description:
'The ID of the sheet to copy (numeric ID, not the sheet name). Use Get Spreadsheet to find sheet IDs.',
},
destinationSpreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the destination spreadsheet where the sheet will be copied',
},
},
request: {
url: (params) => {
const sourceSpreadsheetId = params.sourceSpreadsheetId?.trim()
if (!sourceSpreadsheetId) {
throw new Error('Source spreadsheet ID is required')
}
const sheetId = params.sheetId
if (sheetId === undefined || sheetId === null) {
throw new Error('Sheet ID is required')
}
return `https://sheets.googleapis.com/v4/spreadsheets/${sourceSpreadsheetId}/sheets/${sheetId}:copyTo`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const destinationSpreadsheetId = params.destinationSpreadsheetId?.trim()
if (!destinationSpreadsheetId) {
throw new Error('Destination spreadsheet ID is required')
}
return {
destinationSpreadsheetId,
}
},
},
transformResponse: async (response: Response, params?: GoogleSheetsV2CopySheetParams) => {
const data = await response.json()
return {
success: true,
output: {
sheetId: data.sheetId ?? 0,
title: data.title ?? '',
index: data.index ?? 0,
sheetType: data.sheetType ?? 'GRID',
destinationSpreadsheetId: params?.destinationSpreadsheetId ?? '',
destinationSpreadsheetUrl: `https://docs.google.com/spreadsheets/d/${params?.destinationSpreadsheetId ?? ''}`,
},
}
},
outputs: {
sheetId: {
type: 'number',
description: 'The ID of the newly created sheet in the destination',
},
title: { type: 'string', description: 'The title of the copied sheet' },
index: { type: 'number', description: 'The index (position) of the copied sheet' },
sheetType: { type: 'string', description: 'The type of the sheet (GRID, CHART, etc.)' },
destinationSpreadsheetId: {
type: 'string',
description: 'The ID of the destination spreadsheet',
},
destinationSpreadsheetUrl: {
type: 'string',
description: 'URL to the destination spreadsheet',
},
},
}
@@ -0,0 +1,139 @@
import type {
GoogleSheetsV2CreateSpreadsheetParams,
GoogleSheetsV2CreateSpreadsheetResponse,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const createSpreadsheetV2Tool: ToolConfig<
GoogleSheetsV2CreateSpreadsheetParams,
GoogleSheetsV2CreateSpreadsheetResponse
> = {
id: 'google_sheets_create_spreadsheet_v2',
name: 'Create Spreadsheet V2',
description: 'Create a new Google Sheets spreadsheet',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
title: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The title of the new spreadsheet',
},
sheetTitles: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description:
'Array of sheet names to create (e.g., ["Sheet1", "Data", "Summary"]). Defaults to a single "Sheet1".',
},
locale: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The locale of the spreadsheet (e.g., "en_US")',
},
timeZone: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The time zone of the spreadsheet (e.g., "America/New_York")',
},
},
request: {
url: () => 'https://sheets.googleapis.com/v4/spreadsheets',
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const title = params.title?.trim()
if (!title) {
throw new Error('Spreadsheet title is required')
}
const sheetTitles = params.sheetTitles ?? ['Sheet1']
const sheets = sheetTitles.map((sheetTitle: string, index: number) => ({
properties: {
title: sheetTitle,
index,
},
}))
const body: any = {
properties: {
title,
},
sheets,
}
if (params.locale) {
body.properties.locale = params.locale
}
if (params.timeZone) {
body.properties.timeZone = params.timeZone
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const sheets =
data.sheets?.map((sheet: any) => ({
sheetId: sheet.properties?.sheetId ?? 0,
title: sheet.properties?.title ?? '',
index: sheet.properties?.index ?? 0,
})) ?? []
return {
success: true,
output: {
spreadsheetId: data.spreadsheetId ?? '',
title: data.properties?.title ?? '',
spreadsheetUrl: data.spreadsheetUrl ?? '',
sheets,
},
}
},
outputs: {
spreadsheetId: { type: 'string', description: 'The ID of the created spreadsheet' },
title: { type: 'string', description: 'The title of the created spreadsheet' },
spreadsheetUrl: { type: 'string', description: 'URL to the created spreadsheet' },
sheets: {
type: 'array',
description: 'List of sheets created in the spreadsheet',
items: {
type: 'object',
properties: {
sheetId: { type: 'number', description: 'The sheet ID' },
title: { type: 'string', description: 'The sheet title/name' },
index: { type: 'number', description: 'The sheet index (position)' },
},
},
},
},
}
+156
View File
@@ -0,0 +1,156 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
export interface GoogleSheetsV2DeleteRowsParams {
accessToken: string
spreadsheetId: string
sheetId: number
startIndex: number
endIndex: number
}
export interface GoogleSheetsV2DeleteRowsResponse extends ToolResponse {
output: {
spreadsheetId: string
sheetId: number
deletedRowRange: string
metadata: {
spreadsheetId: string
spreadsheetUrl: string
}
}
}
export const deleteRowsV2Tool: ToolConfig<
GoogleSheetsV2DeleteRowsParams,
GoogleSheetsV2DeleteRowsResponse
> = {
id: 'google_sheets_delete_rows_v2',
name: 'Delete Rows from Google Sheets V2',
description: 'Delete rows from a sheet in a Google Sheets spreadsheet',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
sheetId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description:
'The numeric ID of the sheet/tab (not the sheet name). Use Get Spreadsheet to find sheet IDs.',
},
startIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The start row index (0-based, inclusive) of the rows to delete',
},
endIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The end row index (0-based, exclusive) of the rows to delete',
},
},
request: {
url: (params) => {
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
if (params.sheetId === undefined || params.sheetId === null) {
throw new Error('Sheet ID is required')
}
if (params.startIndex === undefined || params.startIndex === null) {
throw new Error('Start index is required')
}
if (params.endIndex === undefined || params.endIndex === null) {
throw new Error('End index is required')
}
return {
requests: [
{
deleteDimension: {
range: {
sheetId: params.sheetId,
dimension: 'ROWS',
startIndex: params.startIndex,
endIndex: params.endIndex,
},
},
},
],
}
},
},
transformResponse: async (response: Response, params?: GoogleSheetsV2DeleteRowsParams) => {
await response.json()
const spreadsheetId = params?.spreadsheetId ?? ''
const startIndex = params?.startIndex ?? 0
const endIndex = params?.endIndex ?? 0
return {
success: true,
output: {
spreadsheetId,
sheetId: params?.sheetId ?? 0,
deletedRowRange: `rows ${startIndex} to ${endIndex}`,
metadata: {
spreadsheetId,
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
},
},
}
},
outputs: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
sheetId: { type: 'number', description: 'The numeric ID of the sheet' },
deletedRowRange: {
type: 'string',
description: 'Description of the deleted row range',
},
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
@@ -0,0 +1,123 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
export interface GoogleSheetsV2DeleteSheetParams {
accessToken: string
spreadsheetId: string
sheetId: number
}
export interface GoogleSheetsV2DeleteSheetResponse extends ToolResponse {
output: {
spreadsheetId: string
deletedSheetId: number
metadata: {
spreadsheetId: string
spreadsheetUrl: string
}
}
}
export const deleteSheetV2Tool: ToolConfig<
GoogleSheetsV2DeleteSheetParams,
GoogleSheetsV2DeleteSheetResponse
> = {
id: 'google_sheets_delete_sheet_v2',
name: 'Delete Sheet V2',
description: 'Delete a sheet/tab from a Google Sheets spreadsheet',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
sheetId: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description:
'The numeric ID of the sheet/tab to delete (not the sheet name). Use Get Spreadsheet to find sheet IDs.',
},
},
request: {
url: (params) => {
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
if (params.sheetId === undefined || params.sheetId === null) {
throw new Error('Sheet ID is required')
}
return {
requests: [
{
deleteSheet: {
sheetId: params.sheetId,
},
},
],
}
},
},
transformResponse: async (response: Response, params?: GoogleSheetsV2DeleteSheetParams) => {
await response.json()
const spreadsheetId = params?.spreadsheetId ?? ''
return {
success: true,
output: {
spreadsheetId,
deletedSheetId: params?.sheetId ?? 0,
metadata: {
spreadsheetId,
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
},
},
}
},
outputs: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
deletedSheetId: { type: 'number', description: 'The numeric ID of the deleted sheet' },
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
@@ -0,0 +1,86 @@
import type { ToolConfig, ToolResponse } from '@/tools/types'
export interface GoogleSheetsV2DeleteSpreadsheetParams {
accessToken: string
spreadsheetId: string
}
export interface GoogleSheetsV2DeleteSpreadsheetResponse extends ToolResponse {
output: {
spreadsheetId: string
deleted: boolean
}
}
export const deleteSpreadsheetV2Tool: ToolConfig<
GoogleSheetsV2DeleteSpreadsheetParams,
GoogleSheetsV2DeleteSpreadsheetResponse
> = {
id: 'google_sheets_delete_spreadsheet_v2',
name: 'Delete Spreadsheet V2',
description: 'Permanently delete a Google Sheets spreadsheet using the Google Drive API',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the Google Sheets spreadsheet to delete',
},
},
request: {
url: (params) => {
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
return `https://www.googleapis.com/drive/v3/files/${spreadsheetId}`
},
method: 'DELETE',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
}
},
},
transformResponse: async (response: Response, params?: GoogleSheetsV2DeleteSpreadsheetParams) => {
const spreadsheetId = params?.spreadsheetId ?? ''
if (response.status === 204 || response.ok) {
return {
success: true,
output: {
spreadsheetId,
deleted: true,
},
}
}
const data = await response.json()
throw new Error(data.error?.message ?? 'Failed to delete spreadsheet')
},
outputs: {
spreadsheetId: { type: 'string', description: 'The ID of the deleted spreadsheet' },
deleted: { type: 'boolean', description: 'Whether the spreadsheet was successfully deleted' },
},
}
+228
View File
@@ -0,0 +1,228 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { filterSheetRows } from '@/tools/google_sheets/filter'
const VALUES: unknown[][] = [
['Name', 'Email', 'Status', 'Score'],
['Alice', 'alice@example.com', 'Active', '90'],
['Bob', 'bob@test.com', 'Closed', '40'],
['Carol', 'carol@example.com', 'Active', '7'],
]
describe('filterSheetRows', () => {
it('passes values through unchanged when no filter column is provided', () => {
const result = filterSheetRows(VALUES, {})
expect(result.applied).toBe(false)
expect(result.values).toBe(VALUES)
expect(result.totalRows).toBe(3)
})
it('passes through when filterValue is empty', () => {
const result = filterSheetRows(VALUES, { filterColumn: 'Status', filterValue: '' })
expect(result.applied).toBe(false)
expect(result.values).toBe(VALUES)
})
it('defaults to case-insensitive contains', () => {
const result = filterSheetRows(VALUES, { filterColumn: 'status', filterValue: 'active' })
expect(result.applied).toBe(true)
expect(result.columnFound).toBe(true)
expect(result.values).toEqual([VALUES[0], VALUES[1], VALUES[3]])
expect(result.matchedRows).toBe(2)
})
it('matches column names case-insensitively and trims whitespace', () => {
const result = filterSheetRows(VALUES, {
filterColumn: ' EMAIL ',
filterValue: 'example.com',
})
expect(result.columnFound).toBe(true)
expect(result.matchedRows).toBe(2)
})
it('supports exact and not_equals', () => {
expect(
filterSheetRows(VALUES, {
filterColumn: 'Status',
filterValue: 'Active',
filterMatchType: 'exact',
}).matchedRows
).toBe(2)
expect(
filterSheetRows(VALUES, {
filterColumn: 'Status',
filterValue: 'Active',
filterMatchType: 'not_equals',
}).matchedRows
).toBe(1)
})
it('supports starts_with, ends_with, and not_contains', () => {
expect(
filterSheetRows(VALUES, {
filterColumn: 'Email',
filterValue: 'bob',
filterMatchType: 'starts_with',
}).matchedRows
).toBe(1)
expect(
filterSheetRows(VALUES, {
filterColumn: 'Email',
filterValue: '.com',
filterMatchType: 'ends_with',
}).matchedRows
).toBe(3)
expect(
filterSheetRows(VALUES, {
filterColumn: 'Email',
filterValue: 'example.com',
filterMatchType: 'not_contains',
}).matchedRows
).toBe(1)
})
it('compares numerically for ordering operators (not substring)', () => {
const gt = filterSheetRows(VALUES, {
filterColumn: 'Score',
filterValue: '50',
filterMatchType: 'gt',
})
expect(gt.matchedRows).toBe(1)
expect(gt.values).toEqual([VALUES[0], VALUES[1]])
expect(
filterSheetRows(VALUES, {
filterColumn: 'Score',
filterValue: '40',
filterMatchType: 'gte',
}).matchedRows
).toBe(2)
expect(
filterSheetRows(VALUES, {
filterColumn: 'Score',
filterValue: '40',
filterMatchType: 'lt',
}).matchedRows
).toBe(1)
expect(
filterSheetRows(VALUES, {
filterColumn: 'Score',
filterValue: '40',
filterMatchType: 'lte',
}).matchedRows
).toBe(2)
})
it('orders negative numbers correctly', () => {
const temps: unknown[][] = [
['City', 'Temp'],
['A', '-5'],
['B', '0'],
['C', '-12'],
['D', '3'],
]
expect(
filterSheetRows(temps, { filterColumn: 'Temp', filterValue: '-5', filterMatchType: 'gte' })
.matchedRows
).toBe(3)
expect(
filterSheetRows(temps, { filterColumn: 'Temp', filterValue: '0', filterMatchType: 'lt' })
.matchedRows
).toBe(2)
})
it('excludes blank and non-numeric cells from numeric comparisons', () => {
const scores: unknown[][] = [
['Name', 'Score'],
['Alice', '90'],
['Bob', ''],
['Carol', 'N/A'],
['Dan', '60'],
]
const result = filterSheetRows(scores, {
filterColumn: 'Score',
filterValue: '50',
filterMatchType: 'gt',
})
expect(result.matchedRows).toBe(2)
expect(result.values).toEqual([scores[0], scores[1], scores[4]])
})
it('falls back to lexicographic ordering when values are not numeric (ISO dates)', () => {
const dated: unknown[][] = [
['Task', 'Due'],
['A', '2026-01-15'],
['B', '2026-03-01'],
['C', '2025-12-31'],
]
const result = filterSheetRows(dated, {
filterColumn: 'Due',
filterValue: '2026-01-01',
filterMatchType: 'gte',
})
expect(result.matchedRows).toBe(2)
})
it('reports columnFound=false and leaves values unchanged when the column is missing', () => {
const result = filterSheetRows(VALUES, {
filterColumn: 'Nonexistent',
filterValue: 'x',
})
expect(result.applied).toBe(false)
expect(result.columnFound).toBe(false)
expect(result.matchedRows).toBe(0)
expect(result.values).toBe(VALUES)
expect(result.totalRows).toBe(3)
})
it('handles a header-only sheet and reports the column as found when it exists', () => {
const headerOnly: unknown[][] = [['Name', 'Status']]
const result = filterSheetRows(headerOnly, { filterColumn: 'Status', filterValue: 'Active' })
expect(result.applied).toBe(false)
expect(result.columnFound).toBe(true)
expect(result.matchedRows).toBe(0)
expect(result.totalRows).toBe(0)
expect(result.values).toBe(headerOnly)
})
it('reports columnFound=false for a header-only sheet when the column is absent', () => {
const headerOnly: unknown[][] = [['Name', 'Status']]
const result = filterSheetRows(headerOnly, { filterColumn: 'Nonexistent', filterValue: 'x' })
expect(result.applied).toBe(false)
expect(result.columnFound).toBe(false)
expect(result.matchedRows).toBe(0)
expect(result.values).toBe(headerOnly)
})
it('reports columnFound=false for an empty values array', () => {
const empty: unknown[][] = []
const result = filterSheetRows(empty, { filterColumn: 'Status', filterValue: 'Active' })
expect(result.applied).toBe(false)
expect(result.columnFound).toBe(false)
expect(result.matchedRows).toBe(0)
expect(result.totalRows).toBe(0)
})
it('treats missing cells as empty strings', () => {
const sparse: unknown[][] = [['Name', 'Status'], ['Alice'], ['Bob', 'Active']]
const result = filterSheetRows(sparse, {
filterColumn: 'Status',
filterValue: 'Active',
filterMatchType: 'exact',
})
expect(result.matchedRows).toBe(1)
expect(result.values).toEqual([sparse[0], sparse[2]])
})
it('always retains the header row in filtered output', () => {
const result = filterSheetRows(VALUES, {
filterColumn: 'Status',
filterValue: 'no-match',
filterMatchType: 'exact',
})
expect(result.values).toEqual([VALUES[0]])
expect(result.matchedRows).toBe(0)
})
})
+152
View File
@@ -0,0 +1,152 @@
/**
* Client-side row filtering for Google Sheets read results.
*
* The Google Sheets REST API (`spreadsheets.values.get`) has no server-side
* content filtering — `DataFilter` selects only by A1 range, grid range, or
* developer metadata, never by cell value. Filtering by cell content must
* therefore happen after values are fetched, over the window of rows the read
* returned (e.g. the default `A1:Z1000`), not the entire sheet.
*/
/**
* Supported ways to compare a cell against the filter value. Text operators are
* case-insensitive. The ordering operators (`gt`/`gte`/`lt`/`lte`) compare
* numerically when both operands parse as finite numbers, fall back to
* case-insensitive lexicographic comparison when both are non-numeric (which
* orders ISO dates correctly), and never match when one side is numeric and the
* other is not (the values are not comparable).
*/
export type SheetFilterMatchType =
| 'contains'
| 'not_contains'
| 'exact'
| 'not_equals'
| 'starts_with'
| 'ends_with'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
export interface SheetFilterOptions {
filterColumn?: string
filterValue?: string
filterMatchType?: SheetFilterMatchType
}
export interface SheetFilterResult {
/** The (possibly filtered) values, always including the header row when present. */
values: unknown[][]
/** Whether row filtering was actually applied to the data rows. */
applied: boolean
/** Whether the requested filter column was found in the header row. */
columnFound: boolean
/** Number of data rows (excluding the header) that matched the filter. */
matchedRows: number
/** Total number of data rows (excluding the header) that were considered. */
totalRows: number
}
const DEFAULT_MATCH_TYPE: SheetFilterMatchType = 'contains'
/**
* Parses a cell string as a finite number, or returns null when it is blank or
* non-numeric so callers can fall back to lexicographic comparison.
*/
function asFiniteNumber(value: string): number | null {
if (value.trim() === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
/** Case-insensitive lexicographic comparison returning -1, 0, or 1. */
function compareLexicographic(cell: string, target: string): number {
return Math.sign(cell.toLowerCase().localeCompare(target.toLowerCase()))
}
/** Evaluates a single cell against the filter target for the given match type. */
function matchesCell(cell: string, target: string, matchType: SheetFilterMatchType): boolean {
switch (matchType) {
case 'gt':
case 'gte':
case 'lt':
case 'lte': {
const cellNum = asFiniteNumber(cell)
const targetNum = asFiniteNumber(target)
let cmp: number
if (cellNum !== null && targetNum !== null) {
cmp = Math.sign(cellNum - targetNum)
} else if (cellNum === null && targetNum === null) {
cmp = compareLexicographic(cell, target)
} else {
return false
}
if (matchType === 'gt') return cmp > 0
if (matchType === 'gte') return cmp >= 0
if (matchType === 'lt') return cmp < 0
return cmp <= 0
}
case 'exact':
return cell.toLowerCase() === target.toLowerCase()
case 'not_equals':
return cell.toLowerCase() !== target.toLowerCase()
case 'starts_with':
return cell.toLowerCase().startsWith(target.toLowerCase())
case 'ends_with':
return cell.toLowerCase().endsWith(target.toLowerCase())
case 'not_contains':
return !cell.toLowerCase().includes(target.toLowerCase())
default:
return cell.toLowerCase().includes(target.toLowerCase())
}
}
/**
* Filters a 2D values array (header row + data rows) by matching a single column
* against a target value. Returns the original values untouched when no filter
* is requested, when there are no data rows, or when the column is not found —
* the `applied`/`columnFound` flags let callers distinguish "no match possible"
* from "everything matched".
*/
export function filterSheetRows(
values: unknown[][],
options: SheetFilterOptions
): SheetFilterResult {
const { filterColumn, filterValue, filterMatchType } = options
const totalRows = Math.max(values.length - 1, 0)
if (!filterColumn || filterValue === undefined || filterValue === '') {
return { values, applied: false, columnFound: true, matchedRows: totalRows, totalRows }
}
const headers = values[0] ?? []
const normalizedColumn = filterColumn.trim().toLowerCase()
const columnIndex = headers.findIndex(
(header) => String(header).trim().toLowerCase() === normalizedColumn
)
const columnFound = columnIndex !== -1
// No data rows to evaluate (empty or header-only sheet): nothing matched, but
// still report whether the requested column actually exists in the header.
if (values.length <= 1) {
return { values, applied: false, columnFound, matchedRows: 0, totalRows: 0 }
}
// Column not found: leave rows untouched and report zero matches, not totalRows.
if (!columnFound) {
return { values, applied: false, columnFound: false, matchedRows: 0, totalRows }
}
const matchType = filterMatchType ?? DEFAULT_MATCH_TYPE
const matched = values
.slice(1)
.filter((row) => matchesCell(String(row[columnIndex] ?? ''), filterValue, matchType))
return {
values: [values[0], ...matched],
applied: true,
columnFound: true,
matchedRows: matched.length,
totalRows,
}
}
@@ -0,0 +1,112 @@
import type {
GoogleSheetsV2GetSpreadsheetParams,
GoogleSheetsV2GetSpreadsheetResponse,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const getSpreadsheetV2Tool: ToolConfig<
GoogleSheetsV2GetSpreadsheetParams,
GoogleSheetsV2GetSpreadsheetResponse
> = {
id: 'google_sheets_get_spreadsheet_v2',
name: 'Get Spreadsheet Info V2',
description: 'Get metadata about a Google Sheets spreadsheet including title and sheet list',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
includeGridData: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to include grid data (cell values). Defaults to false.',
},
},
request: {
url: (params) => {
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
const includeGridData = params.includeGridData ? 'true' : 'false'
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?includeGridData=${includeGridData}`
},
method: 'GET',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const sheets =
data.sheets?.map((sheet: any) => ({
sheetId: sheet.properties?.sheetId ?? 0,
title: sheet.properties?.title ?? '',
index: sheet.properties?.index ?? 0,
rowCount: sheet.properties?.gridProperties?.rowCount ?? null,
columnCount: sheet.properties?.gridProperties?.columnCount ?? null,
hidden: sheet.properties?.hidden ?? false,
})) ?? []
return {
success: true,
output: {
spreadsheetId: data.spreadsheetId ?? '',
title: data.properties?.title ?? '',
locale: data.properties?.locale ?? null,
timeZone: data.properties?.timeZone ?? null,
spreadsheetUrl: data.spreadsheetUrl ?? '',
sheets,
},
}
},
outputs: {
spreadsheetId: { type: 'string', description: 'The spreadsheet ID' },
title: { type: 'string', description: 'The title of the spreadsheet' },
locale: { type: 'string', description: 'The locale of the spreadsheet', optional: true },
timeZone: { type: 'string', description: 'The time zone of the spreadsheet', optional: true },
spreadsheetUrl: { type: 'string', description: 'URL to the spreadsheet' },
sheets: {
type: 'array',
description: 'List of sheets in the spreadsheet',
items: {
type: 'object',
properties: {
sheetId: { type: 'number', description: 'The sheet ID' },
title: { type: 'string', description: 'The sheet title/name' },
index: { type: 'number', description: 'The sheet index (position)' },
rowCount: { type: 'number', description: 'Number of rows in the sheet' },
columnCount: { type: 'number', description: 'Number of columns in the sheet' },
hidden: { type: 'boolean', description: 'Whether the sheet is hidden' },
},
},
},
},
}
+36
View File
@@ -0,0 +1,36 @@
import { appendTool, appendV2Tool } from '@/tools/google_sheets/append'
import { batchClearV2Tool } from '@/tools/google_sheets/batch_clear'
import { batchGetV2Tool } from '@/tools/google_sheets/batch_get'
import { batchUpdateV2Tool } from '@/tools/google_sheets/batch_update'
import { clearV2Tool } from '@/tools/google_sheets/clear'
import { copySheetV2Tool } from '@/tools/google_sheets/copy_sheet'
import { createSpreadsheetV2Tool } from '@/tools/google_sheets/create_spreadsheet'
import { deleteRowsV2Tool } from '@/tools/google_sheets/delete_rows'
import { deleteSheetV2Tool } from '@/tools/google_sheets/delete_sheet'
import { deleteSpreadsheetV2Tool } from '@/tools/google_sheets/delete_spreadsheet'
import { getSpreadsheetV2Tool } from '@/tools/google_sheets/get_spreadsheet'
import { readTool, readV2Tool } from '@/tools/google_sheets/read'
import { updateTool, updateV2Tool } from '@/tools/google_sheets/update'
import { writeTool, writeV2Tool } from '@/tools/google_sheets/write'
// V1 exports
export const googleSheetsReadTool = readTool
export const googleSheetsWriteTool = writeTool
export const googleSheetsUpdateTool = updateTool
export const googleSheetsAppendTool = appendTool
// V2 exports
export const googleSheetsReadV2Tool = readV2Tool
export const googleSheetsWriteV2Tool = writeV2Tool
export const googleSheetsUpdateV2Tool = updateV2Tool
export const googleSheetsAppendV2Tool = appendV2Tool
export const googleSheetsClearV2Tool = clearV2Tool
export const googleSheetsGetSpreadsheetV2Tool = getSpreadsheetV2Tool
export const googleSheetsCreateSpreadsheetV2Tool = createSpreadsheetV2Tool
export const googleSheetsBatchGetV2Tool = batchGetV2Tool
export const googleSheetsBatchUpdateV2Tool = batchUpdateV2Tool
export const googleSheetsBatchClearV2Tool = batchClearV2Tool
export const googleSheetsCopySheetV2Tool = copySheetV2Tool
export const googleSheetsDeleteRowsV2Tool = deleteRowsV2Tool
export const googleSheetsDeleteSheetV2Tool = deleteSheetV2Tool
export const googleSheetsDeleteSpreadsheetV2Tool = deleteSpreadsheetV2Tool
+97
View File
@@ -0,0 +1,97 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { readV2Tool } from '@/tools/google_sheets/read'
import type { GoogleSheetsV2ToolParams } from '@/tools/google_sheets/types'
const SPREADSHEET_ID = 'abc123'
const URL = `https://sheets.googleapis.com/v4/spreadsheets/${SPREADSHEET_ID}/values/Sheet1!A1:Z1000`
const SHEET_DATA = {
range: 'Sheet1!A1:D4',
values: [
['Name', 'Status'],
['Alice', 'Active'],
['Bob', 'Closed'],
],
}
function mockResponse(body: unknown, url = URL): Response {
// double-cast-allowed: lightweight Response stub for transformResponse unit test
return { url, json: async () => body } as unknown as Response
}
const baseParams: GoogleSheetsV2ToolParams = {
accessToken: 'token',
spreadsheetId: SPREADSHEET_ID,
sheetName: 'Sheet1',
}
describe('readV2Tool.transformResponse', () => {
it('returns values untouched and omits the filter field when no filter is requested', async () => {
const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), baseParams)
expect(result.output.values).toEqual(SHEET_DATA.values)
expect('filter' in result.output).toBe(false)
expect(result.output.range).toBe(SHEET_DATA.range)
expect(result.output.metadata.spreadsheetId).toBe(SPREADSHEET_ID)
})
it('omits the filter field when filterColumn is set but filterValue is empty', async () => {
const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), {
...baseParams,
filterColumn: 'Status',
filterValue: '',
})
expect('filter' in result.output).toBe(false)
expect(result.output.values).toEqual(SHEET_DATA.values)
})
it('filters rows and reports filter metadata when a filter is applied', async () => {
const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), {
...baseParams,
filterColumn: 'Status',
filterValue: 'Active',
filterMatchType: 'exact',
})
expect(result.output.values).toEqual([
['Name', 'Status'],
['Alice', 'Active'],
])
expect(result.output.filter).toEqual({
applied: true,
column: 'Status',
matchType: 'exact',
columnFound: true,
matchedRows: 1,
totalRows: 2,
})
})
it('leaves values unchanged and reports columnFound=false when the column is missing', async () => {
const result = await readV2Tool.transformResponse!(mockResponse(SHEET_DATA), {
...baseParams,
filterColumn: 'Nonexistent',
filterValue: 'x',
})
expect(result.output.values).toEqual(SHEET_DATA.values)
expect(result.output.filter?.columnFound).toBe(false)
expect(result.output.filter?.applied).toBe(false)
expect(result.output.filter?.matchedRows).toBe(0)
})
it('handles a response with no values array', async () => {
const result = await readV2Tool.transformResponse!(mockResponse({ range: 'Sheet1!A1' }), {
...baseParams,
filterColumn: 'Status',
filterValue: 'Active',
})
expect(result.output.values).toEqual([])
expect(result.output.filter?.applied).toBe(false)
})
})
+272
View File
@@ -0,0 +1,272 @@
import { filterSheetRows } from '@/tools/google_sheets/filter'
import type {
GoogleSheetsReadResponse,
GoogleSheetsToolParams,
GoogleSheetsV2ReadResponse,
GoogleSheetsV2ToolParams,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const readTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsReadResponse> = {
id: 'google_sheets_read',
name: 'Read from Google Sheets',
description: 'Read data from a Google Sheets spreadsheet',
version: '1.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'The ID of the spreadsheet (found in the URL: docs.google.com/spreadsheets/d/{SPREADSHEET_ID}/edit).',
},
range: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The A1 notation range to read (e.g. "Sheet1!A1:D10", "A1:B5"). Defaults to first sheet A1:Z1000 if not specified.',
},
},
request: {
url: (params) => {
// Ensure spreadsheetId is valid
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
// If no range is provided, default to the first sheet without hardcoding the title
// Using A1 notation without a sheet name targets the first sheet (per Sheets API)
// Keep a generous column/row bound to avoid huge payloads
if (!params.range) {
const defaultRange = 'A1:Z1000'
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(defaultRange)}`
}
// Otherwise, get values from the specified range
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(params.range)}`
},
method: 'GET',
headers: (params) => {
// Validate access token
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
// Extract spreadsheet ID from the URL (guard if url is missing)
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
// Create a simple metadata object with just the ID and URL
const metadata = {
spreadsheetId,
properties: {},
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
}
// Process the values response
const result: GoogleSheetsReadResponse = {
success: true,
output: {
data: {
range: data.range || '',
values: data.values || [],
},
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
},
},
}
return result
},
outputs: {
data: { type: 'json', description: 'Sheet data including range and cell values' },
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
export const readV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2ReadResponse> = {
id: 'google_sheets_read_v2',
name: 'Read from Google Sheets V2',
description: 'Read data from a specific sheet in a Google Sheets spreadsheet',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
sheetName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The name of the sheet/tab to read from',
},
cellRange: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The cell range to read (e.g. "A1:D10"). Defaults to "A1:Z1000" if not specified.',
},
filterColumn: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Column name (from the header row) to filter on. Filtering is applied to the rows returned by the read range (the default is A1:Z1000), not the entire sheet. If not provided, no filtering is applied.',
},
filterValue: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Value to match against the filter column.',
},
filterMatchType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'How to match the filter value. Text: "contains", "not_contains", "exact", "not_equals", "starts_with", "ends_with". Numeric/ordering: "gt", "gte", "lt", "lte" (numeric when both values are numbers, otherwise lexicographic). Defaults to "contains".',
},
},
request: {
url: (params) => {
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
const sheetName = params.sheetName?.trim()
if (!sheetName) {
throw new Error('Sheet name is required')
}
const cellRange = params.cellRange?.trim() || 'A1:Z1000'
const fullRange = `${sheetName}!${cellRange}`
return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(fullRange)}`
},
method: 'GET',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
}
},
},
transformResponse: async (response: Response, params?: GoogleSheetsV2ToolParams) => {
const data = await response.json()
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const metadata = {
spreadsheetId,
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
}
const rawValues: unknown[][] = data.values ?? []
const filterRequested =
Boolean(params?.filterColumn) &&
params?.filterValue !== undefined &&
params?.filterValue !== ''
const filterResult = filterSheetRows(rawValues, {
filterColumn: params?.filterColumn,
filterValue: params?.filterValue,
filterMatchType: params?.filterMatchType,
})
return {
success: true,
output: {
sheetName: params?.sheetName ?? '',
range: data.range ?? '',
values: filterResult.values,
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
},
...(filterRequested
? {
filter: {
applied: filterResult.applied,
column: params?.filterColumn ?? '',
matchType: params?.filterMatchType ?? 'contains',
columnFound: filterResult.columnFound,
matchedRows: filterResult.matchedRows,
totalRows: filterResult.totalRows,
},
}
: {}),
},
}
},
outputs: {
sheetName: { type: 'string', description: 'Name of the sheet that was read' },
range: { type: 'string', description: 'The range of cells that was read' },
values: { type: 'array', description: 'The cell values as a 2D array' },
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
+318
View File
@@ -0,0 +1,318 @@
import type { GoogleSheetsV2DeleteRowsResponse } from '@/tools/google_sheets/delete_rows'
import type { GoogleSheetsV2DeleteSheetResponse } from '@/tools/google_sheets/delete_sheet'
import type { GoogleSheetsV2DeleteSpreadsheetResponse } from '@/tools/google_sheets/delete_spreadsheet'
import type { SheetFilterMatchType } from '@/tools/google_sheets/filter'
import type { ToolResponse } from '@/tools/types'
interface GoogleSheetsRange {
sheetId?: number
sheetName?: string
range: string
values: any[][]
}
interface GoogleSheetsMetadata {
spreadsheetId: string
spreadsheetUrl?: string
title?: string
sheets?: {
sheetId: number
title: string
index: number
rowCount?: number
columnCount?: number
}[]
}
export interface GoogleSheetsReadResponse extends ToolResponse {
output: {
data: GoogleSheetsRange
metadata: GoogleSheetsMetadata
}
}
export interface GoogleSheetsWriteResponse extends ToolResponse {
output: {
updatedRange: string
updatedRows: number
updatedColumns: number
updatedCells: number
metadata: GoogleSheetsMetadata
}
}
export interface GoogleSheetsUpdateResponse extends ToolResponse {
output: {
updatedRange: string
updatedRows: number
updatedColumns: number
updatedCells: number
metadata: GoogleSheetsMetadata
}
}
export interface GoogleSheetsAppendResponse extends ToolResponse {
output: {
tableRange: string
updatedRange: string
updatedRows: number
updatedColumns: number
updatedCells: number
metadata: GoogleSheetsMetadata
}
}
export interface GoogleSheetsToolParams {
accessToken: string
spreadsheetId: string
range?: string
values?: any[][]
valueInputOption?: 'RAW' | 'USER_ENTERED'
insertDataOption?: 'OVERWRITE' | 'INSERT_ROWS'
includeValuesInResponse?: boolean
responseValueRenderOption?: 'FORMATTED_VALUE' | 'UNFORMATTED_VALUE' | 'FORMULA'
majorDimension?: 'ROWS' | 'COLUMNS'
}
export type GoogleSheetsResponse =
| GoogleSheetsReadResponse
| GoogleSheetsWriteResponse
| GoogleSheetsUpdateResponse
| GoogleSheetsAppendResponse
// V2 Types - with explicit sheetName parameter
export interface GoogleSheetsFilterInfo {
/** Whether row filtering was actually applied to the data rows. */
applied: boolean
/** The column header the filter targeted. */
column: string
/** The match type used for the comparison. */
matchType: SheetFilterMatchType
/** Whether the requested filter column was found in the header row. */
columnFound: boolean
/** Number of data rows (excluding the header) that matched the filter. */
matchedRows: number
/** Total number of data rows (excluding the header) that were considered. */
totalRows: number
}
export interface GoogleSheetsV2ReadResponse extends ToolResponse {
output: {
sheetName: string
range: string
values: any[][]
metadata: GoogleSheetsMetadata
filter?: GoogleSheetsFilterInfo
}
}
export interface GoogleSheetsV2WriteResponse extends ToolResponse {
output: {
updatedRange: string
updatedRows: number
updatedColumns: number
updatedCells: number
metadata: GoogleSheetsMetadata
}
}
export interface GoogleSheetsV2UpdateResponse extends ToolResponse {
output: {
updatedRange: string
updatedRows: number
updatedColumns: number
updatedCells: number
metadata: GoogleSheetsMetadata
}
}
export interface GoogleSheetsV2AppendResponse extends ToolResponse {
output: {
tableRange: string
updatedRange: string
updatedRows: number
updatedColumns: number
updatedCells: number
metadata: GoogleSheetsMetadata
}
}
export interface GoogleSheetsV2ToolParams {
accessToken: string
spreadsheetId: string
sheetName: string
cellRange?: string
values?: any[][]
valueInputOption?: 'RAW' | 'USER_ENTERED'
insertDataOption?: 'OVERWRITE' | 'INSERT_ROWS'
includeValuesInResponse?: boolean
responseValueRenderOption?: 'FORMATTED_VALUE' | 'UNFORMATTED_VALUE' | 'FORMULA'
majorDimension?: 'ROWS' | 'COLUMNS'
filterColumn?: string
filterValue?: string
filterMatchType?: SheetFilterMatchType
}
export type GoogleSheetsV2Response =
| GoogleSheetsV2ReadResponse
| GoogleSheetsV2WriteResponse
| GoogleSheetsV2UpdateResponse
| GoogleSheetsV2AppendResponse
| GoogleSheetsV2ClearResponse
| GoogleSheetsV2GetSpreadsheetResponse
| GoogleSheetsV2CreateSpreadsheetResponse
| GoogleSheetsV2BatchGetResponse
| GoogleSheetsV2BatchUpdateResponse
| GoogleSheetsV2BatchClearResponse
| GoogleSheetsV2CopySheetResponse
| GoogleSheetsV2DeleteRowsResponse
| GoogleSheetsV2DeleteSheetResponse
| GoogleSheetsV2DeleteSpreadsheetResponse
// V2 Clear Types
export interface GoogleSheetsV2ClearParams {
accessToken: string
spreadsheetId: string
sheetName: string
cellRange?: string
}
export interface GoogleSheetsV2ClearResponse extends ToolResponse {
output: {
clearedRange: string
sheetName: string
metadata: GoogleSheetsMetadata
}
}
// V2 Get Spreadsheet Types
export interface GoogleSheetsV2GetSpreadsheetParams {
accessToken: string
spreadsheetId: string
includeGridData?: boolean
}
export interface GoogleSheetsV2GetSpreadsheetResponse extends ToolResponse {
output: {
spreadsheetId: string
title: string
locale: string | null
timeZone: string | null
spreadsheetUrl: string
sheets: {
sheetId: number
title: string
index: number
rowCount: number | null
columnCount: number | null
hidden: boolean
}[]
}
}
// V2 Create Spreadsheet Types
export interface GoogleSheetsV2CreateSpreadsheetParams {
accessToken: string
title: string
sheetTitles?: string[]
locale?: string
timeZone?: string
}
export interface GoogleSheetsV2CreateSpreadsheetResponse extends ToolResponse {
output: {
spreadsheetId: string
title: string
spreadsheetUrl: string
sheets: {
sheetId: number
title: string
index: number
}[]
}
}
// V2 Batch Get Types
export interface GoogleSheetsV2BatchGetParams {
accessToken: string
spreadsheetId: string
ranges: string[]
majorDimension?: 'ROWS' | 'COLUMNS'
valueRenderOption?: 'FORMATTED_VALUE' | 'UNFORMATTED_VALUE' | 'FORMULA'
}
export interface GoogleSheetsV2BatchGetResponse extends ToolResponse {
output: {
spreadsheetId: string
valueRanges: {
range: string
majorDimension: string
values: any[][]
}[]
metadata: GoogleSheetsMetadata
}
}
// V2 Batch Update Types
export interface GoogleSheetsV2BatchUpdateParams {
accessToken: string
spreadsheetId: string
data: {
range: string
values: any[][]
}[]
valueInputOption?: 'RAW' | 'USER_ENTERED'
}
export interface GoogleSheetsV2BatchUpdateResponse extends ToolResponse {
output: {
spreadsheetId: string
totalUpdatedRows: number
totalUpdatedColumns: number
totalUpdatedCells: number
totalUpdatedSheets: number
responses: {
spreadsheetId: string
updatedRange: string
updatedRows: number
updatedColumns: number
updatedCells: number
}[]
metadata: GoogleSheetsMetadata
}
}
// V2 Batch Clear Types
export interface GoogleSheetsV2BatchClearParams {
accessToken: string
spreadsheetId: string
ranges: string[]
}
export interface GoogleSheetsV2BatchClearResponse extends ToolResponse {
output: {
spreadsheetId: string
clearedRanges: string[]
metadata: GoogleSheetsMetadata
}
}
// V2 Copy Sheet Types
export interface GoogleSheetsV2CopySheetParams {
accessToken: string
sourceSpreadsheetId: string
sheetId: number
destinationSpreadsheetId: string
}
export interface GoogleSheetsV2CopySheetResponse extends ToolResponse {
output: {
sheetId: number
title: string
index: number
sheetType: string
destinationSpreadsheetId: string
destinationSpreadsheetUrl: string
}
}
+363
View File
@@ -0,0 +1,363 @@
import type {
GoogleSheetsToolParams,
GoogleSheetsUpdateResponse,
GoogleSheetsV2ToolParams,
GoogleSheetsV2UpdateResponse,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const updateTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsUpdateResponse> = {
id: 'google_sheets_update',
name: 'Update Google Sheets',
description: 'Update data in a Google Sheets spreadsheet',
version: '1.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the spreadsheet to update',
},
range: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The A1 notation range to update (e.g. "Sheet1!A1:D10", "A1:B5")',
},
values: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description:
'The data to update as a 2D array (e.g. [["Name", "Age"], ["Alice", 30]]) or array of objects.',
},
valueInputOption: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The format of the data to update',
},
includeValuesInResponse: {
type: 'boolean',
required: false,
visibility: 'hidden',
description: 'Whether to include the updated values in the response',
},
},
request: {
url: (params) => {
// If range is not provided, use a default range for the first sheet, second row to preserve headers
const range = params.range || 'Sheet1!A2'
const url = new URL(
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(range)}`
)
// Default to USER_ENTERED if not specified
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
url.searchParams.append('valueInputOption', valueInputOption)
if (params.includeValuesInResponse) {
url.searchParams.append('includeValuesInResponse', 'true')
}
return url.toString()
},
method: 'PUT',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
let processedValues: any = params.values || []
// Minimal shape enforcement: Google requires a 2D array
if (!Array.isArray(processedValues)) {
processedValues = [[processedValues]]
} else if (!processedValues.every((item: any) => Array.isArray(item))) {
processedValues = (processedValues as any[]).map((row: any) =>
Array.isArray(row) ? row : [row]
)
}
// Handle array of objects (existing behavior)
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) => {
const data = await response.json()
// Extract spreadsheet ID from the URL (guard if url is missing)
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
// Create a simple metadata object with just the ID and URL
const metadata = {
spreadsheetId,
properties: {},
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${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
},
outputs: {
updatedRange: { type: 'string', description: 'Range of cells that were updated' },
updatedRows: { type: 'number', description: 'Number of rows updated' },
updatedColumns: { type: 'number', description: 'Number of columns updated' },
updatedCells: { type: 'number', description: 'Number of cells updated' },
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
export const updateV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2UpdateResponse> = {
id: 'google_sheets_update_v2',
name: 'Update Google Sheets V2',
description: 'Update data in a specific sheet in a Google Sheets spreadsheet',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
sheetName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The name of the sheet/tab to update',
},
cellRange: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The cell range to update (e.g. "A1:D10", "A1"). Defaults to "A1" if not specified.',
},
values: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description:
'The data to update as a 2D array (e.g. [["Name", "Age"], ["Alice", 30]]) or array of objects.',
},
valueInputOption: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The format of the data to update',
},
includeValuesInResponse: {
type: 'boolean',
required: false,
visibility: 'hidden',
description: 'Whether to include the updated values in the response',
},
},
request: {
url: (params) => {
const sheetName = params.sheetName?.trim()
if (!sheetName) {
throw new Error('Sheet name is required')
}
const cellRange = params.cellRange?.trim() || 'A1'
const fullRange = `${sheetName}!${cellRange}`
const url = new URL(
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(fullRange)}`
)
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
url.searchParams.append('valueInputOption', valueInputOption)
if (params.includeValuesInResponse) {
url.searchParams.append('includeValuesInResponse', 'true')
}
return url.toString()
},
method: 'PUT',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
let processedValues: any = params.values || []
// Minimal shape enforcement: Google requires a 2D array
if (!Array.isArray(processedValues)) {
processedValues = [[processedValues]]
} else if (!processedValues.every((item: any) => Array.isArray(item))) {
processedValues = (processedValues as any[]).map((row: any) =>
Array.isArray(row) ? row : [row]
)
}
// Handle array of objects
if (
Array.isArray(processedValues) &&
processedValues.length > 0 &&
typeof processedValues[0] === 'object' &&
!Array.isArray(processedValues[0])
) {
const allKeys = new Set<string>()
processedValues.forEach((obj: any) => {
if (obj && typeof obj === 'object') {
Object.keys(obj).forEach((key) => allKeys.add(key))
}
})
const headers = Array.from(allKeys)
const rows = processedValues.map((obj: any) => {
if (!obj || typeof obj !== 'object') {
return Array(headers.length).fill('')
}
return headers.map((key) => {
const value = obj[key]
if (value !== null && typeof value === 'object') {
return JSON.stringify(value)
}
return value === undefined ? '' : value
})
})
processedValues = [headers, ...rows]
}
const body: Record<string, any> = {
majorDimension: params.majorDimension || 'ROWS',
values: processedValues,
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const metadata = {
spreadsheetId,
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
}
return {
success: true,
output: {
updatedRange: data.updatedRange ?? null,
updatedRows: data.updatedRows ?? 0,
updatedColumns: data.updatedColumns ?? 0,
updatedCells: data.updatedCells ?? 0,
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
},
},
}
},
outputs: {
updatedRange: { type: 'string', description: 'Range of cells that were updated' },
updatedRows: { type: 'number', description: 'Number of rows updated' },
updatedColumns: { type: 'number', description: 'Number of columns updated' },
updatedCells: { type: 'number', description: 'Number of cells updated' },
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
+356
View File
@@ -0,0 +1,356 @@
import type {
GoogleSheetsToolParams,
GoogleSheetsV2ToolParams,
GoogleSheetsV2WriteResponse,
GoogleSheetsWriteResponse,
} from '@/tools/google_sheets/types'
import type { ToolConfig } from '@/tools/types'
export const writeTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsWriteResponse> = {
id: 'google_sheets_write',
name: 'Write to Google Sheets',
description: 'Write data to a Google Sheets spreadsheet',
version: '1.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the spreadsheet',
},
range: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The A1 notation range to write to (e.g. "Sheet1!A1:D10", "A1:B5")',
},
values: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description:
'The data to write as a 2D array (e.g. [["Name", "Age"], ["Alice", 30], ["Bob", 25]]) or array of objects.',
},
valueInputOption: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The format of the data to write',
},
includeValuesInResponse: {
type: 'boolean',
required: false,
visibility: 'hidden',
description: 'Whether to include the written values in the response',
},
},
request: {
url: (params) => {
// If range is not provided, use a default range for the first sheet, second row to preserve headers
const range = params.range || 'Sheet1!A2'
const url = new URL(
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(range)}`
)
// Default to USER_ENTERED if not specified
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
url.searchParams.append('valueInputOption', valueInputOption)
if (params.includeValuesInResponse) {
url.searchParams.append('includeValuesInResponse', 'true')
}
return url.toString()
},
method: 'PUT',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
let processedValues: any = params.values || []
// Handle array of objects
if (
Array.isArray(processedValues) &&
processedValues.length > 0 &&
typeof processedValues[0] === 'object' &&
!Array.isArray(processedValues[0])
) {
// It's an array of objects
// First, extract all unique keys from all objects to create headers
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)
// Then create rows with object values in the order of headers
const rows = processedValues.map((obj: any) => {
if (!obj || typeof obj !== 'object') {
// Handle non-object items by creating an array with empty values
return Array(headers.length).fill('')
}
return headers.map((key) => {
const value = obj[key]
// Handle nested objects/arrays by converting to JSON string
if (value !== null && typeof value === 'object') {
return JSON.stringify(value)
}
return value === undefined ? '' : value
})
})
// Add headers as the first row, then add data rows
processedValues = [headers, ...rows]
}
const body: Record<string, any> = {
majorDimension: params.majorDimension || 'ROWS',
values: processedValues,
}
// Only include range if it's provided
if (params.range) {
body.range = params.range
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
// Extract spreadsheet ID from the URL (guard if url is missing)
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
// Create a simple metadata object with just the ID and URL
const metadata = {
spreadsheetId,
properties: {},
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${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
},
outputs: {
updatedRange: { type: 'string', description: 'Range of cells that were updated' },
updatedRows: { type: 'number', description: 'Number of rows updated' },
updatedColumns: { type: 'number', description: 'Number of columns updated' },
updatedCells: { type: 'number', description: 'Number of cells updated' },
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}
export const writeV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2WriteResponse> = {
id: 'google_sheets_write_v2',
name: 'Write to Google Sheets V2',
description: 'Write data to a specific sheet in a Google Sheets spreadsheet',
version: '2.0.0',
oauth: {
required: true,
provider: 'google-sheets',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Sheets API',
},
spreadsheetId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Sheets spreadsheet ID',
},
sheetName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The name of the sheet/tab to write to',
},
cellRange: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The cell range to write to (e.g. "A1:D10", "A1"). Defaults to "A1" if not specified.',
},
values: {
type: 'array',
required: true,
visibility: 'user-or-llm',
description:
'The data to write as a 2D array (e.g. [["Name", "Age"], ["Alice", 30], ["Bob", 25]]) or array of objects.',
},
valueInputOption: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The format of the data to write',
},
includeValuesInResponse: {
type: 'boolean',
required: false,
visibility: 'hidden',
description: 'Whether to include the written values in the response',
},
},
request: {
url: (params) => {
const sheetName = params.sheetName?.trim()
if (!sheetName) {
throw new Error('Sheet name is required')
}
// Build the range: SheetName!CellRange or just SheetName!A1
const cellRange = params.cellRange?.trim() || 'A1'
const fullRange = `${sheetName}!${cellRange}`
const url = new URL(
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(fullRange)}`
)
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
url.searchParams.append('valueInputOption', valueInputOption)
if (params.includeValuesInResponse) {
url.searchParams.append('includeValuesInResponse', 'true')
}
return url.toString()
},
method: 'PUT',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
let processedValues: any = params.values || []
// Handle array of objects
if (
Array.isArray(processedValues) &&
processedValues.length > 0 &&
typeof processedValues[0] === 'object' &&
!Array.isArray(processedValues[0])
) {
const allKeys = new Set<string>()
processedValues.forEach((obj: any) => {
if (obj && typeof obj === 'object') {
Object.keys(obj).forEach((key) => allKeys.add(key))
}
})
const headers = Array.from(allKeys)
const rows = processedValues.map((obj: any) => {
if (!obj || typeof obj !== 'object') {
return Array(headers.length).fill('')
}
return headers.map((key) => {
const value = obj[key]
if (value !== null && typeof value === 'object') {
return JSON.stringify(value)
}
return value === undefined ? '' : value
})
})
processedValues = [headers, ...rows]
}
const body: Record<string, any> = {
majorDimension: params.majorDimension || 'ROWS',
values: processedValues,
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const metadata = {
spreadsheetId,
spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
}
return {
success: true,
output: {
updatedRange: data.updatedRange ?? null,
updatedRows: data.updatedRows ?? 0,
updatedColumns: data.updatedColumns ?? 0,
updatedCells: data.updatedCells ?? 0,
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
},
},
}
},
outputs: {
updatedRange: { type: 'string', description: 'Range of cells that were updated' },
updatedRows: { type: 'number', description: 'Number of rows updated' },
updatedColumns: { type: 'number', description: 'Number of columns updated' },
updatedCells: { type: 'number', description: 'Number of cells updated' },
metadata: {
type: 'json',
description: 'Spreadsheet metadata including ID and URL',
properties: {
spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' },
spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' },
},
},
},
}