diff --git a/workspace-server/src/__tests__/services/CalendarService.test.ts b/workspace-server/src/__tests__/services/CalendarService.test.ts index 94fde5e..ccd0201 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({ @@ -1791,6 +1791,7 @@ 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 50c1ff4..d15f61a 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -38,8 +38,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(), }, }, }; @@ -370,4 +375,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/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index e3a2cb1..130aba1 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -226,7 +226,14 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ service: 'sheets', group: 'write', scopes: scopes('spreadsheets'), - tools: [], + tools: [ + 'sheets.updateRange', + 'sheets.appendRange', + 'sheets.clearRange', + 'sheets.createSpreadsheet', + 'sheets.addSheet', + 'sheets.deleteSheet', + ], defaultEnabled: false, }, diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 59a01ce..4787fc6 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -80,6 +80,7 @@ const emailComposeSchema = { .describe('Whether the body is HTML (default: false).'), }; + // Dynamically import version from package.json import { version } from '../package.json'; @@ -505,6 +506,134 @@ async function main() { sheetsService.getMetadata, ); + 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, + ); + + 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, + ); + + 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, + ); + + 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, + ); + + 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, + ); + + 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, + ); + registerTool( 'drive.search', { diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index e67f3bb..f8a01eb 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -15,7 +15,7 @@ import { z } from 'zod'; * Google Drive file attachment for calendar events. * Attachments are fully replaced (not appended) when provided. */ -interface EventAttachment { +export interface EventAttachment { fileUrl: string; title?: string; mimeType?: string; @@ -29,6 +29,7 @@ export type CalendarEventType = export type ListEventsEventType = CalendarEventType | 'birthday' | 'fromGmail'; + export interface CreateEventInput { calendarId?: string; summary?: string; @@ -468,6 +469,7 @@ export class CalendarService { event.workingLocationProperties = wlProps; } + const calendar = await this.getCalendar(); const insertParams: calendar_v3.Params$Resource$Events$Insert = { calendarId: finalCalendarId, @@ -733,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 { diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index 636f03c..3bb9ab1 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -194,6 +194,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}`,