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..127aeca 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: 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); + 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..c564ea1 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -533,6 +533,146 @@ 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; + // 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 }, 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..84af85b 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -194,6 +194,69 @@ 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 { + isError: true, + 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,