diff --git a/scripts/setup-gcp.sh b/scripts/setup-gcp.sh index 639850e6..73eb165e 100755 --- a/scripts/setup-gcp.sh +++ b/scripts/setup-gcp.sh @@ -101,7 +101,7 @@ SCOPES=( "https://www.googleapis.com/auth/userinfo.profile" "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/presentations" "https://www.googleapis.com/auth/spreadsheets.readonly" ) diff --git a/workspace-server/src/__tests__/services/SlidesService.test.ts b/workspace-server/src/__tests__/services/SlidesService.test.ts index b1f180ee..57d7017d 100644 --- a/workspace-server/src/__tests__/services/SlidesService.test.ts +++ b/workspace-server/src/__tests__/services/SlidesService.test.ts @@ -59,6 +59,8 @@ describe('SlidesService', () => { mockSlidesAPI = { presentations: { get: jest.fn(), + create: jest.fn(), + batchUpdate: jest.fn(), }, }; @@ -465,4 +467,951 @@ describe('SlidesService', () => { expect(response.error).toBe('API Error'); }); }); + + describe('create', () => { + it('should create a new presentation', async () => { + mockSlidesAPI.presentations.create.mockResolvedValue({ + data: { + presentationId: 'new-pres-id', + title: 'My New Presentation', + }, + }); + + const result = await slidesService.create({ + title: 'My New Presentation', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.create).toHaveBeenCalledWith({ + requestBody: { title: 'My New Presentation' }, + }); + expect(response.presentationId).toBe('new-pres-id'); + expect(response.title).toBe('My New Presentation'); + expect(response.url).toContain('new-pres-id'); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.create.mockRejectedValue( + new Error('Create Error'), + ); + + const result = await slidesService.create({ title: 'Fail' }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Create Error'); + }); + }); + + describe('addSlide', () => { + it('should add a slide with default settings', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{ createSlide: { objectId: 'new-slide-id' } }] }, + }); + + const result = await slidesService.addSlide({ + presentationId: 'test-pres-id', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [{ createSlide: {} }], + }, + }); + expect(response.slideObjectId).toBe('new-slide-id'); + }); + + it('should add a slide with insertion index and predefined layout', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{ createSlide: { objectId: 'slide-at-0' } }] }, + }); + + const result = await slidesService.addSlide({ + presentationId: 'test-pres-id', + insertionIndex: 0, + predefinedLayout: 'TITLE_AND_BODY', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + createSlide: { + insertionIndex: 0, + slideLayoutReference: { predefinedLayout: 'TITLE_AND_BODY' }, + }, + }, + ], + }, + }); + expect(response.slideObjectId).toBe('slide-at-0'); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Add Slide Error'), + ); + + const result = await slidesService.addSlide({ + presentationId: 'error-id', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Add Slide Error'); + }); + }); + + describe('deleteSlide', () => { + it('should delete a slide', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.deleteSlide({ + presentationId: 'test-pres-id', + slideObjectId: 'slide-to-delete', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [{ deleteObject: { objectId: 'slide-to-delete' } }], + }, + }); + expect(response.deletedSlideObjectId).toBe('slide-to-delete'); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Delete Error'), + ); + + const result = await slidesService.deleteSlide({ + presentationId: 'error-id', + slideObjectId: 'slide1', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Delete Error'); + }); + }); + + describe('duplicateSlide', () => { + it('should duplicate a slide', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { + replies: [{ duplicateObject: { objectId: 'duplicated-slide-id' } }], + }, + }); + + const result = await slidesService.duplicateSlide({ + presentationId: 'test-pres-id', + slideObjectId: 'original-slide', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { duplicateObject: { objectId: 'original-slide' } }, + ], + }, + }); + expect(response.sourceSlideObjectId).toBe('original-slide'); + expect(response.newSlideObjectId).toBe('duplicated-slide-id'); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Duplicate Error'), + ); + + const result = await slidesService.duplicateSlide({ + presentationId: 'error-id', + slideObjectId: 'slide1', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Duplicate Error'); + }); + }); + + describe('reorderSlides', () => { + it('should reorder slides', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.reorderSlides({ + presentationId: 'test-pres-id', + slideObjectIds: ['slide2', 'slide3'], + insertionIndex: 0, + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + updateSlidesPosition: { + slideObjectIds: ['slide2', 'slide3'], + insertionIndex: 0, + }, + }, + ], + }, + }); + expect(response.slideObjectIds).toEqual(['slide2', 'slide3']); + expect(response.insertionIndex).toBe(0); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Reorder Error'), + ); + + const result = await slidesService.reorderSlides({ + presentationId: 'error-id', + slideObjectIds: ['s1'], + insertionIndex: 0, + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Reorder Error'); + }); + }); + + describe('getSpeakerNotes', () => { + it('should retrieve speaker notes for all slides', async () => { + mockSlidesAPI.presentations.get.mockResolvedValue({ + data: { + slides: [ + { + objectId: 'slide1', + slideProperties: { + notesPage: { + notesProperties: { + speakerNotesObjectId: 'notes-shape-1', + }, + pageElements: [ + { + objectId: 'notes-shape-1', + shape: { + text: { + textElements: [ + { textRun: { content: 'Speaker note for slide 1' } }, + ], + }, + }, + }, + ], + }, + }, + }, + { + objectId: 'slide2', + slideProperties: { + notesPage: { + notesProperties: { + speakerNotesObjectId: 'notes-shape-2', + }, + pageElements: [ + { + objectId: 'notes-shape-2', + shape: { + text: { + textElements: [], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }); + + const result = await slidesService.getSpeakerNotes({ + presentationId: 'test-pres-id', + }); + const response = JSON.parse(result.content[0].text); + + expect(response.slides).toHaveLength(2); + expect(response.slides[0].notes).toBe('Speaker note for slide 1'); + expect(response.slides[0].speakerNotesObjectId).toBe('notes-shape-1'); + expect(response.slides[1].notes).toBe(''); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.get.mockRejectedValue( + new Error('Notes Error'), + ); + + const result = await slidesService.getSpeakerNotes({ + presentationId: 'error-id', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Notes Error'); + }); + }); + + describe('updateSpeakerNotes', () => { + it('should update speaker notes for a slide', async () => { + mockSlidesAPI.presentations.get.mockResolvedValue({ + data: { + slides: [ + { + objectId: 'slide1', + slideProperties: { + notesPage: { + notesProperties: { + speakerNotesObjectId: 'notes-shape-1', + }, + pageElements: [ + { + objectId: 'notes-shape-1', + shape: { + text: { + textElements: [ + { textRun: { content: 'Old notes' } }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }); + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}, {}] }, + }); + + const result = await slidesService.updateSpeakerNotes({ + presentationId: 'test-pres-id', + slideObjectId: 'slide1', + notes: 'New speaker notes', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + deleteText: { + objectId: 'notes-shape-1', + textRange: { type: 'ALL' }, + }, + }, + { + insertText: { + objectId: 'notes-shape-1', + insertionIndex: 0, + text: 'New speaker notes', + }, + }, + ], + }, + }); + expect(response.notes).toBe('New speaker notes'); + }); + + it('should handle slide not found', async () => { + mockSlidesAPI.presentations.get.mockResolvedValue({ + data: { slides: [{ objectId: 'other-slide' }] }, + }); + + const result = await slidesService.updateSpeakerNotes({ + presentationId: 'test-pres-id', + slideObjectId: 'nonexistent-slide', + notes: 'Notes', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toContain('Slide not found'); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.get.mockRejectedValue( + new Error('Update Notes Error'), + ); + + const result = await slidesService.updateSpeakerNotes({ + presentationId: 'error-id', + slideObjectId: 'slide1', + notes: 'Notes', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Update Notes Error'); + }); + }); + + describe('replaceAllText', () => { + it('should replace all text in a presentation', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { + replies: [{ replaceAllText: { occurrencesChanged: 5 } }], + }, + }); + + const result = await slidesService.replaceAllText({ + presentationId: 'test-pres-id', + findText: '{{name}}', + replaceText: 'John Doe', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + replaceAllText: { + containsText: { text: '{{name}}', matchCase: true }, + replaceText: 'John Doe', + }, + }, + ], + }, + }); + expect(response.occurrencesChanged).toBe(5); + expect(response.findText).toBe('{{name}}'); + expect(response.replaceText).toBe('John Doe'); + }); + + it('should support case-insensitive matching', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { + replies: [{ replaceAllText: { occurrencesChanged: 3 } }], + }, + }); + + await slidesService.replaceAllText({ + presentationId: 'test-pres-id', + findText: 'hello', + replaceText: 'world', + matchCase: false, + }); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + replaceAllText: { + containsText: { text: 'hello', matchCase: false }, + replaceText: 'world', + }, + }, + ], + }, + }); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Replace Error'), + ); + + const result = await slidesService.replaceAllText({ + presentationId: 'error-id', + findText: 'a', + replaceText: 'b', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Replace Error'); + }); + }); + + describe('insertText', () => { + it('should insert text into an object', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.insertText({ + presentationId: 'test-pres-id', + objectId: 'shape-1', + text: 'Hello World', + insertionIndex: 5, + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + insertText: { + objectId: 'shape-1', + insertionIndex: 5, + text: 'Hello World', + }, + }, + ], + }, + }); + expect(response.objectId).toBe('shape-1'); + expect(response.textLength).toBe(11); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Insert Error'), + ); + + const result = await slidesService.insertText({ + presentationId: 'error-id', + objectId: 'shape-1', + text: 'Test', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Insert Error'); + }); + }); + + describe('deleteText', () => { + it('should delete text from an object with fixed range', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.deleteText({ + presentationId: 'test-pres-id', + objectId: 'shape-1', + startIndex: 0, + endIndex: 5, + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + deleteText: { + objectId: 'shape-1', + textRange: { type: 'FIXED_RANGE', startIndex: 0, endIndex: 5 }, + }, + }, + ], + }, + }); + expect(response.objectId).toBe('shape-1'); + }); + + it('should delete all text when type is ALL', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + await slidesService.deleteText({ + presentationId: 'test-pres-id', + objectId: 'shape-1', + type: 'ALL', + }); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + deleteText: { + objectId: 'shape-1', + textRange: { type: 'ALL' }, + }, + }, + ], + }, + }); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Delete Text Error'), + ); + + const result = await slidesService.deleteText({ + presentationId: 'error-id', + objectId: 'shape-1', + startIndex: 0, + endIndex: 5, + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Delete Text Error'); + }); + }); + + describe('addShape', () => { + it('should add a shape to a slide', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { + replies: [{ createShape: { objectId: 'new-shape-id' } }], + }, + }); + + const result = await slidesService.addShape({ + presentationId: 'test-pres-id', + slideObjectId: 'slide1', + shapeType: 'TEXT_BOX', + x: 100, + y: 100, + width: 300, + height: 50, + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + createShape: { + shapeType: 'TEXT_BOX', + elementProperties: { + pageObjectId: 'slide1', + size: { + width: { magnitude: 300, unit: 'PT' }, + height: { magnitude: 50, unit: 'PT' }, + }, + transform: { + scaleX: 1, + scaleY: 1, + translateX: 100, + translateY: 100, + unit: 'PT', + }, + }, + }, + }, + ], + }, + }); + expect(response.shapeObjectId).toBe('new-shape-id'); + expect(response.shapeType).toBe('TEXT_BOX'); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Shape Error'), + ); + + const result = await slidesService.addShape({ + presentationId: 'error-id', + slideObjectId: 'slide1', + shapeType: 'RECTANGLE', + x: 0, + y: 0, + width: 100, + height: 100, + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Shape Error'); + }); + }); + + describe('addImage', () => { + it('should add an image to a slide', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { + replies: [{ createImage: { objectId: 'new-image-id' } }], + }, + }); + + const result = await slidesService.addImage({ + presentationId: 'test-pres-id', + slideObjectId: 'slide1', + imageUrl: 'https://example.com/photo.png', + x: 50, + y: 50, + width: 200, + height: 150, + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + createImage: { + url: 'https://example.com/photo.png', + elementProperties: { + pageObjectId: 'slide1', + size: { + width: { magnitude: 200, unit: 'PT' }, + height: { magnitude: 150, unit: 'PT' }, + }, + transform: { + scaleX: 1, + scaleY: 1, + translateX: 50, + translateY: 50, + unit: 'PT', + }, + }, + }, + }, + ], + }, + }); + expect(response.imageObjectId).toBe('new-image-id'); + expect(response.imageUrl).toBe('https://example.com/photo.png'); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Image Error'), + ); + + const result = await slidesService.addImage({ + presentationId: 'error-id', + slideObjectId: 'slide1', + imageUrl: 'https://example.com/fail.png', + x: 0, + y: 0, + width: 100, + height: 100, + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Image Error'); + }); + }); + + describe('addTable', () => { + it('should add a table to a slide', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { + replies: [{ createTable: { objectId: 'new-table-id' } }], + }, + }); + + const result = await slidesService.addTable({ + presentationId: 'test-pres-id', + slideObjectId: 'slide1', + rows: 3, + columns: 4, + x: 50, + y: 200, + width: 400, + height: 200, + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + createTable: { + rows: 3, + columns: 4, + elementProperties: { + pageObjectId: 'slide1', + size: { + width: { magnitude: 400, unit: 'PT' }, + height: { magnitude: 200, unit: 'PT' }, + }, + transform: { + scaleX: 1, + scaleY: 1, + translateX: 50, + translateY: 200, + unit: 'PT', + }, + }, + }, + }, + ], + }, + }); + expect(response.tableObjectId).toBe('new-table-id'); + expect(response.rows).toBe(3); + expect(response.columns).toBe(4); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Table Error'), + ); + + const result = await slidesService.addTable({ + presentationId: 'error-id', + slideObjectId: 'slide1', + rows: 2, + columns: 2, + x: 0, + y: 0, + width: 100, + height: 100, + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Table Error'); + }); + }); + + describe('updateTextStyle', () => { + it('should update text style for all text', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.updateTextStyle({ + presentationId: 'test-pres-id', + objectId: 'shape-1', + style: '{"bold": true, "fontSize": {"magnitude": 18, "unit": "PT"}}', + fields: 'bold,fontSize', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + updateTextStyle: { + objectId: 'shape-1', + textRange: { type: 'ALL' }, + style: { + bold: true, + fontSize: { magnitude: 18, unit: 'PT' }, + }, + fields: 'bold,fontSize', + }, + }, + ], + }, + }); + expect(response.objectId).toBe('shape-1'); + expect(response.fields).toBe('bold,fontSize'); + }); + + it('should update text style for a fixed range', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + await slidesService.updateTextStyle({ + presentationId: 'test-pres-id', + objectId: 'shape-1', + style: '{"italic": true}', + fields: 'italic', + startIndex: 0, + endIndex: 10, + type: 'FIXED_RANGE', + }); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + updateTextStyle: { + objectId: 'shape-1', + textRange: { + type: 'FIXED_RANGE', + startIndex: 0, + endIndex: 10, + }, + style: { italic: true }, + fields: 'italic', + }, + }, + ], + }, + }); + }); + + it('should handle invalid JSON gracefully', async () => { + const result = await slidesService.updateTextStyle({ + presentationId: 'test-pres-id', + objectId: 'shape-1', + style: 'not valid json{', + fields: 'bold', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toContain('Invalid JSON for style'); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Style Error'), + ); + + const result = await slidesService.updateTextStyle({ + presentationId: 'error-id', + objectId: 'shape-1', + style: '{"bold": true}', + fields: 'bold', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Style Error'); + }); + }); + + describe('updateShapeProperties', () => { + it('should update shape properties', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const shapePropertiesJson = + '{"shapeBackgroundFill":{"solidFill":{"color":{"rgbColor":{"red":0,"green":0,"blue":1}}}}}'; + + const result = await slidesService.updateShapeProperties({ + presentationId: 'test-pres-id', + objectId: 'shape-1', + shapeProperties: shapePropertiesJson, + fields: 'shapeBackgroundFill', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'test-pres-id', + requestBody: { + requests: [ + { + updateShapeProperties: { + objectId: 'shape-1', + shapeProperties: JSON.parse(shapePropertiesJson), + fields: 'shapeBackgroundFill', + }, + }, + ], + }, + }); + expect(response.objectId).toBe('shape-1'); + expect(response.fields).toBe('shapeBackgroundFill'); + }); + + it('should handle invalid JSON gracefully', async () => { + const result = await slidesService.updateShapeProperties({ + presentationId: 'test-pres-id', + objectId: 'shape-1', + shapeProperties: 'not valid json{', + fields: 'outline', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toContain('Invalid JSON for shapeProperties'); + }); + + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Shape Props Error'), + ); + + const result = await slidesService.updateShapeProperties({ + presentationId: 'error-id', + objectId: 'shape-1', + shapeProperties: '{}', + fields: 'outline', + }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Shape Props Error'); + }); + }); }); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index e2666a41..7a443932 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -56,7 +56,7 @@ const SCOPES = [ 'https://www.googleapis.com/auth/userinfo.profile', '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/presentations', 'https://www.googleapis.com/auth/spreadsheets.readonly', ]; @@ -431,6 +431,430 @@ async function main() { slidesService.getSlideThumbnail, ); + server.registerTool( + 'slides.create', + { + description: + 'Creates a new Google Slides presentation. Returns the presentation ID and URL.', + inputSchema: { + title: z + .string() + .describe('The title for the new presentation.'), + }, + }, + slidesService.create, + ); + + server.registerTool( + 'slides.addSlide', + { + description: + 'Adds a new slide to a Google Slides presentation. Optionally specify position and layout.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + insertionIndex: z + .number() + .optional() + .describe( + 'The 0-based index where the slide should be inserted. If not specified, the slide is added at the end.', + ), + layoutId: z + .string() + .optional() + .describe( + 'The ID of a specific layout to use. Use slides.getMetadata to find available layouts.', + ), + predefinedLayout: z + .enum([ + 'BLANK', + 'TITLE', + 'TITLE_AND_BODY', + 'TITLE_AND_TWO_COLUMNS', + 'TITLE_ONLY', + 'SECTION_HEADER', + 'SECTION_TITLE_AND_DESCRIPTION', + 'ONE_COLUMN_TEXT', + 'MAIN_POINT', + 'BIG_NUMBER', + ]) + .optional() + .describe('A predefined layout type for the new slide.'), + objectId: z + .string() + .optional() + .describe( + 'A user-supplied object ID for the new slide. If not specified, a unique ID is generated.', + ), + }, + }, + slidesService.addSlide, + ); + + server.registerTool( + 'slides.deleteSlide', + { + description: + 'Deletes a slide from a Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + slideObjectId: z + .string() + .describe( + 'The object ID of the slide to delete (can be found via slides.getMetadata).', + ), + }, + }, + slidesService.deleteSlide, + ); + + server.registerTool( + 'slides.duplicateSlide', + { + description: + 'Duplicates (clones) a slide in a Google Slides presentation. The duplicate is placed immediately after the original.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + slideObjectId: z + .string() + .describe( + 'The object ID of the slide to duplicate (can be found via slides.getMetadata).', + ), + }, + }, + slidesService.duplicateSlide, + ); + + server.registerTool( + 'slides.reorderSlides', + { + description: + 'Moves one or more slides to a new position in a Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + slideObjectIds: z + .array(z.string()) + .describe('The object IDs of the slides to move.'), + insertionIndex: z + .number() + .describe( + 'The 0-based index where the slides should be moved to.', + ), + }, + }, + slidesService.reorderSlides, + ); + + server.registerTool( + 'slides.getSpeakerNotes', + { + description: + 'Retrieves the speaker notes for all slides in a Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + }, + ...readOnlyToolProps, + }, + slidesService.getSpeakerNotes, + ); + + server.registerTool( + 'slides.updateSpeakerNotes', + { + description: + 'Updates (replaces) the speaker notes for a specific slide in a Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + slideObjectId: z + .string() + .describe( + 'The object ID of the slide whose speaker notes to update.', + ), + notes: z + .string() + .describe( + 'The new speaker notes text. Pass an empty string to clear the notes.', + ), + }, + }, + slidesService.updateSpeakerNotes, + ); + + server.registerTool( + 'slides.replaceAllText', + { + description: + 'Replaces all occurrences of a given text with new text across the entire Google Slides presentation. Useful for template variable replacement.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + findText: z + .string() + .describe('The text to find in the presentation.'), + replaceText: z + .string() + .describe('The text to replace the found text with.'), + matchCase: z + .boolean() + .optional() + .describe( + 'Whether the search should be case-sensitive (default: true).', + ), + }, + }, + slidesService.replaceAllText, + ); + + server.registerTool( + 'slides.insertText', + { + description: + 'Inserts text into a shape or table cell in a Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + objectId: z + .string() + .describe( + 'The object ID of the shape or table cell to insert text into.', + ), + text: z.string().describe('The text to insert.'), + insertionIndex: z + .number() + .optional() + .describe( + 'The 0-based index where the text should be inserted (default: 0, the beginning).', + ), + }, + }, + slidesService.insertText, + ); + + server.registerTool( + 'slides.deleteText', + { + description: + 'Deletes text from a shape or table cell in a Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + objectId: z + .string() + .describe( + 'The object ID of the shape or table cell to delete text from.', + ), + startIndex: z + .number() + .optional() + .describe( + 'The 0-based start index of the text range to delete.', + ), + endIndex: z + .number() + .optional() + .describe('The 0-based end index of the text range to delete.'), + type: z + .enum(['ALL', 'FIXED_RANGE', 'FROM_START_INDEX']) + .optional() + .default('FIXED_RANGE') + .describe( + 'The type of range: "ALL" to delete all text, "FIXED_RANGE" for a specific range (requires startIndex and endIndex), "FROM_START_INDEX" to delete from startIndex to end.', + ), + }, + }, + slidesService.deleteText, + ); + + server.registerTool( + 'slides.addShape', + { + description: + 'Adds a shape (e.g., text box, rectangle, ellipse) to a slide in a Google Slides presentation. Coordinates and dimensions are in points (PT).', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + slideObjectId: z + .string() + .describe('The object ID of the slide to add the shape to.'), + shapeType: z + .string() + .describe( + 'The type of shape (e.g., "TEXT_BOX", "RECTANGLE", "ELLIPSE", "ROUND_RECTANGLE", "TRIANGLE", "ARROW_NORTH", "ARROW_EAST", "STAR_5", "CLOUD", "HEART").', + ), + x: z + .number() + .describe('The X coordinate of the shape position in points.'), + y: z + .number() + .describe('The Y coordinate of the shape position in points.'), + width: z.number().describe('The width of the shape in points.'), + height: z.number().describe('The height of the shape in points.'), + objectId: z + .string() + .optional() + .describe( + 'A user-supplied object ID for the new shape. If not specified, a unique ID is generated.', + ), + }, + }, + slidesService.addShape, + ); + + server.registerTool( + 'slides.addImage', + { + description: + 'Adds an image from a URL to a slide in a Google Slides presentation. Coordinates and dimensions are in points (PT).', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + slideObjectId: z + .string() + .describe('The object ID of the slide to add the image to.'), + imageUrl: z + .string() + .describe('The URL of the image to insert. Must be publicly accessible.'), + x: z + .number() + .describe('The X coordinate of the image position in points.'), + y: z + .number() + .describe('The Y coordinate of the image position in points.'), + width: z.number().describe('The width of the image in points.'), + height: z.number().describe('The height of the image in points.'), + objectId: z + .string() + .optional() + .describe( + 'A user-supplied object ID for the new image. If not specified, a unique ID is generated.', + ), + }, + }, + slidesService.addImage, + ); + + server.registerTool( + 'slides.addTable', + { + description: + 'Adds a table to a slide in a Google Slides presentation. Coordinates and dimensions are in points (PT).', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + slideObjectId: z + .string() + .describe('The object ID of the slide to add the table to.'), + rows: z.number().describe('The number of rows in the table.'), + columns: z.number().describe('The number of columns in the table.'), + x: z + .number() + .describe('The X coordinate of the table position in points.'), + y: z + .number() + .describe('The Y coordinate of the table position in points.'), + width: z.number().describe('The width of the table in points.'), + height: z.number().describe('The height of the table in points.'), + objectId: z + .string() + .optional() + .describe( + 'A user-supplied object ID for the new table. If not specified, a unique ID is generated.', + ), + }, + }, + slidesService.addTable, + ); + + server.registerTool( + 'slides.updateTextStyle', + { + description: + 'Updates the text style (bold, italic, font size, color, etc.) of text in a shape or table cell in a Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + objectId: z + .string() + .describe( + 'The object ID of the shape or table cell containing the text.', + ), + style: z + .string() + .describe( + 'A JSON string representing the text style to apply (e.g., \'{"bold": true, "fontSize": {"magnitude": 18, "unit": "PT"}}\').', + ), + fields: z + .string() + .describe( + 'A field mask specifying which style fields to update (e.g., "bold,fontSize").', + ), + startIndex: z + .number() + .optional() + .describe( + 'The 0-based start index of the text range (required for FIXED_RANGE and FROM_START_INDEX types).', + ), + endIndex: z + .number() + .optional() + .describe( + 'The 0-based end index of the text range (required for FIXED_RANGE type).', + ), + type: z + .enum(['ALL', 'FIXED_RANGE', 'FROM_START_INDEX']) + .optional() + .default('ALL') + .describe('The range type for the text to style.'), + }, + }, + slidesService.updateTextStyle, + ); + + server.registerTool( + 'slides.updateShapeProperties', + { + description: + 'Updates the properties of a shape (background fill, outline, shadow, etc.) in a Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + objectId: z + .string() + .describe('The object ID of the shape to update.'), + shapeProperties: z + .string() + .describe( + 'A JSON string representing the shape properties to update (e.g., \'{"shapeBackgroundFill": {"solidFill": {"color": {"rgbColor": {"red": 1, "green": 0, "blue": 0}}}}}\').', + ), + fields: z + .string() + .describe( + 'A field mask specifying which properties to update (e.g., "shapeBackgroundFill", "outline").', + ), + }, + }, + slidesService.updateShapeProperties, + ); + // Sheets tools server.registerTool( 'sheets.getText', diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index d9627d7f..b35697e6 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -344,6 +344,849 @@ export class SlidesService { } }; + private formatError(method: string, error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[SlidesService] Error during ${method}: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + + private formatResult(data: unknown) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(data), + }, + ], + }; + } + + public create = async ({ title }: { title: string }) => { + logToFile(`[SlidesService] Creating presentation: ${title}`); + try { + const slides = await this.getSlidesClient(); + const presentation = await slides.presentations.create({ + requestBody: { title }, + }); + + const result = { + presentationId: presentation.data.presentationId, + title: presentation.data.title, + url: `https://docs.google.com/presentation/d/${presentation.data.presentationId}/edit`, + }; + + logToFile( + `[SlidesService] Created presentation: ${result.presentationId}`, + ); + return this.formatResult(result); + } catch (error) { + return this.formatError('slides.create', error); + } + }; + + public addSlide = async ({ + presentationId, + insertionIndex, + layoutId, + predefinedLayout, + objectId, + }: { + presentationId: string; + insertionIndex?: number; + layoutId?: string; + predefinedLayout?: string; + objectId?: string; + }) => { + logToFile( + `[SlidesService] Adding slide to presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const createSlideRequest: slides_v1.Schema$CreateSlideRequest = {}; + if (insertionIndex !== undefined) { + createSlideRequest.insertionIndex = insertionIndex; + } + if (objectId) { + createSlideRequest.objectId = objectId; + } + if (layoutId) { + createSlideRequest.slideLayoutReference = { layoutId }; + } else if (predefinedLayout) { + createSlideRequest.slideLayoutReference = { predefinedLayout }; + } + + const response = await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [{ createSlide: createSlideRequest }], + }, + }); + + const slideObjectId = + response.data.replies?.[0]?.createSlide?.objectId ?? null; + + logToFile(`[SlidesService] Added slide: ${slideObjectId}`); + return this.formatResult({ + presentationId: id, + slideObjectId, + }); + } catch (error) { + return this.formatError('slides.addSlide', error); + } + }; + + public deleteSlide = async ({ + presentationId, + slideObjectId, + }: { + presentationId: string; + slideObjectId: string; + }) => { + logToFile( + `[SlidesService] Deleting slide ${slideObjectId} from presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [{ deleteObject: { objectId: slideObjectId } }], + }, + }); + + logToFile(`[SlidesService] Deleted slide: ${slideObjectId}`); + return this.formatResult({ + presentationId: id, + deletedSlideObjectId: slideObjectId, + }); + } catch (error) { + return this.formatError('slides.deleteSlide', error); + } + }; + + public duplicateSlide = async ({ + presentationId, + slideObjectId, + }: { + presentationId: string; + slideObjectId: string; + }) => { + logToFile( + `[SlidesService] Duplicating slide ${slideObjectId} in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const response = await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [{ duplicateObject: { objectId: slideObjectId } }], + }, + }); + + const newObjectId = + response.data.replies?.[0]?.duplicateObject?.objectId ?? null; + + logToFile(`[SlidesService] Duplicated slide to: ${newObjectId}`); + return this.formatResult({ + presentationId: id, + sourceSlideObjectId: slideObjectId, + newSlideObjectId: newObjectId, + }); + } catch (error) { + return this.formatError('slides.duplicateSlide', error); + } + }; + + public reorderSlides = async ({ + presentationId, + slideObjectIds, + insertionIndex, + }: { + presentationId: string; + slideObjectIds: string[]; + insertionIndex: number; + }) => { + logToFile( + `[SlidesService] Reordering slides in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [ + { + updateSlidesPosition: { + slideObjectIds, + insertionIndex, + }, + }, + ], + }, + }); + + logToFile(`[SlidesService] Reordered slides in presentation: ${id}`); + return this.formatResult({ + presentationId: id, + slideObjectIds, + insertionIndex, + }); + } catch (error) { + return this.formatError('slides.reorderSlides', error); + } + }; + + public getSpeakerNotes = async ({ + presentationId, + }: { + presentationId: string; + }) => { + logToFile( + `[SlidesService] Getting speaker notes for presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const presentation = await slides.presentations.get({ + presentationId: id, + fields: + 'slides(objectId,slideProperties(notesPage(notesProperties(speakerNotesObjectId),pageElements(objectId,shape(text)))))', + }); + + const notesPerSlide = (presentation.data.slides ?? []).map( + (slide, index) => { + const notesPage = slide.slideProperties?.notesPage; + const speakerNotesObjectId = + notesPage?.notesProperties?.speakerNotesObjectId; + + let notesText = ''; + if (speakerNotesObjectId && notesPage?.pageElements) { + const notesShape = notesPage.pageElements.find( + (el) => el.objectId === speakerNotesObjectId, + ); + if (notesShape?.shape?.text) { + notesText = this.extractTextFromTextContent( + notesShape.shape.text, + ).trim(); + } + } + + return { + slideIndex: index + 1, + slideObjectId: slide.objectId, + speakerNotesObjectId, + notes: notesText, + }; + }, + ); + + logToFile( + `[SlidesService] Retrieved speaker notes for presentation: ${id}`, + ); + return this.formatResult({ presentationId: id, slides: notesPerSlide }); + } catch (error) { + return this.formatError('slides.getSpeakerNotes', error); + } + }; + + public updateSpeakerNotes = async ({ + presentationId, + slideObjectId, + notes, + }: { + presentationId: string; + slideObjectId: string; + notes: string; + }) => { + logToFile( + `[SlidesService] Updating speaker notes for slide ${slideObjectId} in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const presentation = await slides.presentations.get({ + presentationId: id, + fields: + 'slides(objectId,slideProperties(notesPage(notesProperties(speakerNotesObjectId),pageElements(objectId,shape(text)))))', + }); + + const slide = presentation.data.slides?.find( + (s) => s.objectId === slideObjectId, + ); + if (!slide) { + throw new Error(`Slide not found: ${slideObjectId}`); + } + + const speakerNotesObjectId = + slide.slideProperties?.notesPage?.notesProperties + ?.speakerNotesObjectId; + if (!speakerNotesObjectId) { + throw new Error( + `Speaker notes object not found for slide: ${slideObjectId}`, + ); + } + + const requests: slides_v1.Schema$Request[] = []; + + const notesShape = + slide.slideProperties?.notesPage?.pageElements?.find( + (el) => el.objectId === speakerNotesObjectId, + ); + + if (notesShape?.shape?.text?.textElements?.length) { + requests.push({ + deleteText: { + objectId: speakerNotesObjectId, + textRange: { type: 'ALL' }, + }, + }); + } + + if (notes.length > 0) { + requests.push({ + insertText: { + objectId: speakerNotesObjectId, + insertionIndex: 0, + text: notes, + }, + }); + } + + if (requests.length > 0) { + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests }, + }); + } + + logToFile( + `[SlidesService] Updated speaker notes for slide: ${slideObjectId}`, + ); + return this.formatResult({ + presentationId: id, + slideObjectId, + speakerNotesObjectId, + notes, + }); + } catch (error) { + return this.formatError('slides.updateSpeakerNotes', error); + } + }; + + public replaceAllText = async ({ + presentationId, + findText, + replaceText, + matchCase = true, + }: { + presentationId: string; + findText: string; + replaceText: string; + matchCase?: boolean; + }) => { + logToFile( + `[SlidesService] Replacing all text in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const response = await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [ + { + replaceAllText: { + containsText: { text: findText, matchCase }, + replaceText, + }, + }, + ], + }, + }); + + const occurrencesChanged = + response.data.replies?.[0]?.replaceAllText?.occurrencesChanged ?? 0; + + logToFile( + `[SlidesService] Replaced ${occurrencesChanged} occurrences in presentation: ${id}`, + ); + return this.formatResult({ + presentationId: id, + findText, + replaceText, + occurrencesChanged, + }); + } catch (error) { + return this.formatError('slides.replaceAllText', error); + } + }; + + public insertText = async ({ + presentationId, + objectId, + text, + insertionIndex = 0, + }: { + presentationId: string; + objectId: string; + text: string; + insertionIndex?: number; + }) => { + logToFile( + `[SlidesService] Inserting text into object ${objectId} in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [ + { + insertText: { + objectId, + insertionIndex, + text, + }, + }, + ], + }, + }); + + logToFile( + `[SlidesService] Inserted text into object ${objectId} in presentation: ${id}`, + ); + return this.formatResult({ + presentationId: id, + objectId, + insertionIndex, + textLength: text.length, + }); + } catch (error) { + return this.formatError('slides.insertText', error); + } + }; + + public deleteText = async ({ + presentationId, + objectId, + startIndex, + endIndex, + type = 'FIXED_RANGE', + }: { + presentationId: string; + objectId: string; + startIndex?: number; + endIndex?: number; + type?: string; + }) => { + logToFile( + `[SlidesService] Deleting text from object ${objectId} in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const textRange: slides_v1.Schema$Range = { type }; + if (type === 'FIXED_RANGE') { + textRange.startIndex = startIndex; + textRange.endIndex = endIndex; + } else if (type === 'FROM_START_INDEX') { + textRange.startIndex = startIndex; + } + + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [ + { + deleteText: { + objectId, + textRange, + }, + }, + ], + }, + }); + + logToFile( + `[SlidesService] Deleted text from object ${objectId} in presentation: ${id}`, + ); + return this.formatResult({ + presentationId: id, + objectId, + textRange, + }); + } catch (error) { + return this.formatError('slides.deleteText', error); + } + }; + + public addShape = async ({ + presentationId, + slideObjectId, + shapeType, + x, + y, + width, + height, + objectId, + }: { + presentationId: string; + slideObjectId: string; + shapeType: string; + x: number; + y: number; + width: number; + height: number; + objectId?: string; + }) => { + logToFile( + `[SlidesService] Adding shape to slide ${slideObjectId} in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const createShapeRequest: slides_v1.Schema$CreateShapeRequest = { + shapeType, + elementProperties: { + pageObjectId: slideObjectId, + size: { + width: { magnitude: width, unit: 'PT' }, + height: { magnitude: height, unit: 'PT' }, + }, + transform: { + scaleX: 1, + scaleY: 1, + translateX: x, + translateY: y, + unit: 'PT', + }, + }, + }; + if (objectId) { + createShapeRequest.objectId = objectId; + } + + const response = await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [{ createShape: createShapeRequest }], + }, + }); + + const newObjectId = + response.data.replies?.[0]?.createShape?.objectId ?? null; + + logToFile(`[SlidesService] Added shape: ${newObjectId}`); + return this.formatResult({ + presentationId: id, + slideObjectId, + shapeObjectId: newObjectId, + shapeType, + }); + } catch (error) { + return this.formatError('slides.addShape', error); + } + }; + + public addImage = async ({ + presentationId, + slideObjectId, + imageUrl, + x, + y, + width, + height, + objectId, + }: { + presentationId: string; + slideObjectId: string; + imageUrl: string; + x: number; + y: number; + width: number; + height: number; + objectId?: string; + }) => { + logToFile( + `[SlidesService] Adding image to slide ${slideObjectId} in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const createImageRequest: slides_v1.Schema$CreateImageRequest = { + url: imageUrl, + elementProperties: { + pageObjectId: slideObjectId, + size: { + width: { magnitude: width, unit: 'PT' }, + height: { magnitude: height, unit: 'PT' }, + }, + transform: { + scaleX: 1, + scaleY: 1, + translateX: x, + translateY: y, + unit: 'PT', + }, + }, + }; + if (objectId) { + createImageRequest.objectId = objectId; + } + + const response = await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [{ createImage: createImageRequest }], + }, + }); + + const newObjectId = + response.data.replies?.[0]?.createImage?.objectId ?? null; + + logToFile(`[SlidesService] Added image: ${newObjectId}`); + return this.formatResult({ + presentationId: id, + slideObjectId, + imageObjectId: newObjectId, + imageUrl, + }); + } catch (error) { + return this.formatError('slides.addImage', error); + } + }; + + public addTable = async ({ + presentationId, + slideObjectId, + rows, + columns, + x, + y, + width, + height, + objectId, + }: { + presentationId: string; + slideObjectId: string; + rows: number; + columns: number; + x: number; + y: number; + width: number; + height: number; + objectId?: string; + }) => { + logToFile( + `[SlidesService] Adding table to slide ${slideObjectId} in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const createTableRequest: slides_v1.Schema$CreateTableRequest = { + rows, + columns, + elementProperties: { + pageObjectId: slideObjectId, + size: { + width: { magnitude: width, unit: 'PT' }, + height: { magnitude: height, unit: 'PT' }, + }, + transform: { + scaleX: 1, + scaleY: 1, + translateX: x, + translateY: y, + unit: 'PT', + }, + }, + }; + if (objectId) { + createTableRequest.objectId = objectId; + } + + const response = await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [{ createTable: createTableRequest }], + }, + }); + + const newObjectId = + response.data.replies?.[0]?.createTable?.objectId ?? null; + + logToFile(`[SlidesService] Added table: ${newObjectId}`); + return this.formatResult({ + presentationId: id, + slideObjectId, + tableObjectId: newObjectId, + rows, + columns, + }); + } catch (error) { + return this.formatError('slides.addTable', error); + } + }; + + public updateTextStyle = async ({ + presentationId, + objectId, + style, + startIndex, + endIndex, + type = 'ALL', + fields, + }: { + presentationId: string; + objectId: string; + style: string | slides_v1.Schema$TextStyle; + startIndex?: number; + endIndex?: number; + type?: string; + fields: string; + }) => { + logToFile( + `[SlidesService] Updating text style for object ${objectId} in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + let parsedStyle: slides_v1.Schema$TextStyle; + if (typeof style === 'string') { + try { + parsedStyle = JSON.parse(style); + } catch { + throw new Error( + 'Invalid JSON for style parameter. Expected a JSON string like \'{"bold": true}\'.', + ); + } + } else { + parsedStyle = style; + } + + const textRange: slides_v1.Schema$Range = { type }; + if (type === 'FIXED_RANGE') { + textRange.startIndex = startIndex; + textRange.endIndex = endIndex; + } else if (type === 'FROM_START_INDEX') { + textRange.startIndex = startIndex; + } + + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [ + { + updateTextStyle: { + objectId, + textRange, + style: parsedStyle, + fields, + }, + }, + ], + }, + }); + + logToFile( + `[SlidesService] Updated text style for object ${objectId} in presentation: ${id}`, + ); + return this.formatResult({ + presentationId: id, + objectId, + textRange, + fields, + }); + } catch (error) { + return this.formatError('slides.updateTextStyle', error); + } + }; + + public updateShapeProperties = async ({ + presentationId, + objectId, + shapeProperties, + fields, + }: { + presentationId: string; + objectId: string; + shapeProperties: string | slides_v1.Schema$ShapeProperties; + fields: string; + }) => { + logToFile( + `[SlidesService] Updating shape properties for object ${objectId} in presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + let parsedProps: slides_v1.Schema$ShapeProperties; + if (typeof shapeProperties === 'string') { + try { + parsedProps = JSON.parse(shapeProperties); + } catch { + throw new Error( + 'Invalid JSON for shapeProperties parameter. Expected a JSON string like \'{"shapeBackgroundFill": {...}}\'.', + ); + } + } else { + parsedProps = shapeProperties; + } + + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [ + { + updateShapeProperties: { + objectId, + shapeProperties: parsedProps, + fields, + }, + }, + ], + }, + }); + + logToFile( + `[SlidesService] Updated shape properties for object ${objectId} in presentation: ${id}`, + ); + return this.formatResult({ + presentationId: id, + objectId, + fields, + }); + } catch (error) { + return this.formatError('slides.updateShapeProperties', error); + } + }; + public getSlideThumbnail = async ({ presentationId, slideObjectId,