From 2511ebe452ca1afb35d4d34ab5367277696478c4 Mon Sep 17 00:00:00 2001 From: Matheus Morgado Date: Fri, 12 Jun 2026 10:34:55 -0300 Subject: [PATCH 1/2] feat(docs,sheets,slides): add docs.insertTable, sheets.getRanges, and slides.replaceImage Three focused additions to the Docs/Sheets/Slides tool surfaces: - docs.insertTable (docs.write): inserts a table at the end of the body or at a specific index, optionally pre-filled with cell data. Cell fill re-reads the document to locate the inserted table and applies insertText requests in reverse document order so earlier indexes stay valid. Validates that data fits within the declared dimensions before any API call. - sheets.getRanges (sheets.read, readOnlyHint): batch read of multiple A1 ranges via spreadsheets.values.batchGet, with valueRenderOption and majorDimension controls. One API call instead of N sheets.getRange calls when reading disjoint ranges. - slides.replaceImage (slides.write): replaces an existing image by objectId with a new image URL via the replaceImage batchUpdate request, preserving position and size. CENTER_INSIDE (default) or CENTER_CROP fit. Pairs with slides.getImages / template workflows for refreshing charts and filling image placeholders. Includes feature-config registration, Jest coverage for all three services (request shapes, defaults, URL extraction, error paths, dimension validation), docs/index.md entries for the default-enabled tools, and skill recipes for google-docs and google-sheets. --- docs/index.md | 4 + skills/google-docs/SKILL.md | 19 +++ skills/google-sheets/SKILL.md | 16 ++ .../__tests__/services/DocsService.test.ts | 148 ++++++++++++++++++ .../__tests__/services/SheetsService.test.ts | 97 ++++++++++++ .../__tests__/services/SlidesService.test.ts | 92 +++++++++++ .../src/features/feature-config.ts | 9 +- workspace-server/src/index.ts | 95 +++++++++++ workspace-server/src/services/DocsService.ts | 139 ++++++++++++++++ .../src/services/SheetsService.ts | 62 ++++++++ .../src/services/SlidesService.ts | 45 ++++++ 11 files changed, 725 insertions(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 7d7b622..e0256b2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,8 @@ The extension provides the following tools: - `docs.getText`: Retrieves the text content of a Google Doc. - `docs.replaceText`: Replaces all occurrences of a given text with new text in a Google Doc. +- `docs.insertTable`: Inserts a table into a Google Doc, optionally pre-filled + with cell data. - `docs.formatText`: Applies formatting (bold, italic, headings, etc.) to text ranges in a Google Doc. @@ -37,6 +39,8 @@ The extension provides the following tools: ### Google Sheets - `sheets.getText`: Retrieves the content of a Google Sheets spreadsheet. +- `sheets.getRanges`: Gets values from multiple ranges of a Google Sheets + spreadsheet in a single call. - `sheets.getRange`: Gets values from a specific range in a Google Sheets spreadsheet. - `sheets.getMetadata`: Gets metadata about a Google Sheets spreadsheet. diff --git a/skills/google-docs/SKILL.md b/skills/google-docs/SKILL.md index eb0d159..ac64878 100644 --- a/skills/google-docs/SKILL.md +++ b/skills/google-docs/SKILL.md @@ -187,6 +187,25 @@ docs.writeText({ }) ``` +## Inserting Tables + +Use `docs.insertTable` to add a table, optionally pre-filled with data in a +single call. The table is appended to the end of the body by default; pass +`index` to insert at a specific position. + +``` +docs.insertTable({ + documentId: "document-id", + rows: 3, + columns: 2, + data: [["Metric", "Value"], ["Revenue", "$1.2M"], ["Churn", "2.1%"]] +}) +``` + +`data` must fit within the table dimensions; omitted or empty cells are left +blank. Filling cells during insertion is much more reliable than writing into +table cells afterwards with index arithmetic. + ## Find and Replace Use `docs.replaceText` to find all occurrences of a string and replace them. diff --git a/skills/google-sheets/SKILL.md b/skills/google-sheets/SKILL.md index 5076816..e813d2a 100644 --- a/skills/google-sheets/SKILL.md +++ b/skills/google-sheets/SKILL.md @@ -49,6 +49,22 @@ sheets.getRange({ }) ``` +### Multiple Ranges in One Call + +When you need several disjoint ranges (e.g., KPI cells spread across sheets), +use `sheets.getRanges` instead of calling `sheets.getRange` repeatedly — it +reads all ranges in a single API call and returns them in request order: + +``` +sheets.getRanges({ + spreadsheetId: "spreadsheet-id", + ranges: ["Sheet1!A1:D10", "Summary!B2", "Q4!F1:F12"] +}) +``` + +Optional: `valueRenderOption` (`FORMATTED_VALUE` default, `UNFORMATTED_VALUE`, +or `FORMULA`) and `majorDimension` (`ROWS` default or `COLUMNS`). + ### Metadata Use `sheets.getMetadata` to get spreadsheet structure without reading data — diff --git a/workspace-server/src/__tests__/services/DocsService.test.ts b/workspace-server/src/__tests__/services/DocsService.test.ts index d3aa66e..a6afbf6 100644 --- a/workspace-server/src/__tests__/services/DocsService.test.ts +++ b/workspace-server/src/__tests__/services/DocsService.test.ts @@ -434,6 +434,154 @@ describe('DocsService', () => { }); }); + describe('insertTable', () => { + it('should append an empty table at the end of the body', async () => { + mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} }); + + const result = await docsService.insertTable({ + documentId: 'test-doc-id', + rows: 2, + columns: 3, + }); + + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledTimes(1); + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + requestBody: { + requests: [ + { + insertTable: { + rows: 2, + columns: 3, + endOfSegmentLocation: {}, + }, + }, + ], + }, + }); + expect(mockDocsAPI.documents.get).not.toHaveBeenCalled(); + + const response = JSON.parse(result.content[0].text); + expect(response.rows).toBe(2); + expect(response.columns).toBe(3); + expect(response.cellsFilled).toBe(0); + }); + + it('should insert at a specific index when provided', async () => { + mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} }); + + await docsService.insertTable({ + documentId: 'test-doc-id', + rows: 1, + columns: 1, + index: 5, + }); + + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + requestBody: { + requests: [ + { + insertTable: { + rows: 1, + columns: 1, + location: { index: 5 }, + }, + }, + ], + }, + }); + }); + + it('should fill cell data in reverse document order', async () => { + mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} }); + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + body: { + content: [ + { startIndex: 0, endIndex: 1 }, + { + startIndex: 1, + endIndex: 50, + table: { + tableRows: [ + { + tableCells: [ + { content: [{ startIndex: 4 }] }, + { content: [{ startIndex: 6 }] }, + ], + }, + { + tableCells: [ + { content: [{ startIndex: 9 }] }, + { content: [{ startIndex: 11 }] }, + ], + }, + ], + }, + }, + ], + }, + }, + }); + + const result = await docsService.insertTable({ + documentId: 'test-doc-id', + rows: 2, + columns: 2, + data: [ + ['Name', 'Score'], + ['Alice', '95'], + ], + }); + + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledTimes(2); + const secondCall = mockDocsAPI.documents.batchUpdate.mock.calls[1][0]; + expect(secondCall.requestBody.requests).toEqual([ + { insertText: { location: { index: 12 }, text: '95' } }, + { insertText: { location: { index: 10 }, text: 'Alice' } }, + { insertText: { location: { index: 7 }, text: 'Score' } }, + { insertText: { location: { index: 5 }, text: 'Name' } }, + ]); + + const response = JSON.parse(result.content[0].text); + expect(response.cellsFilled).toBe(4); + }); + + it('should reject data that exceeds the table dimensions', async () => { + const result = await docsService.insertTable({ + documentId: 'test-doc-id', + rows: 1, + columns: 1, + data: [ + ['a', 'b'], + ['c', 'd'], + ], + }); + + expect(mockDocsAPI.documents.batchUpdate).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + const response = JSON.parse(result.content[0].text); + expect(response.error).toContain('data has 2 rows'); + }); + + it('should handle errors gracefully', async () => { + mockDocsAPI.documents.batchUpdate.mockRejectedValue( + new Error('Table Error'), + ); + + const result = await docsService.insertTable({ + documentId: 'error-id', + rows: 2, + columns: 2, + }); + + expect(result.isError).toBe(true); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Table Error'); + }); + }); + describe('getText', () => { it('should extract text from a document', async () => { const mockDoc = { diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index 50c1ff4..b33162e 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(), + batchGet: jest.fn(), }, }, }; @@ -298,6 +299,102 @@ describe('SheetsService', () => { }); }); + describe('getRanges', () => { + it('should get values from multiple ranges in one call with defaults', async () => { + const mockBatchData = { + data: { + valueRanges: [ + { + range: 'Sheet1!A1:B2', + majorDimension: 'ROWS', + values: [ + ['A1', 'B1'], + ['A2', 'B2'], + ], + }, + { + range: 'Summary!D2', + majorDimension: 'ROWS', + values: [['42']], + }, + ], + }, + }; + + mockSheetsAPI.spreadsheets.values.batchGet.mockResolvedValue( + mockBatchData, + ); + + const result = await sheetsService.getRanges({ + spreadsheetId: 'test-spreadsheet-id', + ranges: ['Sheet1!A1:B2', 'Summary!D2'], + }); + + expect(mockSheetsAPI.spreadsheets.values.batchGet).toHaveBeenCalledWith({ + spreadsheetId: 'test-spreadsheet-id', + ranges: ['Sheet1!A1:B2', 'Summary!D2'], + valueRenderOption: 'FORMATTED_VALUE', + majorDimension: 'ROWS', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.spreadsheetId).toBe('test-spreadsheet-id'); + expect(response.valueRanges).toHaveLength(2); + expect(response.valueRanges[0].range).toBe('Sheet1!A1:B2'); + expect(response.valueRanges[1].values).toEqual([['42']]); + }); + + it('should pass through render and dimension options', async () => { + mockSheetsAPI.spreadsheets.values.batchGet.mockResolvedValue({ + data: { valueRanges: [] }, + }); + + await sheetsService.getRanges({ + spreadsheetId: 'test-spreadsheet-id', + ranges: ['Sheet1!A1'], + valueRenderOption: 'FORMULA', + majorDimension: 'COLUMNS', + }); + + expect(mockSheetsAPI.spreadsheets.values.batchGet).toHaveBeenCalledWith({ + spreadsheetId: 'test-spreadsheet-id', + ranges: ['Sheet1!A1'], + valueRenderOption: 'FORMULA', + majorDimension: 'COLUMNS', + }); + }); + + it('should default missing values arrays to empty', async () => { + mockSheetsAPI.spreadsheets.values.batchGet.mockResolvedValue({ + data: { + valueRanges: [{ range: 'Sheet1!Z99', majorDimension: 'ROWS' }], + }, + }); + + const result = await sheetsService.getRanges({ + spreadsheetId: 'test-spreadsheet-id', + ranges: ['Sheet1!Z99'], + }); + + const response = JSON.parse(result.content[0].text); + expect(response.valueRanges[0].values).toEqual([]); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.values.batchGet.mockRejectedValue( + new Error('Batch Error'), + ); + + const result = await sheetsService.getRanges({ + spreadsheetId: 'error-id', + ranges: ['Sheet1!A1'], + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Batch Error'); + }); + }); + describe('getMetadata', () => { it('should retrieve spreadsheet metadata', async () => { const mockSpreadsheet = { diff --git a/workspace-server/src/__tests__/services/SlidesService.test.ts b/workspace-server/src/__tests__/services/SlidesService.test.ts index f0ecb2f..1825415 100644 --- a/workspace-server/src/__tests__/services/SlidesService.test.ts +++ b/workspace-server/src/__tests__/services/SlidesService.test.ts @@ -1484,6 +1484,98 @@ describe('SlidesService', () => { }); }); + describe('replaceImage', () => { + it('should replace an existing image with the default replace method', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.replaceImage({ + presentationId: 'test-pres-id', + imageObjectId: 'image-1', + imageUrl: 'https://example.com/new-chart.png', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + replaceImage: { + imageObjectId: 'image-1', + url: 'https://example.com/new-chart.png', + imageReplaceMethod: 'CENTER_INSIDE', + }, + }, + ], + }, + }); + expect(response.imageObjectId).toBe('image-1'); + expect(response.imageReplaceMethod).toBe('CENTER_INSIDE'); + }); + + it('should pass through an explicit CENTER_CROP replace method', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + await slidesService.replaceImage({ + presentationId: 'test-pres-id', + imageObjectId: 'image-2', + imageUrl: 'https://example.com/photo.jpg', + imageReplaceMethod: 'CENTER_CROP', + }); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + replaceImage: { + imageObjectId: 'image-2', + url: 'https://example.com/photo.jpg', + imageReplaceMethod: 'CENTER_CROP', + }, + }, + ], + }, + }); + }); + + it('should extract the presentation ID from a full URL', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + await slidesService.replaceImage({ + presentationId: + 'https://docs.google.com/presentation/d/url-pres-id/edit', + imageObjectId: 'image-3', + imageUrl: 'https://example.com/x.png', + }); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith( + expect.objectContaining({ presentationId: 'url-pres-id' }), + ); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Replace Error'), + ); + + const result = await slidesService.replaceImage({ + presentationId: 'error-id', + imageObjectId: 'image-1', + imageUrl: 'https://example.com/fail.png', + }); + const response = JSON.parse(result.content[0].text); + expect(result.isError).toBe(true); + expect(response.error).toBe('Replace Error'); + }); + }); + describe('addTable', () => { it('should add a table to a slide', async () => { mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index dc9eef5..8b24c7a 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -73,6 +73,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'docs.writeText', 'docs.replaceText', 'docs.formatText', + 'docs.insertTable', ], defaultEnabled: true, }, @@ -223,6 +224,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'slides.deleteText', 'slides.addShape', 'slides.addImage', + 'slides.replaceImage', 'slides.addTable', 'slides.updateTextStyle', 'slides.updateShapeProperties', @@ -235,7 +237,12 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ service: 'sheets', group: 'read', scopes: scopes('spreadsheets.readonly'), - tools: ['sheets.getText', 'sheets.getRange', 'sheets.getMetadata'], + tools: [ + 'sheets.getText', + 'sheets.getRange', + 'sheets.getRanges', + 'sheets.getMetadata', + ], defaultEnabled: true, }, { diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index e7a8077..6884927 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -444,6 +444,42 @@ async function main() { docsService.formatText, ); + registerTool( + 'docs.insertTable', + { + description: + 'Inserts a table into a Google Doc, optionally pre-filled with cell data. Inserts at the end of the body by default, or at a specific index.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + rows: z + .number() + .int() + .positive() + .describe('The number of rows in the table.'), + columns: z + .number() + .int() + .positive() + .describe('The number of columns in the table.'), + index: z + .number() + .int() + .positive() + .optional() + .describe( + 'The 1-based index to insert the table at. If not provided, the table is appended to the end of the body.', + ), + data: z + .array(z.array(z.string())) + .optional() + .describe( + 'Cell contents as a 2D array of strings, row by row (e.g., [["Name", "Score"], ["Alice", "95"]]). Must fit within the table dimensions. Omitted or empty cells are left blank.', + ), + }, + }, + docsService.insertTable, + ); + // Slides tools registerTool( 'slides.getText', @@ -805,6 +841,34 @@ async function main() { slidesService.addImage, ); + registerTool( + 'slides.replaceImage', + { + description: + 'Replaces an existing image in a Google Slides presentation with a new image from a URL, preserving the original position and size. Use slides.getImages or slides.getMetadata to find image object IDs. Useful for refreshing charts or filling image placeholders in template decks.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + imageObjectId: z + .string() + .describe('The object ID of the existing image to replace.'), + imageUrl: z + .string() + .describe( + 'The URL of the replacement image. Must be publicly accessible over HTTPS. Google requires PNG, JPEG, or GIF format, at most 50MB in size, and at most 25 megapixels.', + ), + imageReplaceMethod: z + .enum(['CENTER_INSIDE', 'CENTER_CROP']) + .optional() + .describe( + 'How to fit the new image into the original bounds: CENTER_INSIDE scales to fit (default), CENTER_CROP fills and crops.', + ), + }, + }, + slidesService.replaceImage, + ); + registerTool( 'slides.addTable', { @@ -928,6 +992,37 @@ async function main() { sheetsService.getRange, ); + registerTool( + 'sheets.getRanges', + { + description: + 'Gets values from multiple ranges of a Google Sheets spreadsheet in a single call. More efficient than calling sheets.getRange once per range when reading several disjoint ranges (e.g., KPI cells across different sheets).', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + ranges: z + .array(z.string()) + .min(1) + .describe( + 'The A1 notation ranges to get (e.g., ["Sheet1!A1:B10", "Summary!D2"]).', + ), + valueRenderOption: z + .enum(['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA']) + .optional() + .describe( + 'How values are rendered: FORMATTED_VALUE as displayed (default), UNFORMATTED_VALUE as raw values, FORMULA as the cell formulas.', + ), + majorDimension: z + .enum(['ROWS', 'COLUMNS']) + .optional() + .describe( + 'Whether the values arrays are row-major (ROWS, default) or column-major (COLUMNS).', + ), + }, + ...readOnlyToolProps, + }, + sheetsService.getRanges, + ); + registerTool( 'sheets.getMetadata', { diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 72350bb..1abc760 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -533,6 +533,145 @@ export class DocsService { } }; + public insertTable = async ({ + documentId, + rows, + columns, + index, + data, + }: { + documentId: string; + rows: number; + columns: number; + index?: number; + data?: string[][]; + }) => { + logToFile( + `[DocsService] Starting insertTable for document: ${documentId} (${rows}x${columns})`, + ); + try { + const id = extractDocId(documentId) || documentId; + const docs = await this.getDocsClient(); + + if (data) { + if (data.length > rows) { + throw new Error( + `data has ${data.length} rows but the table only has ${rows}.`, + ); + } + const widest = Math.max(0, ...data.map((row) => row.length)); + if (widest > columns) { + throw new Error( + `data has a row with ${widest} cells but the table only has ${columns} columns.`, + ); + } + } + + const insertTableRequest: docs_v1.Schema$InsertTableRequest = { + rows, + columns, + }; + if (index !== undefined) { + insertTableRequest.location = { index }; + } else { + insertTableRequest.endOfSegmentLocation = {}; + } + + await docs.documents.batchUpdate({ + documentId: id, + requestBody: { requests: [{ insertTable: insertTableRequest }] }, + }); + + let cellsFilled = 0; + + if (data && data.length > 0) { + // Re-read the document to locate the table that was just inserted and + // compute each cell's insertion index. + const res = await docs.documents.get({ + documentId: id, + fields: 'body(content(startIndex,endIndex,table))', + }); + const content = res.data.body?.content || []; + + let table: docs_v1.Schema$StructuralElement | undefined; + if (index !== undefined) { + table = content.find( + (el) => el.table && (el.startIndex ?? 0) >= index, + ); + } else { + for (const el of content) { + if (el.table) table = el; + } + } + if (!table?.table?.tableRows) { + throw new Error( + 'Table was inserted but could not be located to fill cell data.', + ); + } + + // Build one insertText request per non-empty cell. Inserting text + // shifts every later index, so requests are applied in reverse + // document order to keep the earlier indexes valid. + const cellRequests: docs_v1.Schema$Request[] = []; + const tableRows = table.table.tableRows; + for (let r = 0; r < tableRows.length && r < data.length; r++) { + const cells = tableRows[r].tableCells || []; + const rowData = data[r] || []; + for (let c = 0; c < cells.length && c < rowData.length; c++) { + const text = String(rowData[c] ?? ''); + if (text === '') continue; + const cellStart = cells[c].content?.[0]?.startIndex; + if (cellStart === undefined || cellStart === null) continue; + // The first insertable position inside a cell is one past the + // cell's own start index. + cellRequests.push({ + insertText: { location: { index: cellStart + 1 }, text }, + }); + } + } + + if (cellRequests.length > 0) { + cellRequests.reverse(); + await docs.documents.batchUpdate({ + documentId: id, + requestBody: { requests: cellRequests }, + }); + cellsFilled = cellRequests.length; + } + } + + logToFile( + `[DocsService] Finished insertTable for document: ${id} (${cellsFilled} cells filled)`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: id, + rows, + columns, + cellsFilled, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.insertTable: ${errorMessage}`); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + public getText = async ({ documentId, tabId, diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index 636f03c..3771e39 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -194,6 +194,68 @@ export class SheetsService { } }; + public getRanges = async ({ + spreadsheetId, + ranges, + valueRenderOption = 'FORMATTED_VALUE', + majorDimension = 'ROWS', + }: { + spreadsheetId: string; + ranges: string[]; + valueRenderOption?: 'FORMATTED_VALUE' | 'UNFORMATTED_VALUE' | 'FORMULA'; + majorDimension?: 'ROWS' | 'COLUMNS'; + }) => { + logToFile( + `[SheetsService] Starting getRanges for spreadsheet: ${spreadsheetId}, ranges: ${ranges.length}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.values.batchGet({ + spreadsheetId: id, + ranges, + valueRenderOption, + majorDimension, + }); + + const valueRanges = (response.data.valueRanges || []).map((vr) => ({ + range: vr.range, + majorDimension: vr.majorDimension, + values: vr.values || [], + })); + + logToFile( + `[SheetsService] Finished getRanges for spreadsheet: ${id} (${valueRanges.length} ranges)`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + spreadsheetId: id, + valueRanges, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.getRanges: ${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}`, diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index d9c7464..e701f34 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -1026,6 +1026,51 @@ export class SlidesService { } }; + public replaceImage = async ({ + presentationId, + imageObjectId, + imageUrl, + imageReplaceMethod = 'CENTER_INSIDE', + }: { + presentationId: string; + imageObjectId: string; + imageUrl: string; + imageReplaceMethod?: 'CENTER_INSIDE' | 'CENTER_CROP'; + }) => { + logToFile( + `[SlidesService] Replacing image ${imageObjectId} in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [ + { + replaceImage: { + imageObjectId, + url: imageUrl, + imageReplaceMethod, + }, + }, + ], + }, + }); + + logToFile(`[SlidesService] Replaced image: ${imageObjectId}`); + return this.formatResult({ + presentationId: id, + imageObjectId, + imageUrl, + imageReplaceMethod, + }); + } catch (error) { + return this.formatError('slides.replaceImage', error); + } + }; + public addTable = async ({ presentationId, slideObjectId, From 8eedd9f63ff5f163610b88ede538379de7e73c3a Mon Sep 17 00:00:00 2001 From: Matheus Morgado Date: Fri, 12 Jun 2026 15:56:08 -0300 Subject: [PATCH 2/2] fix(review): insert at the cell paragraph's startIndex, add isError to getRanges failures Addresses gemini-code-assist review: cellStart already points at the cell's first paragraph, so inserting at cellStart + 1 landed on the terminating newline and 400'd on empty cells; sheets.getRanges error responses now carry isError: true so MCP clients can distinguish failures from content. --- .../src/__tests__/services/DocsService.test.ts | 8 ++++---- workspace-server/src/services/DocsService.ts | 7 ++++--- workspace-server/src/services/SheetsService.ts | 1 + 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/workspace-server/src/__tests__/services/DocsService.test.ts b/workspace-server/src/__tests__/services/DocsService.test.ts index a6afbf6..127aeca 100644 --- a/workspace-server/src/__tests__/services/DocsService.test.ts +++ b/workspace-server/src/__tests__/services/DocsService.test.ts @@ -538,10 +538,10 @@ describe('DocsService', () => { expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledTimes(2); const secondCall = mockDocsAPI.documents.batchUpdate.mock.calls[1][0]; expect(secondCall.requestBody.requests).toEqual([ - { insertText: { location: { index: 12 }, text: '95' } }, - { insertText: { location: { index: 10 }, text: 'Alice' } }, - { insertText: { location: { index: 7 }, text: 'Score' } }, - { insertText: { location: { index: 5 }, text: 'Name' } }, + { insertText: { location: { index: 11 }, text: '95' } }, + { insertText: { location: { index: 9 }, text: 'Alice' } }, + { insertText: { location: { index: 6 }, text: 'Score' } }, + { insertText: { location: { index: 4 }, text: 'Name' } }, ]); const response = JSON.parse(result.content[0].text); diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 1abc760..c564ea1 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -622,10 +622,11 @@ export class DocsService { if (text === '') continue; const cellStart = cells[c].content?.[0]?.startIndex; if (cellStart === undefined || cellStart === null) continue; - // The first insertable position inside a cell is one past the - // cell's own start index. + // content[0] is the cell's first paragraph; its startIndex is the + // first insertable position (inserting one past it would land on + // the paragraph's terminating newline and 400 on empty cells). cellRequests.push({ - insertText: { location: { index: cellStart + 1 }, text }, + insertText: { location: { index: cellStart }, text }, }); } } diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index 3771e39..84af85b 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -246,6 +246,7 @@ export class SheetsService { `[SheetsService] Error during sheets.getRanges: ${errorMessage}`, ); return { + isError: true, content: [ { type: 'text' as const,