diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index 50c1ff4..b3c4f8d 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -40,6 +40,7 @@ describe('SheetsService', () => { get: jest.fn(), values: { get: jest.fn(), + append: jest.fn(), }, }, }; @@ -370,4 +371,71 @@ describe('SheetsService', () => { expect(response.error).toBe('Metadata Error'); }); }); + + describe('appendRows', () => { + it('should append rows and return update info', async () => { + const mockResponse = { + data: { + updates: { + updatedRange: 'Sheet1!A3:B3', + updatedRows: 1, + updatedColumns: 2, + updatedCells: 2, + }, + }, + }; + + mockSheetsAPI.spreadsheets.values.append.mockResolvedValue(mockResponse); + + const result = await sheetsService.appendRows({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1', + values: [['foo', 'bar']], + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1', + valueInputOption: 'USER_ENTERED', + requestBody: { values: [['foo', 'bar']] }, + }); + + expect(response.updatedRange).toBe('Sheet1!A3:B3'); + expect(response.updatedRows).toBe(1); + expect(response.updatedColumns).toBe(2); + expect(response.updatedCells).toBe(2); + }); + + it('should extract spreadsheet ID from URL', async () => { + mockSheetsAPI.spreadsheets.values.append.mockResolvedValue({ + data: { updates: { updatedRange: 'Sheet1!A2:A2', updatedRows: 1, updatedColumns: 1, updatedCells: 1 } }, + }); + + await sheetsService.appendRows({ + spreadsheetId: 'https://docs.google.com/spreadsheets/d/abc123/edit', + range: 'Sheet1!A1', + values: [['value']], + }); + + expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith( + expect.objectContaining({ spreadsheetId: 'abc123' }), + ); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.values.append.mockRejectedValue( + new Error('Append Error'), + ); + + const result = await sheetsService.appendRows({ + spreadsheetId: 'error-id', + range: 'Sheet1!A1', + values: [['data']], + }); + const response = JSON.parse(result.content[0].text); + + expect(response.error).toBe('Append Error'); + }); + }); }); diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index e3a2cb1..56afe30 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -226,7 +226,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ service: 'sheets', group: 'write', scopes: scopes('spreadsheets'), - tools: [], + tools: ['sheets.appendRows'], defaultEnabled: false, }, diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 59a01ce..302048d 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -505,6 +505,28 @@ async function main() { sheetsService.getMetadata, ); + registerTool( + 'sheets.appendRows', + { + description: + 'Appends rows to a Google Sheets spreadsheet after the last row with data in the given range.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + range: z + .string() + .describe( + 'The A1 notation range to append to (e.g., "Sheet1!A1"). Data is appended after the last row with data in this range.', + ), + values: z + .array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))) + .describe( + 'A 2D array of values to append. Each inner array is a row (e.g., [["col1", "col2"], ["val1", "val2"]]).', + ), + }, + }, + sheetsService.appendRows, + ); + registerTool( 'drive.search', { diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index 636f03c..739d271 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -194,6 +194,62 @@ export class SheetsService { } }; + public appendRows = async ({ + spreadsheetId, + range, + values, + }: { + spreadsheetId: string; + range: string; + values: (string | number | boolean | null)[][]; + }) => { + logToFile( + `[SheetsService] Starting appendRows for spreadsheet: ${spreadsheetId}, range: ${range}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.values.append({ + spreadsheetId: id, + range: range, + valueInputOption: 'USER_ENTERED', + requestBody: { + values: values, + }, + }); + + logToFile(`[SheetsService] Finished appendRows for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + updatedRange: response.data.updates?.updatedRange, + updatedRows: response.data.updates?.updatedRows, + updatedColumns: response.data.updates?.updatedColumns, + updatedCells: response.data.updates?.updatedCells, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.appendRows: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + public getMetadata = async ({ spreadsheetId }: { spreadsheetId: string }) => { logToFile( `[SheetsService] Starting getMetadata for spreadsheet: ${spreadsheetId}`,