From 67269c331938741f118da4ccd031e7ee55658fe3 Mon Sep 17 00:00:00 2001 From: Nate Hogsten Date: Mon, 23 Feb 2026 18:35:31 -1000 Subject: [PATCH 1/2] fix(slides): normalize shape types and rollback failed seeded creates --- .../__tests__/services/SlidesService.test.ts | 333 ++++++++- workspace-server/src/index.ts | 171 ++++- .../src/services/SlidesService.ts | 683 +++++++++++++++++- 3 files changed, 1176 insertions(+), 11 deletions(-) diff --git a/workspace-server/src/__tests__/services/SlidesService.test.ts b/workspace-server/src/__tests__/services/SlidesService.test.ts index b1f180ee..73d1daea 100644 --- a/workspace-server/src/__tests__/services/SlidesService.test.ts +++ b/workspace-server/src/__tests__/services/SlidesService.test.ts @@ -59,12 +59,20 @@ describe('SlidesService', () => { mockSlidesAPI = { presentations: { get: jest.fn(), + create: jest.fn(), + batchUpdate: jest.fn(), + pages: { + getThumbnail: jest.fn(), + }, }, }; mockDriveAPI = { files: { list: jest.fn(), + get: jest.fn(), + update: jest.fn(), + delete: jest.fn(), }, }; @@ -92,6 +100,325 @@ describe('SlidesService', () => { jest.restoreAllMocks(); }); + describe('create', () => { + it('should create a presentation with seeded slides', async () => { + mockSlidesAPI.presentations.create.mockResolvedValue({ + data: { + presentationId: 'new-pres-id', + title: 'Quarterly Review', + slides: [{ objectId: 'default-slide' }], + }, + }); + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { + replies: [], + }, + }); + + const result = await slidesService.create({ + title: 'Quarterly Review', + slides: [ + { title: 'Overview', body: ['Summary 1', 'Summary 2'] }, + { title: 'Metrics', layout: 'BLANK' }, + ], + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.create).toHaveBeenCalledWith({ + requestBody: { title: 'Quarterly Review' }, + }); + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'new-pres-id', + requestBody: expect.objectContaining({ + requests: expect.any(Array), + }), + }); + const createRequests = + mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody + .requests; + expect( + createRequests + .filter((req: any) => req.createShape) + .every((req: any) => req.createShape.shapeType === 'TEXT_BOX'), + ).toBe(true); + expect(response.presentationId).toBe('new-pres-id'); + expect(response.slideObjectIds).toHaveLength(2); + expect(response.url).toBe( + 'https://docs.google.com/presentation/d/new-pres-id/edit', + ); + }); + + it('should move created presentation to folder when folderName is provided', async () => { + mockSlidesAPI.presentations.create.mockResolvedValue({ + data: { + presentationId: 'new-pres-id', + title: 'Foldered Deck', + slides: [{ objectId: 'default-slide' }], + }, + }); + mockDriveAPI.files.list.mockResolvedValue({ + data: { files: [{ id: 'folder-123', name: 'My Folder' }] }, + }); + mockDriveAPI.files.get.mockResolvedValue({ + data: { parents: ['old-parent'] }, + }); + mockDriveAPI.files.update.mockResolvedValue({ + data: { id: 'new-pres-id' }, + }); + + const result = await slidesService.create({ + title: 'Foldered Deck', + folderName: 'My Folder', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockDriveAPI.files.list).toHaveBeenCalledWith({ + q: "mimeType='application/vnd.google-apps.folder' and name='My Folder' and trashed=false", + pageSize: 2, + fields: 'files(id,name)', + supportsAllDrives: true, + includeItemsFromAllDrives: true, + }); + expect(mockDriveAPI.files.update).toHaveBeenCalledWith({ + fileId: 'new-pres-id', + addParents: 'folder-123', + removeParents: 'old-parent', + fields: 'id,parents', + supportsAllDrives: true, + }); + expect(response.folderId).toBe('folder-123'); + }); + + it('should delete the created deck when seeded create fails', async () => { + mockSlidesAPI.presentations.create.mockResolvedValue({ + data: { + presentationId: 'failed-pres-id', + title: 'Seeded Deck', + slides: [{ objectId: 'default-slide' }], + }, + }); + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Invalid request body'), + ); + mockDriveAPI.files.delete.mockResolvedValue({ data: {} }); + + const result = await slidesService.create({ + title: 'Seeded Deck', + slides: [{ title: 'Slide 1', body: ['A'] }], + }); + const response = JSON.parse(result.content[0].text); + + expect(mockDriveAPI.files.delete).toHaveBeenCalledWith({ + fileId: 'failed-pres-id', + supportsAllDrives: true, + }); + expect(response.code).toBe('SLIDES_CREATE_ROLLED_BACK'); + expect(response.retryable).toBe(false); + expect(response.rolledBack).toBe(true); + expect(response.error).toBe('Invalid request body'); + }); + }); + + describe('addSlide', () => { + it('should add a slide and return slide object id', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [] }, + }); + + const result = await slidesService.addSlide({ + presentationId: 'pres-1', + title: 'New Slide', + body: ['Line 1', 'Line 2'], + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'pres-1', + requestBody: { + requests: expect.any(Array), + }, + }); + const addSlideRequests = + mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody + .requests; + expect( + addSlideRequests + .filter((req: any) => req.createShape) + .every((req: any) => req.createShape.shapeType === 'TEXT_BOX'), + ).toBe(true); + expect(response.presentationId).toBe('pres-1'); + expect(response.slideObjectId).toEqual(expect.any(String)); + }); + + it('should return structured error payload on addSlide failure', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Bad request'), + ); + + const result = await slidesService.addSlide({ + presentationId: 'pres-1', + title: 'New Slide', + }); + const response = JSON.parse(result.content[0].text); + + expect(response.error).toBe('Bad request'); + expect(response.code).toBe('SLIDES_INVALID_REQUEST'); + expect(response.retryable).toBe(false); + }); + }); + + describe('insertText', () => { + it('should create a shape and insert text', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [] }, + }); + + const result = await slidesService.insertText({ + presentationId: 'pres-1', + slideObjectId: 'slide-1', + text: 'Hello slide', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'pres-1', + requestBody: { + requests: expect.any(Array), + }, + }); + expect(response.slideObjectId).toBe('slide-1'); + expect(response.shapeObjectId).toEqual(expect.any(String)); + }); + + it('should map TITLE alias to TEXT_BOX for shape creation', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [] }, + }); + + await slidesService.insertText({ + presentationId: 'pres-1', + slideObjectId: 'slide-1', + text: 'Title text', + shapeType: 'TITLE', + }); + + const requests = + mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody + .requests; + const createShapeReq = requests.find((req: any) => req.createShape); + expect(createShapeReq.createShape.shapeType).toBe('TEXT_BOX'); + }); + + it('should map SUBTITLE alias to TEXT_BOX for shape creation', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [] }, + }); + + await slidesService.insertText({ + presentationId: 'pres-1', + slideObjectId: 'slide-1', + text: 'Subtitle text', + shapeType: 'SUBTITLE', + }); + + const requests = + mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody + .requests; + const createShapeReq = requests.find((req: any) => req.createShape); + expect(createShapeReq.createShape.shapeType).toBe('TEXT_BOX'); + }); + }); + + describe('replaceText', () => { + it('should replace text in presentation', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [] }, + }); + + const result = await slidesService.replaceText({ + presentationId: 'pres-1', + findText: 'Old', + replaceText: 'New', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'pres-1', + requestBody: { + requests: [ + { + replaceAllText: { + containsText: { + text: 'Old', + matchCase: false, + }, + replaceText: 'New', + }, + }, + ], + }, + }); + expect(response.presentationId).toBe('pres-1'); + }); + }); + + describe('deleteSlide', () => { + it('should delete slide by object id', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [] }, + }); + + const result = await slidesService.deleteSlide({ + presentationId: 'pres-1', + slideObjectId: 'slide-1', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'pres-1', + requestBody: { + requests: [ + { + deleteObject: { + objectId: 'slide-1', + }, + }, + ], + }, + }); + expect(response.deletedSlideObjectId).toBe('slide-1'); + }); + }); + + describe('batchUpdate', () => { + it('should pass through batch update requests', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{ createSlide: {} }] }, + }); + + const requests = [ + { + createSlide: { + objectId: 'slide-123', + }, + }, + ]; + + const result = await slidesService.batchUpdate({ + presentationId: 'pres-1', + requests, + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'pres-1', + requestBody: { requests }, + }); + expect(response.presentationId).toBe('pres-1'); + expect(response.replies).toHaveLength(1); + }); + }); + describe('getText', () => { it('should extract text from a presentation', async () => { const mockPresentation = { @@ -408,12 +735,6 @@ describe('SlidesService', () => { }); describe('getSlideThumbnail', () => { - beforeEach(() => { - mockSlidesAPI.presentations.pages = { - getThumbnail: jest.fn(), - }; - }); - it('should download thumbnail when localPath is provided', async () => { const mockThumbnail = { data: { diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index e2666a41..3a555b80 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', ]; @@ -336,6 +336,175 @@ async function main() { ); // Slides tools + server.registerTool( + 'slides.create', + { + description: + 'Creates a new Google Slides presentation with optional seeded slide content. Prefer a single call with all desired slides to avoid duplicate deck creation.', + inputSchema: { + title: z.string().describe('The title of the presentation to create.'), + folderName: z + .string() + .optional() + .describe( + 'Optional folder name to move the created presentation into. Folder must already exist.', + ), + slides: z + .array( + z.object({ + title: z + .string() + .optional() + .describe('Optional title text for this slide.'), + body: z + .array(z.string()) + .optional() + .describe('Optional body paragraphs for this slide.'), + layout: z + .enum(['TITLE', 'TITLE_AND_BODY', 'BLANK']) + .optional() + .describe('Optional predefined slide layout.'), + }), + ) + .optional() + .describe('Optional list of slides to seed the presentation with.'), + }, + }, + slidesService.create, + ); + + server.registerTool( + 'slides.addSlide', + { + description: + 'Adds a slide to an existing Google Slides presentation. Use this after an existing presentationId is known instead of creating a second deck.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + layout: z + .enum(['TITLE', 'TITLE_AND_BODY', 'BLANK']) + .optional() + .describe('The layout for the new slide. Defaults to TITLE_AND_BODY.'), + insertionIndex: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Optional zero-based position to insert the slide. Defaults to appending.', + ), + title: z + .string() + .optional() + .describe('Optional title text to insert on the new slide.'), + body: z + .array(z.string()) + .optional() + .describe('Optional body paragraphs to insert on the new slide.'), + }, + }, + slidesService.addSlide, + ); + + server.registerTool( + 'slides.insertText', + { + description: + 'Creates a new shape on a slide and inserts text into that shape.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + slideObjectId: z + .string() + .describe('The object ID of the target slide.'), + text: z.string().describe('The text to insert into the created shape.'), + shapeType: z + .enum(['TEXT_BOX', 'TITLE', 'SUBTITLE']) + .optional() + .describe( + 'Type of shape to create before inserting text. Defaults to TEXT_BOX. TITLE and SUBTITLE are compatibility aliases rendered as positioned text boxes.', + ), + xPt: z + .number() + .optional() + .describe('Optional x position in points for the new shape.'), + yPt: z + .number() + .optional() + .describe('Optional y position in points for the new shape.'), + widthPt: z + .number() + .positive() + .optional() + .describe('Optional width in points for the new shape.'), + heightPt: z + .number() + .positive() + .optional() + .describe('Optional height in points for the new shape.'), + }, + }, + slidesService.insertText, + ); + + server.registerTool( + 'slides.replaceText', + { + description: + 'Replaces text across an entire Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + findText: z.string().describe('The text to search for.'), + replaceText: z.string().describe('The replacement text.'), + matchCase: z + .boolean() + .optional() + .describe('Whether to match case when searching. Defaults to false.'), + }, + }, + slidesService.replaceText, + ); + + 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.'), + }, + }, + slidesService.deleteSlide, + ); + + server.registerTool( + 'slides.batchUpdate', + { + description: + 'Applies a raw Google Slides batchUpdate request payload to a presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + requests: z + .array(z.record(z.string(), z.any())) + .min(1) + .describe( + 'Array of Google Slides API request objects for presentations.batchUpdate.', + ), + }, + }, + slidesService.batchUpdate, + ); + server.registerTool( 'slides.getText', { diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index d9627d7f..b938e92c 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -14,6 +14,38 @@ import { extractDocId } from '../utils/IdUtils'; import { gaxiosOptions } from '../utils/GaxiosConfig'; import { buildDriveSearchQuery, MIME_TYPES } from '../utils/DriveQueryBuilder'; +const PT_UNIT = 'PT'; +const DEFAULT_LAYOUT = 'TITLE_AND_BODY'; + +type SlideLayout = 'TITLE' | 'TITLE_AND_BODY' | 'BLANK'; +type ShapeType = 'TEXT_BOX' | 'TITLE' | 'SUBTITLE'; +type CreateShapeType = 'TEXT_BOX'; + +interface SlideSeedInput { + title?: string; + body?: string[]; + layout?: SlideLayout; +} + +interface ShapeGeometry { + xPt: number; + yPt: number; + widthPt: number; + heightPt: number; +} + +interface BuildAddSlideResult { + slideObjectId: string; + requests: slides_v1.Schema$Request[]; +} + +interface WriteErrorPayload { + error: string; + code: string; + retryable: boolean; + rolledBack?: boolean; +} + export class SlidesService { constructor(private authManager: AuthManager) {} @@ -29,12 +61,655 @@ export class SlidesService { return google.drive({ version: 'v3', ...options }); } + private toPresentationId(presentationId: string): string { + return extractDocId(presentationId) || presentationId; + } + + private createObjectId(prefix: string): string { + const entropy = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + return `${prefix}_${entropy}`.replace(/[^a-zA-Z0-9_]/g, '_'); + } + + private getShapeGeometry(shapeType: ShapeType): ShapeGeometry { + if (shapeType === 'TITLE') { + return { + xPt: 40, + yPt: 36, + widthPt: 640, + heightPt: 64, + }; + } + + if (shapeType === 'SUBTITLE') { + return { + xPt: 40, + yPt: 112, + widthPt: 640, + heightPt: 72, + }; + } + + return { + xPt: 40, + yPt: 120, + widthPt: 640, + heightPt: 300, + }; + } + + private toLayout(layout?: SlideLayout): SlideLayout { + return layout || DEFAULT_LAYOUT; + } + + private buildCreateShapeRequest( + slideObjectId: string, + shapeObjectId: string, + shapeType: ShapeType, + geometry: ShapeGeometry, + ): slides_v1.Schema$Request { + const normalizedShapeType: CreateShapeType = this.toCreateShapeType(shapeType); + return { + createShape: { + objectId: shapeObjectId, + shapeType: normalizedShapeType, + elementProperties: { + pageObjectId: slideObjectId, + size: { + width: { magnitude: geometry.widthPt, unit: PT_UNIT }, + height: { magnitude: geometry.heightPt, unit: PT_UNIT }, + }, + transform: { + scaleX: 1, + scaleY: 1, + translateX: geometry.xPt, + translateY: geometry.yPt, + unit: PT_UNIT, + }, + }, + }, + }; + } + + private toCreateShapeType(_shapeType: ShapeType): CreateShapeType { + // TITLE and SUBTITLE are not valid createShape enums in Google Slides API. + // Keep API compatibility by mapping all of our text-like aliases to TEXT_BOX. + return 'TEXT_BOX'; + } + + private buildWriteError( + error: unknown, + code: string, + retryable: boolean, + extras?: { rolledBack?: boolean }, + ): WriteErrorPayload { + return { + error: error instanceof Error ? error.message : String(error), + code, + retryable, + ...(extras ?? {}), + }; + } + + private buildAddSlideRequests( + slide: SlideSeedInput, + insertionIndex?: number, + ): BuildAddSlideResult { + const slideObjectId = this.createObjectId('slide'); + const requests: slides_v1.Schema$Request[] = [ + { + createSlide: { + objectId: slideObjectId, + insertionIndex, + slideLayoutReference: { + predefinedLayout: this.toLayout(slide.layout), + }, + }, + }, + ]; + + if (slide.title) { + const titleShapeId = this.createObjectId('title'); + requests.push( + this.buildCreateShapeRequest( + slideObjectId, + titleShapeId, + 'TITLE', + this.getShapeGeometry('TITLE'), + ), + { + insertText: { + objectId: titleShapeId, + insertionIndex: 0, + text: slide.title, + }, + }, + ); + } + + if (slide.body?.length) { + const bodyShapeId = this.createObjectId('body'); + requests.push( + this.buildCreateShapeRequest( + slideObjectId, + bodyShapeId, + 'TEXT_BOX', + this.getShapeGeometry('TEXT_BOX'), + ), + { + insertText: { + objectId: bodyShapeId, + insertionIndex: 0, + text: slide.body.join('\n'), + }, + }, + ); + } + + return { slideObjectId, requests }; + } + + private async movePresentationToFolder( + presentationId: string, + folderName: string, + ): Promise { + const drive = await this.getDriveClient(); + const escapedFolderName = folderName.replace(/'/g, "\\'"); + const folderQuery = + `mimeType='application/vnd.google-apps.folder' and ` + + `name='${escapedFolderName}' and trashed=false`; + + const folderSearchResponse = await drive.files.list({ + q: folderQuery, + pageSize: 2, + fields: 'files(id,name)', + supportsAllDrives: true, + includeItemsFromAllDrives: true, + }); + + const folders = folderSearchResponse.data.files ?? []; + if (folders.length === 0 || !folders[0].id) { + throw new Error(`Folder not found: ${folderName}`); + } + + if (folders.length > 1) { + logToFile( + `[SlidesService] Multiple folders found for "${folderName}". Using first match (${folders[0].id}).`, + ); + } + + const folderId = folders[0].id; + const file = await drive.files.get({ + fileId: presentationId, + fields: 'parents', + supportsAllDrives: true, + }); + + await drive.files.update({ + fileId: presentationId, + addParents: folderId, + removeParents: file.data.parents?.join(','), + fields: 'id,parents', + supportsAllDrives: true, + }); + + return folderId; + } + + public create = async ({ + title, + folderName, + slides, + }: { + title: string; + folderName?: string; + slides?: SlideSeedInput[]; + }) => { + logToFile( + `[SlidesService] Starting create with title: ${title}, folderName: ${folderName}, slideCount: ${slides?.length ?? 0}`, + ); + let presentationId: string | undefined; + try { + const slidesClient = await this.getSlidesClient(); + const createdPresentation = await slidesClient.presentations.create({ + requestBody: { title }, + }); + + presentationId = createdPresentation.data.presentationId; + if (!presentationId) { + throw new Error('Slides API did not return a presentationId.'); + } + + const defaultSlideObjectId = createdPresentation.data.slides?.[0]?.objectId; + const createdSlideObjectIds: string[] = []; + + if (slides?.length) { + const requests: slides_v1.Schema$Request[] = []; + + if (defaultSlideObjectId) { + requests.push({ + deleteObject: { + objectId: defaultSlideObjectId, + }, + }); + } + + slides.forEach((slideSeed) => { + const built = this.buildAddSlideRequests(slideSeed); + createdSlideObjectIds.push(built.slideObjectId); + requests.push(...built.requests); + }); + + await slidesClient.presentations.batchUpdate({ + presentationId, + requestBody: { requests }, + }); + } else if (defaultSlideObjectId) { + createdSlideObjectIds.push(defaultSlideObjectId); + } + + let folderId: string | undefined; + if (folderName) { + folderId = await this.movePresentationToFolder(presentationId, folderName); + } + + logToFile( + `[SlidesService] Finished create for presentation: ${presentationId}`, + ); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + presentationId, + title: createdPresentation.data.title || title, + url: `https://docs.google.com/presentation/d/${presentationId}/edit`, + slideObjectIds: createdSlideObjectIds, + folderId, + }), + }, + ], + }; + } catch (error) { + let rolledBack = false; + + // If seeded creation fails after shell creation, delete the shell to avoid orphan decks. + if (presentationId && slides?.length) { + try { + const drive = await this.getDriveClient(); + await drive.files.delete({ + fileId: presentationId, + supportsAllDrives: true, + }); + rolledBack = true; + logToFile( + `[SlidesService] Rolled back failed seeded create by deleting presentation: ${presentationId}`, + ); + } catch (rollbackError) { + const rollbackMessage = + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError); + logToFile( + `[SlidesService] Failed to roll back presentation ${presentationId}: ${rollbackMessage}`, + ); + } + } + + const payload = + rolledBack && slides?.length + ? this.buildWriteError(error, 'SLIDES_CREATE_ROLLED_BACK', false, { + rolledBack: true, + }) + : this.buildWriteError(error, 'SLIDES_INVALID_REQUEST', false); + + logToFile( + `[SlidesService] Error during slides.create [${payload.code}]: ${payload.error}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(payload), + }, + ], + }; + } + }; + + public addSlide = async ({ + presentationId, + layout, + insertionIndex, + title, + body, + }: { + presentationId: string; + layout?: SlideLayout; + insertionIndex?: number; + title?: string; + body?: string[]; + }) => { + logToFile( + `[SlidesService] Starting addSlide for presentation: ${presentationId}`, + ); + try { + const id = this.toPresentationId(presentationId); + const slidesClient = await this.getSlidesClient(); + + const built = this.buildAddSlideRequests( + { + layout, + title, + body, + }, + insertionIndex, + ); + + const response = await slidesClient.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: built.requests, + }, + }); + + logToFile(`[SlidesService] Finished addSlide for presentation: ${id}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + presentationId: id, + slideObjectId: built.slideObjectId, + replies: response.data.replies, + writeControl: response.data.writeControl, + }), + }, + ], + }; + } catch (error) { + const payload = this.buildWriteError( + error, + 'SLIDES_INVALID_REQUEST', + false, + ); + logToFile( + `[SlidesService] Error during slides.addSlide [${payload.code}]: ${payload.error}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(payload), + }, + ], + }; + } + }; + + public insertText = async ({ + presentationId, + slideObjectId, + text, + shapeType = 'TEXT_BOX', + xPt, + yPt, + widthPt, + heightPt, + }: { + presentationId: string; + slideObjectId: string; + text: string; + shapeType?: ShapeType; + xPt?: number; + yPt?: number; + widthPt?: number; + heightPt?: number; + }) => { + logToFile( + `[SlidesService] Starting insertText for presentation: ${presentationId}, slide: ${slideObjectId}`, + ); + try { + const id = this.toPresentationId(presentationId); + const slidesClient = await this.getSlidesClient(); + + const defaultGeometry = this.getShapeGeometry(shapeType); + const geometry: ShapeGeometry = { + xPt: xPt ?? defaultGeometry.xPt, + yPt: yPt ?? defaultGeometry.yPt, + widthPt: widthPt ?? defaultGeometry.widthPt, + heightPt: heightPt ?? defaultGeometry.heightPt, + }; + + const shapeObjectId = this.createObjectId('shape'); + const requests: slides_v1.Schema$Request[] = [ + this.buildCreateShapeRequest( + slideObjectId, + shapeObjectId, + shapeType, + geometry, + ), + { + insertText: { + objectId: shapeObjectId, + insertionIndex: 0, + text, + }, + }, + ]; + + await slidesClient.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests }, + }); + + logToFile(`[SlidesService] Finished insertText for presentation: ${id}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + presentationId: id, + slideObjectId, + shapeObjectId, + }), + }, + ], + }; + } catch (error) { + const payload = this.buildWriteError( + error, + 'SLIDES_INVALID_REQUEST', + false, + ); + logToFile( + `[SlidesService] Error during slides.insertText [${payload.code}]: ${payload.error}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(payload), + }, + ], + }; + } + }; + + public replaceText = async ({ + presentationId, + findText, + replaceText, + matchCase = false, + }: { + presentationId: string; + findText: string; + replaceText: string; + matchCase?: boolean; + }) => { + logToFile( + `[SlidesService] Starting replaceText for presentation: ${presentationId}`, + ); + try { + const id = this.toPresentationId(presentationId); + const slidesClient = await this.getSlidesClient(); + const result = await slidesClient.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [ + { + replaceAllText: { + containsText: { + text: findText, + matchCase, + }, + replaceText, + }, + }, + ], + }, + }); + + logToFile(`[SlidesService] Finished replaceText for presentation: ${id}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + presentationId: id, + replies: result.data.replies, + writeControl: result.data.writeControl, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[SlidesService] Error during slides.replaceText: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public deleteSlide = async ({ + presentationId, + slideObjectId, + }: { + presentationId: string; + slideObjectId: string; + }) => { + logToFile( + `[SlidesService] Starting deleteSlide for presentation: ${presentationId}, slide: ${slideObjectId}`, + ); + try { + const id = this.toPresentationId(presentationId); + const slidesClient = await this.getSlidesClient(); + + await slidesClient.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [ + { + deleteObject: { + objectId: slideObjectId, + }, + }, + ], + }, + }); + + logToFile(`[SlidesService] Finished deleteSlide for presentation: ${id}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + presentationId: id, + deletedSlideObjectId: slideObjectId, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[SlidesService] Error during slides.deleteSlide: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public batchUpdate = async ({ + presentationId, + requests, + }: { + presentationId: string; + requests: slides_v1.Schema$Request[]; + }) => { + logToFile( + `[SlidesService] Starting batchUpdate for presentation: ${presentationId} with ${requests.length} requests`, + ); + try { + const id = this.toPresentationId(presentationId); + const slidesClient = await this.getSlidesClient(); + + const response = await slidesClient.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests }, + }); + + logToFile(`[SlidesService] Finished batchUpdate for presentation: ${id}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + presentationId: id, + replies: response.data.replies, + writeControl: response.data.writeControl, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[SlidesService] Error during slides.batchUpdate: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + public getText = async ({ presentationId }: { presentationId: string }) => { logToFile( `[SlidesService] Starting getText for presentation: ${presentationId}`, ); try { - const id = extractDocId(presentationId) || presentationId; + const id = this.toPresentationId(presentationId); const slides = await this.getSlidesClient(); // Get the presentation with all necessary fields @@ -198,7 +873,7 @@ export class SlidesService { `[SlidesService] Starting getMetadata for presentation: ${presentationId}`, ); try { - const id = extractDocId(presentationId) || presentationId; + const id = this.toPresentationId(presentationId); const slides = await this.getSlidesClient(); const presentation = await slides.presentations.get({ @@ -276,7 +951,7 @@ export class SlidesService { `[SlidesService] Starting getImages for presentation: ${presentationId} (localPath: ${localPath})`, ); try { - const id = extractDocId(presentationId) || presentationId; + const id = this.toPresentationId(presentationId); const slides = await this.getSlidesClient(); const presentation = await slides.presentations.get({ presentationId: id, @@ -357,7 +1032,7 @@ export class SlidesService { `[SlidesService] Starting getSlideThumbnail for presentation: ${presentationId}, slide: ${slideObjectId} (localPath: ${localPath})`, ); try { - const id = extractDocId(presentationId) || presentationId; + const id = this.toPresentationId(presentationId); const slides = await this.getSlidesClient(); const thumbnail = await slides.presentations.pages.getThumbnail({ presentationId: id, From 6e3b16c12716c29473f252a7e044ab87b40f5c1a Mon Sep 17 00:00:00 2001 From: Nate Hogsten Date: Mon, 23 Feb 2026 19:04:43 -1000 Subject: [PATCH 2/2] fix(slides): use layout placeholders and unify write errors --- .../__tests__/services/SlidesService.test.ts | 87 +++++++++-- .../src/services/SlidesService.ts | 140 +++++++++++++----- 2 files changed, 181 insertions(+), 46 deletions(-) diff --git a/workspace-server/src/__tests__/services/SlidesService.test.ts b/workspace-server/src/__tests__/services/SlidesService.test.ts index 73d1daea..7ae9c7d0 100644 --- a/workspace-server/src/__tests__/services/SlidesService.test.ts +++ b/workspace-server/src/__tests__/services/SlidesService.test.ts @@ -136,11 +136,20 @@ describe('SlidesService', () => { const createRequests = mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody .requests; - expect( - createRequests - .filter((req: any) => req.createShape) - .every((req: any) => req.createShape.shapeType === 'TEXT_BOX'), - ).toBe(true); + const slideCreateReq = createRequests.find((req: any) => req.createSlide); + expect(slideCreateReq.createSlide.placeholderIdMappings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layoutPlaceholder: { type: 'TITLE', index: 0 }, + }), + expect.objectContaining({ + layoutPlaceholder: { type: 'BODY', index: 0 }, + }), + ]), + ); + expect(createRequests.filter((req: any) => req.createShape)).toHaveLength( + 1, + ); expect(response.presentationId).toBe('new-pres-id'); expect(response.slideObjectIds).toHaveLength(2); expect(response.url).toBe( @@ -241,11 +250,22 @@ describe('SlidesService', () => { const addSlideRequests = mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody .requests; + const addSlideCreateReq = addSlideRequests.find( + (req: any) => req.createSlide, + ); + expect(addSlideCreateReq.createSlide.placeholderIdMappings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layoutPlaceholder: { type: 'TITLE', index: 0 }, + }), + expect.objectContaining({ + layoutPlaceholder: { type: 'BODY', index: 0 }, + }), + ]), + ); expect( - addSlideRequests - .filter((req: any) => req.createShape) - .every((req: any) => req.createShape.shapeType === 'TEXT_BOX'), - ).toBe(true); + addSlideRequests.filter((req: any) => req.createShape), + ).toHaveLength(0); expect(response.presentationId).toBe('pres-1'); expect(response.slideObjectId).toEqual(expect.any(String)); }); @@ -360,6 +380,23 @@ describe('SlidesService', () => { }); expect(response.presentationId).toBe('pres-1'); }); + + it('should return structured error payload on replaceText failure', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Bad request'), + ); + + const result = await slidesService.replaceText({ + presentationId: 'pres-1', + findText: 'Old', + replaceText: 'New', + }); + const response = JSON.parse(result.content[0].text); + + expect(response.error).toBe('Bad request'); + expect(response.code).toBe('SLIDES_INVALID_REQUEST'); + expect(response.retryable).toBe(false); + }); }); describe('deleteSlide', () => { @@ -388,6 +425,22 @@ describe('SlidesService', () => { }); expect(response.deletedSlideObjectId).toBe('slide-1'); }); + + it('should return structured error payload on deleteSlide failure', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Bad request'), + ); + + const result = await slidesService.deleteSlide({ + presentationId: 'pres-1', + slideObjectId: 'slide-1', + }); + const response = JSON.parse(result.content[0].text); + + expect(response.error).toBe('Bad request'); + expect(response.code).toBe('SLIDES_INVALID_REQUEST'); + expect(response.retryable).toBe(false); + }); }); describe('batchUpdate', () => { @@ -417,6 +470,22 @@ describe('SlidesService', () => { expect(response.presentationId).toBe('pres-1'); expect(response.replies).toHaveLength(1); }); + + it('should return structured error payload on batchUpdate failure', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Bad request'), + ); + + const result = await slidesService.batchUpdate({ + presentationId: 'pres-1', + requests: [{ createSlide: { objectId: 'slide-123' } }], + }); + const response = JSON.parse(result.content[0].text); + + expect(response.error).toBe('Bad request'); + expect(response.code).toBe('SLIDES_INVALID_REQUEST'); + expect(response.retryable).toBe(false); + }); }); describe('getText', () => { diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index b938e92c..bf96bb8c 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -155,54 +155,105 @@ export class SlidesService { insertionIndex?: number, ): BuildAddSlideResult { const slideObjectId = this.createObjectId('slide'); + const layout = this.toLayout(slide.layout); + const supportsTitlePlaceholder = layout !== 'BLANK'; + const bodyPlaceholderType = layout === 'TITLE' ? 'SUBTITLE' : 'BODY'; + const supportsBodyPlaceholder = + bodyPlaceholderType === 'SUBTITLE' || bodyPlaceholderType === 'BODY'; + const titlePlaceholderObjectId = + slide.title && supportsTitlePlaceholder + ? this.createObjectId('title_placeholder') + : undefined; + const bodyPlaceholderObjectId = + slide.body?.length && supportsBodyPlaceholder + ? this.createObjectId('body_placeholder') + : undefined; + const placeholderIdMappings: slides_v1.Schema$LayoutPlaceholderIdMapping[] = + []; + + if (titlePlaceholderObjectId) { + placeholderIdMappings.push({ + layoutPlaceholder: { type: 'TITLE', index: 0 }, + objectId: titlePlaceholderObjectId, + }); + } + + if (bodyPlaceholderObjectId) { + placeholderIdMappings.push({ + layoutPlaceholder: { type: bodyPlaceholderType, index: 0 }, + objectId: bodyPlaceholderObjectId, + }); + } + const requests: slides_v1.Schema$Request[] = [ { createSlide: { objectId: slideObjectId, insertionIndex, slideLayoutReference: { - predefinedLayout: this.toLayout(slide.layout), + predefinedLayout: layout, }, + ...(placeholderIdMappings.length > 0 ? { placeholderIdMappings } : {}), }, }, ]; if (slide.title) { - const titleShapeId = this.createObjectId('title'); - requests.push( - this.buildCreateShapeRequest( - slideObjectId, - titleShapeId, - 'TITLE', - this.getShapeGeometry('TITLE'), - ), - { + if (titlePlaceholderObjectId) { + requests.push({ insertText: { - objectId: titleShapeId, + objectId: titlePlaceholderObjectId, insertionIndex: 0, text: slide.title, }, - }, - ); + }); + } else { + const titleShapeId = this.createObjectId('title'); + requests.push( + this.buildCreateShapeRequest( + slideObjectId, + titleShapeId, + 'TITLE', + this.getShapeGeometry('TITLE'), + ), + { + insertText: { + objectId: titleShapeId, + insertionIndex: 0, + text: slide.title, + }, + }, + ); + } } if (slide.body?.length) { - const bodyShapeId = this.createObjectId('body'); - requests.push( - this.buildCreateShapeRequest( - slideObjectId, - bodyShapeId, - 'TEXT_BOX', - this.getShapeGeometry('TEXT_BOX'), - ), - { + if (bodyPlaceholderObjectId) { + requests.push({ insertText: { - objectId: bodyShapeId, + objectId: bodyPlaceholderObjectId, insertionIndex: 0, text: slide.body.join('\n'), }, - }, - ); + }); + } else { + const bodyShapeId = this.createObjectId('body'); + requests.push( + this.buildCreateShapeRequest( + slideObjectId, + bodyShapeId, + 'TEXT_BOX', + this.getShapeGeometry('TEXT_BOX'), + ), + { + insertText: { + objectId: bodyShapeId, + insertionIndex: 0, + text: slide.body.join('\n'), + }, + }, + ); + } } return { slideObjectId, requests }; @@ -587,14 +638,19 @@ export class SlidesService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile(`[SlidesService] Error during slides.replaceText: ${errorMessage}`); + const payload = this.buildWriteError( + error, + 'SLIDES_INVALID_REQUEST', + false, + ); + logToFile( + `[SlidesService] Error during slides.replaceText [${payload.code}]: ${payload.error}`, + ); return { content: [ { type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), + text: JSON.stringify(payload), }, ], }; @@ -642,14 +698,19 @@ export class SlidesService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile(`[SlidesService] Error during slides.deleteSlide: ${errorMessage}`); + const payload = this.buildWriteError( + error, + 'SLIDES_INVALID_REQUEST', + false, + ); + logToFile( + `[SlidesService] Error during slides.deleteSlide [${payload.code}]: ${payload.error}`, + ); return { content: [ { type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), + text: JSON.stringify(payload), }, ], }; @@ -690,14 +751,19 @@ export class SlidesService { ], }; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logToFile(`[SlidesService] Error during slides.batchUpdate: ${errorMessage}`); + const payload = this.buildWriteError( + error, + 'SLIDES_INVALID_REQUEST', + false, + ); + logToFile( + `[SlidesService] Error during slides.batchUpdate [${payload.code}]: ${payload.error}`, + ); return { content: [ { type: 'text' as const, - text: JSON.stringify({ error: errorMessage }), + text: JSON.stringify(payload), }, ], };