From 0bc595e8796a9a923d75c7f0da0a369988dd17ec Mon Sep 17 00:00:00 2001 From: Tommy Nguyen Date: Mon, 2 Mar 2026 11:15:06 -0800 Subject: [PATCH 1/5] feat(calendar): support Meet links and attachments in event tools Signed-off-by: Tommy Nguyen --- .../services/CalendarService.test.ts | 248 ++++++++++++++++++ workspace-server/src/index.ts | 26 ++ .../src/services/CalendarService.ts | 84 +++++- 3 files changed, 351 insertions(+), 7 deletions(-) diff --git a/workspace-server/src/__tests__/services/CalendarService.test.ts b/workspace-server/src/__tests__/services/CalendarService.test.ts index 994f272c..462c63b3 100644 --- a/workspace-server/src/__tests__/services/CalendarService.test.ts +++ b/workspace-server/src/__tests__/services/CalendarService.test.ts @@ -1305,4 +1305,252 @@ describe('CalendarService', () => { }); }); }); + + describe('createEvent with Google Meet', () => { + beforeEach(async () => { + mockCalendarAPI.calendarList.list.mockResolvedValue({ + data: { + items: [{ id: 'primary', primary: true }], + }, + }); + }); + + it('should create an event with a Google Meet link', async () => { + const mockCreatedEvent = { + id: 'event123', + summary: 'Meeting with Meet', + conferenceData: { + conferenceId: 'meet-id', + entryPoints: [{ uri: 'https://meet.google.com/abc-defg-hij' }], + }, + }; + + mockCalendarAPI.events.insert.mockResolvedValue({ + data: mockCreatedEvent, + }); + + const result = await calendarService.createEvent({ + calendarId: 'primary', + summary: 'Meeting with Meet', + start: { dateTime: '2024-01-15T10:00:00-07:00' }, + end: { dateTime: '2024-01-15T11:00:00-07:00' }, + addGoogleMeet: true, + }); + + expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith( + expect.objectContaining({ + calendarId: 'primary', + conferenceDataVersion: 1, + requestBody: expect.objectContaining({ + summary: 'Meeting with Meet', + conferenceData: expect.objectContaining({ + createRequest: expect.objectContaining({ + conferenceSolutionKey: { type: 'hangoutsMeet' }, + }), + }), + }), + }), + ); + + expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent); + }); + + it('should not include conferenceData when addGoogleMeet is false', async () => { + const mockCreatedEvent = { id: 'event123', summary: 'No Meet' }; + mockCalendarAPI.events.insert.mockResolvedValue({ + data: mockCreatedEvent, + }); + + await calendarService.createEvent({ + calendarId: 'primary', + summary: 'No Meet', + start: { dateTime: '2024-01-15T10:00:00-07:00' }, + end: { dateTime: '2024-01-15T11:00:00-07:00' }, + addGoogleMeet: false, + }); + + const callArgs = mockCalendarAPI.events.insert.mock.calls[0][0]; + expect(callArgs.conferenceDataVersion).toBeUndefined(); + expect(callArgs.requestBody.conferenceData).toBeUndefined(); + }); + }); + + describe('createEvent with attachments', () => { + beforeEach(async () => { + mockCalendarAPI.calendarList.list.mockResolvedValue({ + data: { + items: [{ id: 'primary', primary: true }], + }, + }); + }); + + it('should create an event with file attachments', async () => { + const mockCreatedEvent = { + id: 'event123', + summary: 'Meeting with Docs', + attachments: [ + { + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Agenda', + }, + ], + }; + + mockCalendarAPI.events.insert.mockResolvedValue({ + data: mockCreatedEvent, + }); + + const result = await calendarService.createEvent({ + calendarId: 'primary', + summary: 'Meeting with Docs', + start: { dateTime: '2024-01-15T10:00:00-07:00' }, + end: { dateTime: '2024-01-15T11:00:00-07:00' }, + attachments: [ + { + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Agenda', + mimeType: 'application/vnd.google-apps.document', + }, + ], + }); + + expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith( + expect.objectContaining({ + supportsAttachments: true, + requestBody: expect.objectContaining({ + attachments: [ + { + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Agenda', + mimeType: 'application/vnd.google-apps.document', + }, + ], + }), + }), + ); + + expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent); + }); + + it('should create an event with both Google Meet and attachments', async () => { + const mockCreatedEvent = { id: 'event123' }; + mockCalendarAPI.events.insert.mockResolvedValue({ + data: mockCreatedEvent, + }); + + await calendarService.createEvent({ + calendarId: 'primary', + summary: 'Full Featured Meeting', + start: { dateTime: '2024-01-15T10:00:00-07:00' }, + end: { dateTime: '2024-01-15T11:00:00-07:00' }, + addGoogleMeet: true, + attachments: [ + { fileUrl: 'https://drive.google.com/open?id=file123' }, + ], + }); + + const callArgs = mockCalendarAPI.events.insert.mock.calls[0][0]; + expect(callArgs.conferenceDataVersion).toBe(1); + expect(callArgs.supportsAttachments).toBe(true); + expect(callArgs.requestBody.conferenceData).toBeDefined(); + expect(callArgs.requestBody.attachments).toBeDefined(); + }); + }); + + describe('updateEvent with Google Meet', () => { + beforeEach(async () => { + mockCalendarAPI.calendarList.list.mockResolvedValue({ + data: { + items: [{ id: 'primary', primary: true }], + }, + }); + }); + + it('should add Google Meet to an existing event', async () => { + const updatedEvent = { + id: 'event123', + conferenceData: { + conferenceId: 'meet-id', + entryPoints: [{ uri: 'https://meet.google.com/abc-defg-hij' }], + }, + }; + + mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + + const result = await calendarService.updateEvent({ + eventId: 'event123', + addGoogleMeet: true, + }); + + const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + expect(callArgs.conferenceDataVersion).toBe(1); + expect(callArgs.requestBody.conferenceData).toBeDefined(); + expect( + callArgs.requestBody.conferenceData.createRequest + .conferenceSolutionKey.type, + ).toBe('hangoutsMeet'); + + expect(JSON.parse(result.content[0].text)).toEqual(updatedEvent); + }); + + it('should not include conferenceData when addGoogleMeet is false', async () => { + const updatedEvent = { id: 'event123', summary: 'No Meet' }; + mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + + await calendarService.updateEvent({ + eventId: 'event123', + summary: 'No Meet', + addGoogleMeet: false, + }); + + const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + expect(callArgs.conferenceDataVersion).toBeUndefined(); + expect(callArgs.requestBody.conferenceData).toBeUndefined(); + }); + }); + + describe('updateEvent with attachments', () => { + beforeEach(async () => { + mockCalendarAPI.calendarList.list.mockResolvedValue({ + data: { + items: [{ id: 'primary', primary: true }], + }, + }); + }); + + it('should add attachments to an existing event', async () => { + const updatedEvent = { + id: 'event123', + attachments: [ + { + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Notes', + }, + ], + }; + + mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + + const result = await calendarService.updateEvent({ + eventId: 'event123', + attachments: [ + { + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Notes', + }, + ], + }); + + const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + expect(callArgs.supportsAttachments).toBe(true); + expect(callArgs.requestBody.attachments).toEqual([ + expect.objectContaining({ + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Notes', + }), + ]); + + expect(JSON.parse(result.content[0].text)).toEqual(updatedEvent); + }); + }); }); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 3044460d..be0758f0 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -25,6 +25,30 @@ import { extractDocId } from './utils/IdUtils'; import { setLoggingEnabled } from './utils/logger'; import { applyToolNameNormalization } from './utils/tool-normalization'; +// Shared schemas for calendar event tools +const eventMeetAndAttachmentsSchema = { + addGoogleMeet: z + .boolean() + .optional() + .describe('Whether to create a Google Meet link for the event.'), + attachments: z + .array( + z.object({ + fileUrl: z.string().describe('Google Drive file URL.'), + title: z + .string() + .optional() + .describe('Display title for the attachment.'), + mimeType: z + .string() + .optional() + .describe('MIME type of the attachment.'), + }), + ) + .optional() + .describe('Google Drive file attachments.'), +}; + // Shared schemas for Gmail tools const emailComposeSchema = { to: z @@ -601,6 +625,7 @@ async function main() { .describe( 'Whether to send notifications to attendees. Defaults to "all" if attendees are provided, otherwise "none".', ), + ...eventMeetAndAttachmentsSchema, }, }, calendarService.createEvent, @@ -719,6 +744,7 @@ async function main() { .array(z.string()) .optional() .describe('The new list of attendees for the event.'), + ...eventMeetAndAttachmentsSchema, }, }, calendarService.updateEvent, diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index 831ba32c..54d4f02d 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -10,6 +10,12 @@ import { gaxiosOptions } from '../utils/GaxiosConfig'; import { iso8601DateTimeSchema, emailArraySchema } from '../utils/validation'; import { z } from 'zod'; +export interface EventAttachment { + fileUrl: string; + title?: string; + mimeType?: string; +} + export interface CreateEventInput { calendarId?: string; summary: string; @@ -18,6 +24,8 @@ export interface CreateEventInput { end: { dateTime: string }; attendees?: string[]; sendUpdates?: 'all' | 'externalOnly' | 'none'; + addGoogleMeet?: boolean; + attachments?: EventAttachment[]; } export interface ListEventsInput { @@ -45,6 +53,8 @@ export interface UpdateEventInput { start?: { dateTime: string }; end?: { dateTime: string }; attendees?: string[]; + addGoogleMeet?: boolean; + attachments?: EventAttachment[]; } export interface RespondToEventInput { @@ -67,6 +77,32 @@ export class CalendarService { constructor(private authManager: any) {} + /** Adds conferenceData and attachments to an event body and its API params. */ + private applyMeetAndAttachments( + event: calendar_v3.Schema$Event, + params: { conferenceDataVersion?: number; supportsAttachments?: boolean }, + addGoogleMeet?: boolean, + attachments?: EventAttachment[], + ): void { + if (addGoogleMeet) { + event.conferenceData = { + createRequest: { + requestId: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + conferenceSolutionKey: { type: 'hangoutsMeet' }, + }, + }; + params.conferenceDataVersion = 1; + } + if (attachments && attachments.length > 0) { + event.attachments = attachments.map((a) => ({ + fileUrl: a.fileUrl, + title: a.title, + mimeType: a.mimeType, + })); + params.supportsAttachments = true; + } + } + private createValidationErrorResponse(error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Validation failed'; @@ -168,6 +204,8 @@ export class CalendarService { end, attendees, sendUpdates, + addGoogleMeet, + attachments, } = input; // Validate datetime formats @@ -188,6 +226,9 @@ export class CalendarService { logToFile(`Event start: ${start.dateTime}`); logToFile(`Event end: ${end.dateTime}`); logToFile(`Event attendees: ${attendees?.join(', ')}`); + if (addGoogleMeet) logToFile('Adding Google Meet link'); + if (attachments?.length) + logToFile(`Attachments: ${attachments.length} file(s)`); // Determine sendUpdates value let finalSendUpdates = sendUpdates; @@ -199,19 +240,28 @@ export class CalendarService { } try { - const event = { + const event: calendar_v3.Schema$Event = { summary, description, start, end, attendees: attendees?.map((email) => ({ email })), }; + const calendar = await this.getCalendar(); - const res = await calendar.events.insert({ + const insertParams: calendar_v3.Params$Resource$Events$Insert = { calendarId: finalCalendarId, requestBody: event, sendUpdates: finalSendUpdates, - }); + }; + this.applyMeetAndAttachments( + event, + insertParams, + addGoogleMeet, + attachments, + ); + + const res = await calendar.events.insert(insertParams); logToFile(`Successfully created event: ${res.data.id}`); return { content: [ @@ -380,8 +430,17 @@ export class CalendarService { }; updateEvent = async (input: UpdateEventInput) => { - const { eventId, calendarId, summary, description, start, end, attendees } = - input; + const { + eventId, + calendarId, + summary, + description, + start, + end, + attendees, + addGoogleMeet, + attachments, + } = input; // Validate datetime formats if provided try { @@ -400,6 +459,9 @@ export class CalendarService { const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); logToFile(`Updating event ${eventId} in calendar: ${finalCalendarId}`); + if (addGoogleMeet) logToFile('Adding Google Meet link'); + if (attachments?.length) + logToFile(`Attachments: ${attachments.length} file(s)`); try { const calendar = await this.getCalendar(); @@ -413,11 +475,19 @@ export class CalendarService { if (attendees) requestBody.attendees = attendees.map((email) => ({ email })); - const res = await calendar.events.update({ + const updateParams: calendar_v3.Params$Resource$Events$Update = { calendarId: finalCalendarId, eventId, requestBody, - }); + }; + this.applyMeetAndAttachments( + requestBody, + updateParams, + addGoogleMeet, + attachments, + ); + + const res = await calendar.events.update(updateParams); logToFile(`Successfully updated event: ${res.data.id}`); return { From f8697562a21db3472bdd4217f51d64b01d504a9d Mon Sep 17 00:00:00 2001 From: Tommy Nguyen Date: Mon, 2 Mar 2026 11:18:47 -0800 Subject: [PATCH 2/5] feat(sheets): add write, create, and sheet management tools Signed-off-by: Tommy Nguyen --- .../__tests__/services/SheetsService.test.ts | 348 ++++++++++++++++++ workspace-server/src/index.ts | 130 ++++++- .../src/services/SheetsService.ts | 319 ++++++++++++++++ 3 files changed, 796 insertions(+), 1 deletion(-) diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index ac5fc7fd..450e755b 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -39,8 +39,13 @@ describe('SheetsService', () => { mockSheetsAPI = { spreadsheets: { get: jest.fn(), + create: jest.fn(), + batchUpdate: jest.fn(), values: { get: jest.fn(), + update: jest.fn(), + append: jest.fn(), + clear: jest.fn(), }, }, }; @@ -432,4 +437,347 @@ describe('SheetsService', () => { expect(response.error).toBe('Metadata Error'); }); }); + + describe('updateRange', () => { + it('should write values to a specific range', async () => { + const mockResponse = { + data: { + updatedRange: 'Sheet1!A1:B2', + updatedRows: 2, + updatedColumns: 2, + updatedCells: 4, + }, + }; + + mockSheetsAPI.spreadsheets.values.update.mockResolvedValue(mockResponse); + + const result = await sheetsService.updateRange({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1:B2', + values: [ + ['A1', 'B1'], + ['A2', 'B2'], + ], + }); + + expect(mockSheetsAPI.spreadsheets.values.update).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1:B2', + valueInputOption: 'USER_ENTERED', + requestBody: { + values: [ + ['A1', 'B1'], + ['A2', 'B2'], + ], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.updatedRange).toBe('Sheet1!A1:B2'); + expect(response.updatedRows).toBe(2); + expect(response.updatedColumns).toBe(2); + expect(response.updatedCells).toBe(4); + }); + + it('should use RAW valueInputOption when specified', async () => { + const mockResponse = { + data: { + updatedRange: 'Sheet1!A1:A1', + updatedRows: 1, + updatedColumns: 1, + updatedCells: 1, + }, + }; + + mockSheetsAPI.spreadsheets.values.update.mockResolvedValue(mockResponse); + + await sheetsService.updateRange({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1', + values: [['=SUM(B1:B10)']], + valueInputOption: 'RAW', + }); + + expect(mockSheetsAPI.spreadsheets.values.update).toHaveBeenCalledWith( + expect.objectContaining({ + valueInputOption: 'RAW', + }), + ); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.values.update.mockRejectedValue( + new Error('Update Error'), + ); + + const result = await sheetsService.updateRange({ + spreadsheetId: 'error-id', + range: 'Sheet1!A1', + values: [['test']], + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Update Error'); + }); + }); + + describe('appendRange', () => { + it('should append rows to a sheet', async () => { + const mockResponse = { + data: { + updates: { + updatedRange: 'Sheet1!A4:B5', + updatedRows: 2, + updatedColumns: 2, + updatedCells: 4, + }, + }, + }; + + mockSheetsAPI.spreadsheets.values.append.mockResolvedValue(mockResponse); + + const result = await sheetsService.appendRange({ + spreadsheetId: 'test-id', + range: 'Sheet1!A:B', + values: [ + ['NewRow1', 'Data1'], + ['NewRow2', 'Data2'], + ], + }); + + expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + range: 'Sheet1!A:B', + valueInputOption: 'USER_ENTERED', + insertDataOption: 'INSERT_ROWS', + requestBody: { + values: [ + ['NewRow1', 'Data1'], + ['NewRow2', 'Data2'], + ], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.updates.updatedRange).toBe('Sheet1!A4:B5'); + expect(response.updates.updatedRows).toBe(2); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.values.append.mockRejectedValue( + new Error('Append Error'), + ); + + const result = await sheetsService.appendRange({ + spreadsheetId: 'error-id', + range: 'Sheet1!A:B', + values: [['test']], + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Append Error'); + }); + }); + + describe('clearRange', () => { + it('should clear values from a range', async () => { + const mockResponse = { + data: { + clearedRange: 'Sheet1!A1:D10', + }, + }; + + mockSheetsAPI.spreadsheets.values.clear.mockResolvedValue(mockResponse); + + const result = await sheetsService.clearRange({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1:D10', + }); + + expect(mockSheetsAPI.spreadsheets.values.clear).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1:D10', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.clearedRange).toBe('Sheet1!A1:D10'); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.values.clear.mockRejectedValue( + new Error('Clear Error'), + ); + + const result = await sheetsService.clearRange({ + spreadsheetId: 'error-id', + range: 'Sheet1!A1:A1', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Clear Error'); + }); + }); + + describe('createSpreadsheet', () => { + it('should create a new spreadsheet', async () => { + const mockResponse = { + data: { + spreadsheetId: 'new-spreadsheet-id', + spreadsheetUrl: 'https://docs.google.com/spreadsheets/d/new-spreadsheet-id', + properties: { title: 'My New Sheet' }, + sheets: [ + { properties: { sheetId: 0, title: 'Sheet1' } }, + ], + }, + }; + + mockSheetsAPI.spreadsheets.create.mockResolvedValue(mockResponse); + + const result = await sheetsService.createSpreadsheet({ + title: 'My New Sheet', + }); + + expect(mockSheetsAPI.spreadsheets.create).toHaveBeenCalledWith({ + requestBody: { + properties: { title: 'My New Sheet' }, + sheets: undefined, + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.spreadsheetId).toBe('new-spreadsheet-id'); + expect(response.title).toBe('My New Sheet'); + }); + + it('should create a spreadsheet with custom sheet titles', async () => { + const mockResponse = { + data: { + spreadsheetId: 'new-id', + spreadsheetUrl: 'https://docs.google.com/spreadsheets/d/new-id', + properties: { title: 'Budget' }, + sheets: [ + { properties: { sheetId: 0, title: 'Summary' } }, + { properties: { sheetId: 1, title: 'Data' } }, + ], + }, + }; + + mockSheetsAPI.spreadsheets.create.mockResolvedValue(mockResponse); + + const result = await sheetsService.createSpreadsheet({ + title: 'Budget', + sheetTitles: ['Summary', 'Data'], + }); + + expect(mockSheetsAPI.spreadsheets.create).toHaveBeenCalledWith({ + requestBody: { + properties: { title: 'Budget' }, + sheets: [ + { properties: { title: 'Summary' } }, + { properties: { title: 'Data' } }, + ], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.sheets).toHaveLength(2); + expect(response.sheets[0].title).toBe('Summary'); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.create.mockRejectedValue( + new Error('Create Error'), + ); + + const result = await sheetsService.createSpreadsheet({ + title: 'Error Sheet', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Create Error'); + }); + }); + + describe('addSheet', () => { + it('should add a new sheet to a spreadsheet', async () => { + const mockResponse = { + data: { + replies: [ + { + addSheet: { + properties: { sheetId: 123, title: 'New Tab' }, + }, + }, + ], + }, + }; + + mockSheetsAPI.spreadsheets.batchUpdate.mockResolvedValue(mockResponse); + + const result = await sheetsService.addSheet({ + spreadsheetId: 'test-id', + title: 'New Tab', + }); + + expect(mockSheetsAPI.spreadsheets.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + requestBody: { + requests: [{ addSheet: { properties: { title: 'New Tab' } } }], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.sheetId).toBe(123); + expect(response.title).toBe('New Tab'); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.batchUpdate.mockRejectedValue( + new Error('AddSheet Error'), + ); + + const result = await sheetsService.addSheet({ + spreadsheetId: 'error-id', + title: 'Bad Tab', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('AddSheet Error'); + }); + }); + + describe('deleteSheet', () => { + it('should delete a sheet from a spreadsheet', async () => { + mockSheetsAPI.spreadsheets.batchUpdate.mockResolvedValue({ data: {} }); + + const result = await sheetsService.deleteSheet({ + spreadsheetId: 'test-id', + sheetId: 456, + }); + + expect(mockSheetsAPI.spreadsheets.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + requestBody: { + requests: [{ deleteSheet: { sheetId: 456 } }], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.message).toBe('Successfully deleted sheet 456'); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.batchUpdate.mockRejectedValue( + new Error('DeleteSheet Error'), + ); + + const result = await sheetsService.deleteSheet({ + spreadsheetId: 'error-id', + sheetId: 999, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('DeleteSheet Error'); + }); + }); }); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index be0758f0..5601eeb7 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -81,7 +81,7 @@ const SCOPES = [ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/directory.readonly', 'https://www.googleapis.com/auth/presentations.readonly', - 'https://www.googleapis.com/auth/spreadsheets.readonly', + 'https://www.googleapis.com/auth/spreadsheets', ]; // Dynamically import version from package.json @@ -523,6 +523,134 @@ async function main() { sheetsService.getMetadata, ); + server.registerTool( + 'sheets.updateRange', + { + description: + 'Writes values to a specific range in a Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + range: z + .string() + .describe( + 'The A1 notation range to write to (e.g., "Sheet1!A1:B2").', + ), + values: z + .array( + z.array( + z.union([z.string(), z.number(), z.boolean(), z.null()]), + ), + ) + .describe( + 'The values to write, as a 2D array (rows x columns). Supports strings, numbers, booleans, and null.', + ), + valueInputOption: z + .enum(['RAW', 'USER_ENTERED']) + .optional() + .describe( + 'How to interpret the input values. RAW: values are stored as-is. USER_ENTERED: values are parsed as if typed into the UI (default: USER_ENTERED).', + ), + }, + }, + sheetsService.updateRange, + ); + + server.registerTool( + 'sheets.appendRange', + { + description: + 'Appends rows of values after the last row with data in a Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + range: z + .string() + .describe( + 'The A1 notation range to search for data to append after (e.g., "Sheet1!A:E").', + ), + values: z + .array( + z.array( + z.union([z.string(), z.number(), z.boolean(), z.null()]), + ), + ) + .describe( + 'The rows to append, as a 2D array. Supports strings, numbers, booleans, and null.', + ), + valueInputOption: z + .enum(['RAW', 'USER_ENTERED']) + .optional() + .describe( + 'How to interpret the input values. RAW: values are stored as-is. USER_ENTERED: values are parsed as if typed into the UI (default: USER_ENTERED).', + ), + }, + }, + sheetsService.appendRange, + ); + + server.registerTool( + 'sheets.clearRange', + { + description: + 'Clears all values from a specific range in a Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + range: z + .string() + .describe( + 'The A1 notation range to clear (e.g., "Sheet1!A1:B2").', + ), + }, + }, + sheetsService.clearRange, + ); + + server.registerTool( + 'sheets.createSpreadsheet', + { + description: 'Creates a new Google Sheets spreadsheet.', + inputSchema: { + title: z.string().describe('The title of the new spreadsheet.'), + sheetTitles: z + .array(z.string()) + .optional() + .describe( + 'Optional list of sheet/tab names to create. Defaults to a single "Sheet1" tab.', + ), + }, + }, + sheetsService.createSpreadsheet, + ); + + server.registerTool( + 'sheets.addSheet', + { + description: + 'Adds a new sheet (tab) to an existing Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + title: z.string().describe('The title of the new sheet/tab.'), + }, + }, + sheetsService.addSheet, + ); + + server.registerTool( + 'sheets.deleteSheet', + { + description: + 'Deletes a sheet (tab) from a Google Sheets spreadsheet by its numeric sheet ID.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + sheetId: z + .number() + .describe( + 'The numeric ID of the sheet to delete. Use sheets.getMetadata to find sheet IDs.', + ), + }, + }, + sheetsService.deleteSheet, + ); + server.registerTool( 'drive.search', { diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index fb8df36f..6620e87e 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -258,6 +258,325 @@ export class SheetsService { } }; + public updateRange = async ({ + spreadsheetId, + range, + values, + valueInputOption = 'USER_ENTERED', + }: { + spreadsheetId: string; + range: string; + values: (string | number | boolean | null)[][]; + valueInputOption?: 'RAW' | 'USER_ENTERED'; + }) => { + logToFile( + `[SheetsService] Starting updateRange for spreadsheet: ${spreadsheetId}, range: ${range}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.values.update({ + spreadsheetId: id, + range, + valueInputOption, + requestBody: { values }, + }); + + logToFile(`[SheetsService] Finished updateRange for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + updatedRange: response.data.updatedRange, + updatedRows: response.data.updatedRows, + updatedColumns: response.data.updatedColumns, + updatedCells: response.data.updatedCells, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.updateRange: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public appendRange = async ({ + spreadsheetId, + range, + values, + valueInputOption = 'USER_ENTERED', + }: { + spreadsheetId: string; + range: string; + values: (string | number | boolean | null)[][]; + valueInputOption?: 'RAW' | 'USER_ENTERED'; + }) => { + logToFile( + `[SheetsService] Starting appendRange 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, + valueInputOption, + insertDataOption: 'INSERT_ROWS', + requestBody: { values }, + }); + + logToFile(`[SheetsService] Finished appendRange for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + updates: { + 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.appendRange: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public clearRange = async ({ + spreadsheetId, + range, + }: { + spreadsheetId: string; + range: string; + }) => { + logToFile( + `[SheetsService] Starting clearRange for spreadsheet: ${spreadsheetId}, range: ${range}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.values.clear({ + spreadsheetId: id, + range, + }); + + logToFile(`[SheetsService] Finished clearRange for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + clearedRange: response.data.clearedRange, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.clearRange: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public createSpreadsheet = async ({ + title, + sheetTitles, + }: { + title: string; + sheetTitles?: string[]; + }) => { + logToFile( + `[SheetsService] Starting createSpreadsheet with title: ${title}`, + ); + try { + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.create({ + requestBody: { + properties: { title }, + sheets: sheetTitles?.map((t) => ({ properties: { title: t } })), + }, + }); + + logToFile( + `[SheetsService] Created spreadsheet: ${response.data.spreadsheetId}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + spreadsheetId: response.data.spreadsheetId, + spreadsheetUrl: response.data.spreadsheetUrl, + title: response.data.properties?.title, + sheets: response.data.sheets?.map((s) => ({ + sheetId: s.properties?.sheetId, + title: s.properties?.title, + })), + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.createSpreadsheet: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public addSheet = async ({ + spreadsheetId, + title, + }: { + spreadsheetId: string; + title: string; + }) => { + logToFile( + `[SheetsService] Starting addSheet for spreadsheet: ${spreadsheetId}, title: ${title}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.batchUpdate({ + spreadsheetId: id, + requestBody: { + requests: [{ addSheet: { properties: { title } } }], + }, + }); + + const addedSheet = response.data.replies?.[0]?.addSheet; + logToFile(`[SheetsService] Added sheet to spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + sheetId: addedSheet?.properties?.sheetId, + title: addedSheet?.properties?.title, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.addSheet: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public deleteSheet = async ({ + spreadsheetId, + sheetId, + }: { + spreadsheetId: string; + sheetId: number; + }) => { + logToFile( + `[SheetsService] Starting deleteSheet for spreadsheet: ${spreadsheetId}, sheetId: ${sheetId}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + await sheets.spreadsheets.batchUpdate({ + spreadsheetId: id, + requestBody: { + requests: [{ deleteSheet: { sheetId } }], + }, + }); + + logToFile( + `[SheetsService] Deleted sheet ${sheetId} from spreadsheet: ${id}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + message: `Successfully deleted sheet ${sheetId}`, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.deleteSheet: ${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}`, From 90b95a948259193a0d8e3df7eed8ba6e97237c68 Mon Sep 17 00:00:00 2001 From: Tommy Nguyen Date: Tue, 7 Apr 2026 08:50:40 -0700 Subject: [PATCH 3/5] fix(calendar): use patch for event updates and disable sheets.write by default Signed-off-by: Tommy Nguyen --- .../__tests__/features/feature-config.test.ts | 3 ++- .../services/CalendarService.test.ts | 26 +++++++++---------- .../src/features/feature-config.ts | 2 +- .../src/services/CalendarService.ts | 6 ++--- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/workspace-server/src/__tests__/features/feature-config.test.ts b/workspace-server/src/__tests__/features/feature-config.test.ts index 6555f21c..f304664e 100644 --- a/workspace-server/src/__tests__/features/feature-config.test.ts +++ b/workspace-server/src/__tests__/features/feature-config.test.ts @@ -24,11 +24,12 @@ describe('feature-config', () => { expect(duplicates).toEqual([]); }); - it('should have slides.write, tasks.read, and tasks.write defaulted to OFF', () => { + it('should have slides.write, sheets.write, tasks.read, and tasks.write defaulted to OFF', () => { const offByDefault = FEATURE_GROUPS.filter((fg) => !fg.defaultEnabled).map( featureGroupKey, ); expect(offByDefault).toContain('slides.write'); + expect(offByDefault).toContain('sheets.write'); expect(offByDefault).toContain('tasks.read'); expect(offByDefault).toContain('tasks.write'); }); diff --git a/workspace-server/src/__tests__/services/CalendarService.test.ts b/workspace-server/src/__tests__/services/CalendarService.test.ts index e01e2e2c..ccd0201d 100644 --- a/workspace-server/src/__tests__/services/CalendarService.test.ts +++ b/workspace-server/src/__tests__/services/CalendarService.test.ts @@ -857,7 +857,7 @@ describe('CalendarService', () => { attendees: [{ email: 'new@example.com' }], }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', @@ -867,7 +867,7 @@ describe('CalendarService', () => { attendees: ['new@example.com'], }); - expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({ + expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { @@ -889,14 +889,14 @@ describe('CalendarService', () => { description: 'New updated description', }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', description: 'New updated description', }); - expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({ + expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { @@ -910,7 +910,7 @@ describe('CalendarService', () => { it('should handle update errors', async () => { const apiError = new Error('Update failed'); - mockCalendarAPI.events.update.mockRejectedValue(apiError); + mockCalendarAPI.events.patch.mockRejectedValue(apiError); const result = await calendarService.updateEvent({ eventId: 'event123', @@ -927,14 +927,14 @@ describe('CalendarService', () => { summary: 'Updated Meeting Only', }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); await calendarService.updateEvent({ eventId: 'event123', summary: 'Updated Meeting Only', }); - expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({ + expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { @@ -1495,14 +1495,14 @@ describe('CalendarService', () => { }, }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', addGoogleMeet: true, }); - const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0]; expect(callArgs.conferenceDataVersion).toBe(1); expect(callArgs.requestBody.conferenceData).toBeDefined(); expect( @@ -1515,7 +1515,7 @@ describe('CalendarService', () => { it('should not include conferenceData when addGoogleMeet is false', async () => { const updatedEvent = { id: 'event123', summary: 'No Meet' }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); await calendarService.updateEvent({ eventId: 'event123', @@ -1523,7 +1523,7 @@ describe('CalendarService', () => { addGoogleMeet: false, }); - const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0]; expect(callArgs.conferenceDataVersion).toBeUndefined(); expect(callArgs.requestBody.conferenceData).toBeUndefined(); }); @@ -1541,7 +1541,7 @@ describe('CalendarService', () => { ], }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', @@ -1553,7 +1553,7 @@ describe('CalendarService', () => { ], }); - const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0]; expect(callArgs.supportsAttachments).toBe(true); expect(callArgs.requestBody.attachments).toEqual([ expect.objectContaining({ diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index 7cb8aa80..130aba1e 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -234,7 +234,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'sheets.addSheet', 'sheets.deleteSheet', ], - defaultEnabled: true, + defaultEnabled: false, }, // Time (no scopes needed) diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index b9c2a317..f8a01eb6 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -735,19 +735,19 @@ export class CalendarService { if (attendees) requestBody.attendees = attendees.map((email) => ({ email })); - const updateParams: calendar_v3.Params$Resource$Events$Update = { + const patchParams: calendar_v3.Params$Resource$Events$Patch = { calendarId: finalCalendarId, eventId, requestBody, }; this.applyMeetAndAttachments( requestBody, - updateParams, + patchParams, addGoogleMeet, attachments, ); - const res = await calendar.events.update(updateParams); + const res = await calendar.events.patch(patchParams); logToFile(`Successfully updated event: ${res.data.id}`); return { From 388be47d489051b7f2d3def340099a18d1b3ef10 Mon Sep 17 00:00:00 2001 From: Tommy Nguyen Date: Thu, 28 May 2026 00:47:51 -0700 Subject: [PATCH 4/5] style: format tests and schema definitions Signed-off-by: Tommy Nguyen --- .../src/__tests__/services/CalendarService.test.ts | 1 - .../src/__tests__/services/SheetsService.test.ts | 7 +++---- workspace-server/src/index.ts | 13 +++---------- workspace-server/src/services/CalendarService.ts | 2 -- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/workspace-server/src/__tests__/services/CalendarService.test.ts b/workspace-server/src/__tests__/services/CalendarService.test.ts index 380ad4b7..0acbb9c0 100644 --- a/workspace-server/src/__tests__/services/CalendarService.test.ts +++ b/workspace-server/src/__tests__/services/CalendarService.test.ts @@ -1915,7 +1915,6 @@ describe('CalendarService', () => { }, }; - mockCalendarAPI.events.insert.mockResolvedValue({ data: mockCreatedEvent, }); diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index d15f61ac..00c93a37 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -561,11 +561,10 @@ describe('SheetsService', () => { const mockResponse = { data: { spreadsheetId: 'new-spreadsheet-id', - spreadsheetUrl: 'https://docs.google.com/spreadsheets/d/new-spreadsheet-id', + spreadsheetUrl: + 'https://docs.google.com/spreadsheets/d/new-spreadsheet-id', properties: { title: 'My New Sheet' }, - sheets: [ - { properties: { sheetId: 0, title: 'Sheet1' } }, - ], + sheets: [{ properties: { sheetId: 0, title: 'Sheet1' } }], }, }; diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 2b44cfee..4adcb3d6 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -98,7 +98,6 @@ const emailComposeSchema = { .describe('Whether the body is HTML (default: false).'), }; - // Dynamically import version from package.json import { version } from '../package.json'; @@ -538,9 +537,7 @@ async function main() { ), values: z .array( - z.array( - z.union([z.string(), z.number(), z.boolean(), z.null()]), - ), + z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), ) .describe( 'The values to write, as a 2D array (rows x columns). Supports strings, numbers, booleans, and null.', @@ -570,9 +567,7 @@ async function main() { ), values: z .array( - z.array( - z.union([z.string(), z.number(), z.boolean(), z.null()]), - ), + z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), ) .describe( 'The rows to append, as a 2D array. Supports strings, numbers, booleans, and null.', @@ -597,9 +592,7 @@ async function main() { spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), range: z .string() - .describe( - 'The A1 notation range to clear (e.g., "Sheet1!A1:B2").', - ), + .describe('The A1 notation range to clear (e.g., "Sheet1!A1:B2").'), }, }, sheetsService.clearRange, diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index ddc6c9d3..bd30b6a6 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -33,7 +33,6 @@ export type CalendarEventType = export type ListEventsEventType = CalendarEventType | 'birthday' | 'fromGmail'; - export interface CreateEventInput { calendarId?: string; summary?: string; @@ -428,7 +427,6 @@ export class CalendarService { event.workingLocationProperties = wlProps; } - const calendar = await this.getCalendar(); const insertParams: calendar_v3.Params$Resource$Events$Insert = { calendarId: finalCalendarId, From 6551bc7ae97c9b39089421aa4ed8752ac2d2fec4 Mon Sep 17 00:00:00 2001 From: Tommy Nguyen Date: Fri, 5 Jun 2026 21:57:19 -0700 Subject: [PATCH 5/5] Address Sheets write review feedback Signed-off-by: Tommy Nguyen --- docs/feature-configuration.md | 13 ++- docs/index.md | 8 ++ .../__tests__/services/SheetsService.test.ts | 65 ++++++++++- .../src/services/CalendarService.ts | 8 +- .../src/services/SheetsService.ts | 106 +++++------------- 5 files changed, 114 insertions(+), 86 deletions(-) diff --git a/docs/feature-configuration.md b/docs/feature-configuration.md index 3e9aed6c..1a7906d3 100644 --- a/docs/feature-configuration.md +++ b/docs/feature-configuration.md @@ -64,8 +64,8 @@ export WORKSPACE_FEATURE_OVERRIDES="gmail.write:off" # Disable all of Chat export WORKSPACE_FEATURE_OVERRIDES="chat.read:off,chat.write:off" -# Enable experimental features (Slides write, Tasks) -export WORKSPACE_FEATURE_OVERRIDES="slides.write:on,tasks.read:on,tasks.write:on" +# Enable experimental features (Slides write, Sheets write, Tasks) +export WORKSPACE_FEATURE_OVERRIDES="slides.write:on,sheets.write:on,tasks.read:on,tasks.write:on" ``` ### Tool-Level Overrides @@ -197,6 +197,15 @@ When a feature group is disabled: - `sheets.getRange` - `sheets.getMetadata` +### `sheets.write` + +- `sheets.updateRange` +- `sheets.appendRange` +- `sheets.clearRange` +- `sheets.createSpreadsheet` +- `sheets.addSheet` +- `sheets.deleteSheet` + ### `time.read` - `time.getCurrentDate` diff --git a/docs/index.md b/docs/index.md index 7d7b622e..031236f1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,6 +40,14 @@ The extension provides the following tools: - `sheets.getRange`: Gets values from a specific range in a Google Sheets spreadsheet. - `sheets.getMetadata`: Gets metadata about a Google Sheets spreadsheet. +- `sheets.updateRange`: Writes values to a specific range in a Google Sheets + spreadsheet. +- `sheets.appendRange`: Appends rows to a range in a Google Sheets spreadsheet. +- `sheets.clearRange`: Clears values from a specific range in a Google Sheets + spreadsheet. +- `sheets.createSpreadsheet`: Creates a new Google Sheets spreadsheet. +- `sheets.addSheet`: Adds a sheet tab to a Google Sheets spreadsheet. +- `sheets.deleteSheet`: Deletes a sheet tab from a Google Sheets spreadsheet. ### Google Drive diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index 00c93a37..b7258fc2 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -443,6 +443,32 @@ describe('SheetsService', () => { ); }); + it('should extract spreadsheet ID from a Sheets URL', async () => { + const mockResponse = { + data: { + updatedRange: 'Sheet1!A1:A1', + updatedRows: 1, + updatedColumns: 1, + updatedCells: 1, + }, + }; + + mockSheetsAPI.spreadsheets.values.update.mockResolvedValue(mockResponse); + + await sheetsService.updateRange({ + spreadsheetId: + 'https://docs.google.com/spreadsheets/d/url-spreadsheet-id/edit#gid=0', + range: 'Sheet1!A1', + values: [['test']], + }); + + expect(mockSheetsAPI.spreadsheets.values.update).toHaveBeenCalledWith( + expect.objectContaining({ + spreadsheetId: 'url-spreadsheet-id', + }), + ); + }); + it('should handle errors gracefully', async () => { mockSheetsAPI.spreadsheets.values.update.mockRejectedValue( new Error('Update Error'), @@ -455,6 +481,7 @@ describe('SheetsService', () => { }); const response = JSON.parse(result.content[0].text); + expect(result).toHaveProperty('isError', true); expect(response.error).toBe('Update Error'); }); }); @@ -501,6 +528,22 @@ describe('SheetsService', () => { expect(response.updates.updatedRows).toBe(2); }); + it('should handle an append response without updates', async () => { + mockSheetsAPI.spreadsheets.values.append.mockResolvedValue({ data: {} }); + + const result = await sheetsService.appendRange({ + spreadsheetId: 'test-id', + range: 'Sheet1!A:B', + values: [['NewRow']], + }); + + const response = JSON.parse(result.content[0].text); + expect(response.updates.updatedRange).toBeUndefined(); + expect(response.updates.updatedRows).toBeUndefined(); + expect(response.updates.updatedColumns).toBeUndefined(); + expect(response.updates.updatedCells).toBeUndefined(); + }); + it('should handle errors gracefully', async () => { mockSheetsAPI.spreadsheets.values.append.mockRejectedValue( new Error('Append Error'), @@ -513,6 +556,7 @@ describe('SheetsService', () => { }); const response = JSON.parse(result.content[0].text); + expect(result).toHaveProperty('isError', true); expect(response.error).toBe('Append Error'); }); }); @@ -552,6 +596,7 @@ describe('SheetsService', () => { }); const response = JSON.parse(result.content[0].text); + expect(result).toHaveProperty('isError', true); expect(response.error).toBe('Clear Error'); }); }); @@ -631,6 +676,7 @@ describe('SheetsService', () => { }); const response = JSON.parse(result.content[0].text); + expect(result).toHaveProperty('isError', true); expect(response.error).toBe('Create Error'); }); }); @@ -668,6 +714,19 @@ describe('SheetsService', () => { expect(response.title).toBe('New Tab'); }); + it('should handle an addSheet response without replies', async () => { + mockSheetsAPI.spreadsheets.batchUpdate.mockResolvedValue({ data: {} }); + + const result = await sheetsService.addSheet({ + spreadsheetId: 'test-id', + title: 'New Tab', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.sheetId).toBeUndefined(); + expect(response.title).toBeUndefined(); + }); + it('should handle errors gracefully', async () => { mockSheetsAPI.spreadsheets.batchUpdate.mockRejectedValue( new Error('AddSheet Error'), @@ -679,6 +738,7 @@ describe('SheetsService', () => { }); const response = JSON.parse(result.content[0].text); + expect(result).toHaveProperty('isError', true); expect(response.error).toBe('AddSheet Error'); }); }); @@ -700,7 +760,9 @@ describe('SheetsService', () => { }); const response = JSON.parse(result.content[0].text); - expect(response.message).toBe('Successfully deleted sheet 456'); + expect(response.message).toBe( + 'Delete sheet request completed for sheet 456', + ); }); it('should handle errors gracefully', async () => { @@ -714,6 +776,7 @@ describe('SheetsService', () => { }); const response = JSON.parse(result.content[0].text); + expect(result).toHaveProperty('isError', true); expect(response.error).toBe('DeleteSheet Error'); }); }); diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index bd30b6a6..33c0b2c5 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -19,7 +19,7 @@ import { z } from 'zod'; * Google Drive file attachment for calendar events. * Attachments are fully replaced (not appended) when provided. */ -export interface EventAttachment { +interface EventAttachment { fileUrl: string; title?: string; mimeType?: string; @@ -653,20 +653,20 @@ export class CalendarService { if (attendees !== undefined) requestBody.attendees = attendees.map((email) => ({ email })); - const patchParams: calendar_v3.Params$Resource$Events$Patch = { + const updateParams: calendar_v3.Params$Resource$Events$Patch = { calendarId: finalCalendarId, eventId, requestBody, }; this.applyMeetAndAttachments( requestBody, - patchParams, + updateParams, addGoogleMeet, attachments, { allowEmptyAttachments: true }, ); - const res = await calendar.events.patch(patchParams); + const res = await calendar.events.patch(updateParams); logToFile(`Successfully updated event: ${res.data.id}`); return { diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index 3bb9ab18..4ba9e5a1 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -13,6 +13,26 @@ import { gaxiosOptions } from '../utils/GaxiosConfig'; export class SheetsService { constructor(private authManager: AuthManager) {} + private handleError( + context: string, + error: unknown, + ): { + isError: true; + content: { type: 'text'; text: string }[]; + } { + const errorMessage = error instanceof Error ? error.message : String(error); + logToFile(`[SheetsService] Error during ${context}: ${errorMessage}`); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + private async getSheetsClient(): Promise { const auth = await this.authManager.getAuthenticatedClient(); const options = { ...gaxiosOptions, auth }; @@ -234,19 +254,7 @@ export class SheetsService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile( - `[SheetsService] Error during sheets.updateRange: ${errorMessage}`, - ); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), - }, - ], - }; + return this.handleError('sheets.updateRange', error); } }; @@ -293,19 +301,7 @@ export class SheetsService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile( - `[SheetsService] Error during sheets.appendRange: ${errorMessage}`, - ); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), - }, - ], - }; + return this.handleError('sheets.appendRange', error); } }; @@ -340,19 +336,7 @@ export class SheetsService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile( - `[SheetsService] Error during sheets.clearRange: ${errorMessage}`, - ); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), - }, - ], - }; + return this.handleError('sheets.clearRange', error); } }; @@ -395,19 +379,7 @@ export class SheetsService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile( - `[SheetsService] Error during sheets.createSpreadsheet: ${errorMessage}`, - ); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), - }, - ], - }; + return this.handleError('sheets.createSpreadsheet', error); } }; @@ -446,19 +418,7 @@ export class SheetsService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile( - `[SheetsService] Error during sheets.addSheet: ${errorMessage}`, - ); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), - }, - ], - }; + return this.handleError('sheets.addSheet', error); } }; @@ -491,25 +451,13 @@ export class SheetsService { { type: 'text' as const, text: JSON.stringify({ - message: `Successfully deleted sheet ${sheetId}`, + message: `Delete sheet request completed for sheet ${sheetId}`, }), }, ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile( - `[SheetsService] Error during sheets.deleteSheet: ${errorMessage}`, - ); - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), - }, - ], - }; + return this.handleError('sheets.deleteSheet', error); } };