From 2ae378ca043ba89e29790d310314bde23d0ab9f7 Mon Sep 17 00:00:00 2001 From: Nick Losier Date: Thu, 25 Jun 2026 22:59:22 -0600 Subject: [PATCH 1/6] feat(slides,drive): add createFromJson, drive staging primitives, theme system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt on top of the baseline slides tools now in main. Layers the net-new blueprint engine and Drive image-staging primitives onto the granular slides.*/drive.* tools without duplicating them (create/getSpeakerNotes/ updateSpeakerNotes are reused from the baseline). New tools (all registered through the feature-config-gated registerTool wrapper): - slides.createFromJson — JSON blueprint → slides in one batchUpdate, with a theme/color-alias system, inline speaker notes, and layer ordering. - slides.batchUpdate — raw Slides API passthrough (escape hatch); accepts a request array or a JSON string. - drive.uploadFile / addPublicAccess / removePublicAccess — safe-by-default image staging: upload is private; sharing is explicit and reversible. Addresses review feedback: - Themes pared to a small honest set (default + dark); neutral naming; tool description trimmed to what actually ships. - createFromJson only deletes the default "p" slide when isNewPresentation and after confirming it exists; no more silent catch; guards missing elements. - buildSlideRequests skips empty bold_phrases/links (no zero-length ranges); placeholder image URLs are reported under result.warnings. - color/bg_color/border_color accept a theme alias string OR an RGB object, so the alias system is reachable from the tool boundary. - drive.addPublicAccess returns an lh3 image URL and a structured, actionable hint on publishOutNotPermitted/cannotShareOutsideDomain; removePublicAccess isolates each delete and reports {removed, failed}. - All new returns set isError on failure. - Unit tests for resolveColor, createFromJson (translation/guards/warnings/ default-slide), batchUpdate, uploadFile, add/removePublicAccess. --- .../__tests__/services/DriveService.test.ts | 135 ++++ .../__tests__/services/SlidesService.test.ts | 231 ++++++- .../src/features/feature-config.ts | 5 + workspace-server/src/index.ts | 188 +++++ workspace-server/src/services/DriveService.ts | 191 ++++++ .../src/services/SlidesService.ts | 645 ++++++++++++++++++ 6 files changed, 1394 insertions(+), 1 deletion(-) diff --git a/workspace-server/src/__tests__/services/DriveService.test.ts b/workspace-server/src/__tests__/services/DriveService.test.ts index 6703e68..5a4cb8f 100644 --- a/workspace-server/src/__tests__/services/DriveService.test.ts +++ b/workspace-server/src/__tests__/services/DriveService.test.ts @@ -31,6 +31,7 @@ jest.mock('node:fs', () => { existsSync: jest.fn(), writeFileSync: jest.fn(), mkdirSync: jest.fn(), + createReadStream: jest.fn(), }; }); jest.mock('node:path', () => { @@ -76,6 +77,11 @@ describe('DriveService', () => { comments: { list: jest.fn(), }, + permissions: { + create: jest.fn(), + list: jest.fn(), + delete: jest.fn(), + }, }; // Mock the google.drive constructor @@ -1439,4 +1445,133 @@ describe('DriveService', () => { ); }); }); + + describe('uploadFile', () => { + it('should error (isError) when the local file does not exist', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + const result = await driveService.uploadFile({ + localPath: '/does/not/exist.png', + }); + const response = JSON.parse(result.content[0].text); + + expect('isError' in result && result.isError).toBe(true); + expect(response.error).toContain('File not found'); + expect(mockDriveAPI.files.create).not.toHaveBeenCalled(); + }); + + it('should upload a private file and return its id', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + mockDriveAPI.files.create.mockResolvedValue({ + data: { id: 'uploaded-id', name: 'pic.png', webViewLink: 'http://x' }, + }); + + const result = await driveService.uploadFile({ + localPath: '/tmp/pic.png', + mimeType: 'image/png', + }); + const response = JSON.parse(result.content[0].text); + + expect(response.id).toBe('uploaded-id'); + // No sharing is granted on upload. + expect(mockDriveAPI.permissions.create).not.toHaveBeenCalled(); + }); + }); + + describe('addPublicAccess', () => { + it('should grant anyone:reader and return an lh3 image URL', async () => { + mockDriveAPI.permissions.create.mockResolvedValue({ + data: { id: 'perm-1' }, + }); + + const result = await driveService.addPublicAccess({ fileId: 'file123' }); + const response = JSON.parse(result.content[0].text); + + expect(mockDriveAPI.permissions.create).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: { role: 'reader', type: 'anyone' }, + }), + ); + expect(response.imageUrl).toBe( + 'https://lh3.googleusercontent.com/d/file123', + ); + }); + + it('should surface a structured hint when org policy blocks sharing', async () => { + mockDriveAPI.permissions.create.mockRejectedValue({ + message: 'Insufficient permissions', + errors: [{ reason: 'publishOutNotPermitted' }], + }); + + const result = await driveService.addPublicAccess({ fileId: 'file123' }); + const response = JSON.parse(result.content[0].text); + + expect('isError' in result && result.isError).toBe(true); + expect(response.reason).toBe('publishOutNotPermitted'); + expect(response.hint).toContain('publicly reachable URL'); + }); + }); + + describe('removePublicAccess', () => { + it('should revoke only anyone-type permissions', async () => { + mockDriveAPI.permissions.list.mockResolvedValue({ + data: { + permissions: [ + { id: 'p-anyone', type: 'anyone', role: 'reader' }, + { id: 'p-user', type: 'user', role: 'writer' }, + ], + }, + }); + mockDriveAPI.permissions.delete.mockResolvedValue({}); + + const result = await driveService.removePublicAccess({ + fileId: 'file123', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockDriveAPI.permissions.delete).toHaveBeenCalledTimes(1); + expect(mockDriveAPI.permissions.delete).toHaveBeenCalledWith( + expect.objectContaining({ permissionId: 'p-anyone' }), + ); + expect(response.removed).toEqual(['p-anyone']); + expect(response.failed).toEqual([]); + }); + + it('should report partial failures without throwing', async () => { + mockDriveAPI.permissions.list.mockResolvedValue({ + data: { + permissions: [ + { id: 'p-1', type: 'anyone' }, + { id: 'p-2', type: 'anyone' }, + ], + }, + }); + mockDriveAPI.permissions.delete + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('revoked auth')); + + const result = await driveService.removePublicAccess({ + fileId: 'file123', + }); + const response = JSON.parse(result.content[0].text); + + expect(response.removed).toEqual(['p-1']); + expect(response.failed).toHaveLength(1); + expect(response.failed[0].permissionId).toBe('p-2'); + }); + + it('should be a no-op when there are no public permissions', async () => { + mockDriveAPI.permissions.list.mockResolvedValue({ + data: { permissions: [{ id: 'p-user', type: 'user' }] }, + }); + + const result = await driveService.removePublicAccess({ + fileId: 'file123', + }); + const response = JSON.parse(result.content[0].text); + + expect(mockDriveAPI.permissions.delete).not.toHaveBeenCalled(); + expect(response.removed).toEqual([]); + }); + }); }); diff --git a/workspace-server/src/__tests__/services/SlidesService.test.ts b/workspace-server/src/__tests__/services/SlidesService.test.ts index f0ecb2f..3fced0e 100644 --- a/workspace-server/src/__tests__/services/SlidesService.test.ts +++ b/workspace-server/src/__tests__/services/SlidesService.test.ts @@ -12,7 +12,11 @@ import { beforeEach, afterEach, } from '@jest/globals'; -import { SlidesService } from '../../services/SlidesService'; +import { + SlidesService, + resolveColor, + THEMES, +} from '../../services/SlidesService'; import { AuthManager } from '../../auth/AuthManager'; import { google } from 'googleapis'; import { request } from 'gaxios'; @@ -1788,4 +1792,229 @@ describe('SlidesService', () => { expect(response.error).toBe('Shape Props Error'); }); }); + + describe('batchUpdate', () => { + it('should parse a JSON string of requests and call the API', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.batchUpdate({ + presentationId: 'pres-1', + requests: JSON.stringify([{ createSlide: {} }]), + }); + JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'pres-1', + requestBody: { requests: [{ createSlide: {} }] }, + }); + expect(result.isError).toBeUndefined(); + }); + + it('should accept an already-parsed array of requests', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [] }, + }); + + await slidesService.batchUpdate({ + presentationId: 'pres-1', + requests: [{ deleteObject: { objectId: 'x' } }], + }); + + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalledWith({ + presentationId: 'pres-1', + requestBody: { requests: [{ deleteObject: { objectId: 'x' } }] }, + }); + }); + + it('should flag errors with isError', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Batch Error'), + ); + const result = await slidesService.batchUpdate({ + presentationId: 'pres-1', + requests: [], + }); + const response = JSON.parse(result.content[0].text); + expect(result.isError).toBe(true); + expect(response.error).toBe('Batch Error'); + }); + }); + + describe('createFromJson', () => { + it('should translate a blueprint into createSlide + element requests', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}, {}] }, + }); + + const result = await slidesService.createFromJson({ + presentationId: 'pres-1', + slideJson: { + slides: [ + { + elements: [ + { + type: 'shape', + shape_type: 'RECTANGLE', + position: { x: 0, y: 0, w: 100, h: 50 }, + style: { bg_color: 'primary' }, + }, + { + type: 'text', + content: 'Hello', + position: { x: 10, y: 10, w: 80, h: 30 }, + style: { color: 'blue', size: 18 }, + }, + ], + }, + ], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(result.isError).toBeUndefined(); + expect(response.slidesCreated).toBe(1); + expect(response.presentationLink).toContain('pres-1'); + // Default-slide deletion only runs for new presentations. + expect(mockSlidesAPI.presentations.get).not.toHaveBeenCalled(); + + const sentRequests = + mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody + .requests; + const kinds = sentRequests.map((r: any) => Object.keys(r)[0]); + expect(kinds).toContain('createSlide'); + expect(kinds).toContain('createShape'); + expect(kinds).toContain('insertText'); + }); + + it('should not crash when a slide omits elements', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.createFromJson({ + presentationId: 'pres-1', + slideJson: { slides: [{}] } as any, + }); + const response = JSON.parse(result.content[0].text); + expect(result.isError).toBeUndefined(); + expect(response.slidesCreated).toBe(1); + }); + + it('should substitute a fallback icon and warn for placeholder image URLs', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.createFromJson({ + presentationId: 'pres-1', + slideJson: { + slides: [ + { + elements: [ + { + type: 'image', + url: 'https://example.com/{placeholder}.png', + position: { x: 0, y: 0, w: 100, h: 100 }, + }, + ], + }, + ], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.warnings).toHaveLength(1); + expect(response.warnings[0]).toMatchObject({ + slideIndex: 0, + elementIndex: 0, + }); + + const sentRequests = + mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody + .requests; + const image = sentRequests.find((r: any) => r.createImage); + expect(image.createImage.url).not.toContain('{'); + }); + + it('should delete the default slide only when isNewPresentation and "p" exists', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + mockSlidesAPI.presentations.get.mockResolvedValue({ + data: { slides: [{ objectId: 'p' }, { objectId: 'slide_x' }] }, + }); + + await slidesService.createFromJson({ + presentationId: 'pres-1', + slideJson: { + slides: [ + { + elements: [ + { + type: 'text', + content: 'x', + position: { x: 0, y: 0, w: 1, h: 1 }, + }, + ], + }, + ], + }, + isNewPresentation: true, + }); + + const deleteCall = + mockSlidesAPI.presentations.batchUpdate.mock.calls.find((c: any) => + c[0].requestBody.requests.some((r: any) => r.deleteObject), + ); + expect(deleteCall).toBeDefined(); + expect(deleteCall[0].requestBody.requests[0].deleteObject.objectId).toBe( + 'p', + ); + }); + + it('should flag errors with isError', async () => { + mockSlidesAPI.presentations.batchUpdate.mockRejectedValue( + new Error('Create From Json Error'), + ); + const result = await slidesService.createFromJson({ + presentationId: 'pres-1', + slideJson: { slides: [{ elements: [] }] }, + }); + const response = JSON.parse(result.content[0].text); + expect(result.isError).toBe(true); + expect(response.error).toBe('Create From Json Error'); + }); + }); +}); + +describe('resolveColor + THEMES', () => { + it('resolves named aliases against the active theme', () => { + const theme = THEMES.default; + expect(resolveColor('primary', theme)).toEqual(theme.primary); + expect(resolveColor('text_muted', theme)).toEqual(theme.textMuted); + }); + + it('maps semantic accent aliases (blue/red/yellow/green)', () => { + const theme = THEMES.default; + expect(resolveColor('blue', theme)).toEqual(theme.accent1); + expect(resolveColor('red', theme)).toEqual(theme.accent2); + expect(resolveColor('yellow', theme)).toEqual(theme.accent3); + expect(resolveColor('green', theme)).toEqual(theme.accent4); + }); + + it('passes RGB objects through unchanged', () => { + const rgb = { red: 0.1, green: 0.2, blue: 0.3 }; + expect(resolveColor(rgb, THEMES.default)).toBe(rgb); + }); + + it('returns undefined for unknown aliases or empty input', () => { + expect(resolveColor('not-a-color', THEMES.default)).toBeUndefined(); + expect(resolveColor(undefined, THEMES.default)).toBeUndefined(); + }); + + it('ships exactly the default and dark themes', () => { + expect(Object.keys(THEMES).sort()).toEqual(['dark', 'default']); + }); }); diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index dc9eef5..81064ac 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -99,6 +99,9 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'drive.moveFile', 'drive.trashFile', 'drive.renameFile', + 'drive.uploadFile', + 'drive.addPublicAccess', + 'drive.removePublicAccess', ], defaultEnabled: true, }, @@ -226,6 +229,8 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'slides.addTable', 'slides.updateTextStyle', 'slides.updateShapeProperties', + 'slides.batchUpdate', + 'slides.createFromJson', ], defaultEnabled: false, }, diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index e7a8077..d4d9276 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -893,6 +893,140 @@ async function main() { slidesService.updateShapeProperties, ); + registerTool( + 'slides.batchUpdate', + { + description: + 'Raw passthrough to the Slides API presentations.batchUpdate. Escape hatch for arbitrary or complex edits to an existing deck that the granular slides.* tools do not cover (connectors, grouping, table cell merges, transforms, bulk restyle). For building decks, prefer slides.createFromJson.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation to modify.'), + requests: z + .union([z.string(), z.array(z.any())]) + .describe( + 'An array of Slides API request objects, or a JSON string of that array (e.g., [{"createSlide":{}}, {"createShape":{...}}]).', + ), + }, + }, + slidesService.batchUpdate, + ); + + // Color accepts a theme alias string ("primary", "text", "blue", ...) or an + // explicit RGB object (0-1 components). Aliases resolve against the active theme. + const slidesColorSchema = z.union([ + z.string(), + z.object({ + red: z.number().min(0).max(1), + green: z.number().min(0).max(1), + blue: z.number().min(0).max(1), + }), + ]); + + const slideElementSchema = z.object({ + type: z.enum(['text', 'shape', 'image']), + content: z.string().optional().describe('Text content (text elements).'), + shape_type: z + .string() + .optional() + .describe( + 'Slides shape type (e.g. "RECTANGLE", "ELLIPSE"). Shapes only.', + ), + url: z.string().optional().describe('Image URL (image elements).'), + layer: z + .number() + .optional() + .describe('Render order; lower renders first (backgrounds=0, text=2+).'), + position: z + .object({ + x: z.number(), + y: z.number(), + w: z.number(), + h: z.number(), + }) + .describe('Position and size in points on the 720×405 canvas.'), + style: z + .object({ + size: z.number().optional(), + bold: z.boolean().optional(), + italic: z.boolean().optional(), + underline: z.boolean().optional(), + strikethrough: z.boolean().optional(), + align: z.enum(['START', 'CENTER', 'END']).optional(), + vertical_align: z.enum(['TOP', 'MIDDLE', 'BOTTOM']).optional(), + indent: z.number().optional(), + color: slidesColorSchema + .optional() + .describe('Text color (alias or RGB).'), + bg_color: slidesColorSchema + .optional() + .describe('Shape background color (alias or RGB).'), + border_color: slidesColorSchema + .optional() + .describe('Shape border color (alias or RGB).'), + border_weight: z.number().optional(), + no_border: z.boolean().optional(), + font_family: z + .string() + .optional() + .describe('Font family, or "theme" to inherit the theme font.'), + bold_phrases: z + .array(z.string().min(1)) + .optional() + .describe('Phrases within content to bold (every occurrence).'), + bold_until: z + .number() + .optional() + .describe('Bold the leading characters up to this index.'), + links: z + .array(z.object({ text: z.string().min(1), url: z.string() })) + .optional() + .describe('Hyperlinks applied to matching phrases.'), + }) + .optional(), + }); + + const slideObjectSchema = z.object({ + elements: z.array(slideElementSchema), + speaker_notes: z.string().optional(), + }); + + registerTool( + 'slides.createFromJson', + { + description: + 'Creates one or more slides from a JSON blueprint and appends them to a presentation. Speaker notes in the blueprint are written automatically.\n\nFORMATS: {"slides":[{"elements":[...],"speaker_notes":"..."},...]} for multiple slides, or {"elements":[...]} for a single slide.\n\nCANVAS: 720×405 pt (16:9), origin top-left.\n\nELEMENT TYPES: type ("text"|"shape"|"image"), position ({x,y,w,h} in points), optional content, shape_type, url (images), layer (z-index; lower renders first — backgrounds=0, boxes=1, text=2+).\n\nCOLORS: use a theme alias ("primary", "secondary", "surface", "surface_alt", "text", "text_muted", "background", or accent aliases "blue"/"red"/"yellow"/"green") OR an explicit RGB 0-1 object for one-off colors. Aliases resolve against the active theme.\n\nTHEMES: pass theme:"default" (light, the default) or theme:"dark". font_family:"theme" inherits the theme font.\n\nSPEAKER NOTES (strongly recommended): include "speaker_notes" on each slide and they are written automatically. If omitted, the response returns an action_required hint asking you to call slides.updateSpeakerNotes per slide.\n\nNEW DECKS: when you just created the presentation with slides.create, pass isNewPresentation:true to remove the default blank first slide. When appending to an existing deck, leave it false so nothing is deleted.\n\nSTYLE PROPERTIES: size, bold, italic, underline, strikethrough, align (START|CENTER|END), vertical_align (TOP|MIDDLE|BOTTOM), indent, color, bg_color, border_color, border_weight, no_border, font_family ("theme" to inherit), bold_phrases, bold_until, links ([{text,url}]). font_family defaults to the active theme font. Image URLs containing unresolved placeholders are replaced with a fallback icon and reported under "warnings".', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation to add slides to.'), + slideJson: z + .union([ + z.object({ slides: z.array(slideObjectSchema) }), + z.object({ + elements: z.array(slideElementSchema), + speaker_notes: z.string().optional(), + }), + z.string(), + ]) + .describe( + 'The slide blueprint. Use {"slides":[{"elements":[...],"speaker_notes":"..."}]} for multiple slides or {"elements":[...]} for one. Accepts an object or a JSON string.', + ), + theme: z + .enum(['default', 'dark']) + .optional() + .describe('Theme to apply. Defaults to "default" (light).'), + isNewPresentation: z + .boolean() + .optional() + .describe( + 'When true, removes the default blank slide that a brand-new presentation ships with. Leave false (default) when appending to an existing deck.', + ), + }, + }, + slidesService.createFromJson, + ); + // Sheets tools registerTool( 'sheets.getText', @@ -1047,6 +1181,60 @@ async function main() { driveService.renameFile, ); + registerTool( + 'drive.uploadFile', + { + description: + 'Uploads a local file to Google Drive. File is PRIVATE by default — only the authenticated user can read it. To make it fetchable by the Slides API (or any other public consumer), call drive.addPublicAccess separately. Returns id, name, and webViewLink.', + inputSchema: { + localPath: z + .string() + .describe('Absolute path to the local file to upload.'), + name: z + .string() + .optional() + .describe( + 'Name for the file in Drive. Defaults to the local filename.', + ), + mimeType: z + .string() + .optional() + .describe( + 'MIME type of the file (e.g. "image/png"). Defaults to application/octet-stream.', + ), + parentId: z + .string() + .optional() + .describe('Drive folder ID to upload into. Defaults to root.'), + }, + }, + driveService.uploadFile, + ); + + registerTool( + 'drive.addPublicAccess', + { + description: + 'Grants anyone:reader sharing on a Drive file, making it readable by anyone with the link (including unauthenticated services like the Slides API image fetcher). Returns a public imageUrl suitable for slides.createFromJson image elements. ALWAYS pair with drive.removePublicAccess when done to close the share. Subject to Workspace org policy — corporate domains often block this (publishOutNotPermitted error).', + inputSchema: { + fileId: z.string().describe('The ID or URL of the Drive file.'), + }, + }, + driveService.addPublicAccess, + ); + + registerTool( + 'drive.removePublicAccess', + { + description: + 'Revokes any anyone-type sharing permissions on a Drive file (e.g. anyone:reader granted by drive.addPublicAccess). File remains in Drive — only the public link is removed. Returns the lists of removed and failed permission IDs. Idempotent: returns empty lists if no public permissions exist.', + inputSchema: { + fileId: z.string().describe('The ID or URL of the Drive file.'), + }, + }, + driveService.removePublicAccess, + ); + registerTool( 'calendar.list', { diff --git a/workspace-server/src/services/DriveService.ts b/workspace-server/src/services/DriveService.ts index 8561b61..07e0102 100644 --- a/workspace-server/src/services/DriveService.ts +++ b/workspace-server/src/services/DriveService.ts @@ -639,4 +639,195 @@ export class DriveService { }; } }; + + /** + * Upload a local file to Drive. The file is PRIVATE — no sharing is granted. + * To make it fetchable by the Slides API (or any other public consumer), call + * drive.addPublicAccess separately, then drive.removePublicAccess when done. + */ + public uploadFile = async ({ + localPath, + name, + mimeType, + parentId, + }: { + localPath: string; + name?: string; + mimeType?: string; + parentId?: string; + }) => { + logToFile(`Uploading file from ${localPath}`); + try { + const drive = await this.getDriveClient(); + + const absolutePath = path.isAbsolute(localPath) + ? localPath + : path.join(PROJECT_ROOT, localPath); + + if (!fs.existsSync(absolutePath)) { + return this.handleError( + 'drive.uploadFile', + new Error(`File not found: ${absolutePath}`), + ); + } + + const fileName = name ?? path.basename(absolutePath); + const fileMime = mimeType ?? 'application/octet-stream'; + + const fileMetadata: drive_v3.Schema$File = { name: fileName }; + if (parentId) fileMetadata.parents = [parentId]; + + const file = await drive.files.create({ + requestBody: fileMetadata, + media: { mimeType: fileMime, body: fs.createReadStream(absolutePath) }, + fields: 'id, name, webViewLink', + supportsAllDrives: true, + }); + + const fileId = file.data.id!; + logToFile(`Uploaded ${fileName} → ${fileId} (private)`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + id: fileId, + name: file.data.name, + webViewLink: file.data.webViewLink, + }), + }, + ], + }; + } catch (error) { + return this.handleError('drive.uploadFile', error); + } + }; + + /** + * Grant anyone:reader on a file and return a public image URL suitable for the + * Slides API server-side fetch. On Workspace domains where org policy blocks + * public sharing, returns a structured, actionable error instead of an opaque + * permissions string. + */ + public addPublicAccess = async ({ fileId }: { fileId: string }) => { + logToFile(`Granting anyone:reader on Drive file: ${fileId}`); + try { + const drive = await this.getDriveClient(); + const id = extractDocumentId(fileId); + const perm = await drive.permissions.create({ + fileId: id, + supportsAllDrives: true, + requestBody: { role: 'reader', type: 'anyone' }, + fields: 'id', + }); + // lh3 is reliably fetchable by the Slides API; the legacy + // uc?export=download form returns an HTML interstitial for larger files. + const imageUrl = `https://lh3.googleusercontent.com/d/${id}`; + logToFile(`Granted anyone:reader on ${id} (perm ${perm.data.id})`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + id, + permissionId: perm.data.id, + imageUrl, + }), + }, + ], + }; + } catch (error) { + const reason = this.getErrorReason(error); + if ( + reason === 'publishOutNotPermitted' || + reason === 'cannotShareOutsideDomain' + ) { + const message = error instanceof Error ? error.message : String(error); + logToFile( + `drive.addPublicAccess blocked by org policy (${reason}): ${message}`, + ); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: message, + reason, + hint: 'This Workspace domain blocks public link sharing. Host the image on a publicly reachable URL instead (e.g. a GCS signed URL) and pass that to slides.createFromJson.', + }), + }, + ], + }; + } + return this.handleError('drive.addPublicAccess', error); + } + }; + + /** + * Revoke every anyone:* permission on a file. Idempotent: each deletion is + * isolated so a mid-loop failure still reports which permissions were removed + * and which failed, keeping retries safe. + */ + public removePublicAccess = async ({ fileId }: { fileId: string }) => { + logToFile(`Removing public access from Drive file: ${fileId}`); + try { + const drive = await this.getDriveClient(); + const id = extractDocumentId(fileId); + const perms = await drive.permissions.list({ + fileId: id, + supportsAllDrives: true, + fields: 'permissions(id,type,role)', + }); + const anyonePerms = (perms.data.permissions ?? []).filter( + (p) => p.type === 'anyone', + ); + const removed: string[] = []; + const failed: Array<{ permissionId: string; error: string }> = []; + for (const p of anyonePerms) { + if (!p.id) continue; + try { + await drive.permissions.delete({ + fileId: id, + permissionId: p.id, + supportsAllDrives: true, + }); + removed.push(p.id); + } catch (delError) { + failed.push({ + permissionId: p.id, + error: + delError instanceof Error ? delError.message : String(delError), + }); + } + } + logToFile( + `Revoked ${removed.length} anyone:* permission(s) on ${id} (${failed.length} failed)`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ id, removed, failed }), + }, + ], + }; + } catch (error) { + return this.handleError('drive.removePublicAccess', error); + } + }; + + /** Extract a Google API error `reason` code from a gaxios/googleapis error. */ + private getErrorReason(error: unknown): string | undefined { + const e = error as { + errors?: Array<{ reason?: string }>; + response?: { data?: { error?: { errors?: Array<{ reason?: string }> } } }; + }; + return ( + e?.errors?.[0]?.reason ?? + e?.response?.data?.error?.errors?.[0]?.reason ?? + undefined + ); + } } diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index d9c7464..44c3d6e 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -13,6 +13,106 @@ import { logToFile } from '../utils/logger'; import { extractDocId } from '../utils/IdUtils'; import { gaxiosOptions } from '../utils/GaxiosConfig'; +// === Theme system (used by createFromJson) === + +type RGB = { red: number; green: number; blue: number }; +type ColorValue = RGB | string; + +interface Theme { + primary: RGB; // Header / primary accent background + primaryText: RGB; // Text on primary background + secondary: RGB; // Secondary accent + secondaryText: RGB; // Text on secondary background + surface: RGB; // Card / box background (primary tint) + surfaceAlt: RGB; // Card / box background (secondary tint) + text: RGB; // Default body text + textMuted: RGB; // Muted / caption text + background: RGB; // Slide background + fontFamily: string; // Default font family + // Extended accent palette — used when an accent alias is referenced. + accent1?: RGB; + accent2?: RGB; + accent3?: RGB; + accent4?: RGB; +} + +/** + * Built-in themes for createFromJson. Two ship today: a neutral light theme + * (`default`) and a dark theme (`dark`). Color aliases resolve against the + * active theme so blueprints stay theme-portable. + */ +export const THEMES: Record = { + default: { + primary: { red: 0.125, green: 0.129, blue: 0.141 }, // #202124 near-black (headers) + primaryText: { red: 1.0, green: 1.0, blue: 1.0 }, + secondary: { red: 0.102, green: 0.451, blue: 0.91 }, // #1A73E8 blue accent + secondaryText: { red: 1.0, green: 1.0, blue: 1.0 }, + surface: { red: 0.91, green: 0.941, blue: 0.996 }, // light blue tint + surfaceAlt: { red: 0.902, green: 0.957, blue: 0.918 }, // light green tint + text: { red: 0.122, green: 0.122, blue: 0.122 }, // #1F1F1F + textMuted: { red: 0.267, green: 0.278, blue: 0.275 }, // #444746 + background: { red: 1.0, green: 1.0, blue: 1.0 }, + fontFamily: 'Arial', + accent1: { red: 0.263, green: 0.522, blue: 0.957 }, // blue + accent2: { red: 0.918, green: 0.263, blue: 0.208 }, // red + accent3: { red: 0.984, green: 0.737, blue: 0.02 }, // yellow + accent4: { red: 0.204, green: 0.659, blue: 0.325 }, // green + }, + dark: { + primary: { red: 0.129, green: 0.588, blue: 0.953 }, // bright blue accent on dark + primaryText: { red: 1.0, green: 1.0, blue: 1.0 }, + secondary: { red: 0.611, green: 0.353, blue: 0.949 }, // purple accent + secondaryText: { red: 1.0, green: 1.0, blue: 1.0 }, + surface: { red: 0.157, green: 0.165, blue: 0.184 }, // #282A2F card + surfaceAlt: { red: 0.204, green: 0.212, blue: 0.235 }, // slightly lighter card + text: { red: 0.925, green: 0.933, blue: 0.945 }, // near-white body + textMuted: { red: 0.667, green: 0.678, blue: 0.698 }, // muted gray + background: { red: 0.075, green: 0.082, blue: 0.094 }, // #131517 slide bg + fontFamily: 'Arial', + accent1: { red: 0.4, green: 0.624, blue: 0.969 }, // blue + accent2: { red: 0.969, green: 0.451, blue: 0.408 }, // red + accent3: { red: 1.0, green: 0.831, blue: 0.31 }, // yellow + accent4: { red: 0.388, green: 0.776, blue: 0.494 }, // green + }, +}; + +export const DEFAULT_THEME = 'default'; + +const COLOR_ALIASES: Record = { + primary: 'primary', + primary_text: 'primaryText', + secondary: 'secondary', + secondary_text: 'secondaryText', + surface: 'surface', + surface_alt: 'surfaceAlt', + text: 'text', + text_muted: 'textMuted', + background: 'background', + accent1: 'accent1', + accent2: 'accent2', + accent3: 'accent3', + accent4: 'accent4', + // Convenience semantic aliases for the accent palette. + blue: 'accent1', + red: 'accent2', + yellow: 'accent3', + green: 'accent4', +}; + +/** + * Resolve a color value: pass RGB objects through, resolve string aliases via + * the active theme. Returns undefined for an unknown alias. + */ +export function resolveColor( + color: ColorValue | undefined, + theme: Theme, +): RGB | undefined { + if (!color) return undefined; + if (typeof color !== 'string') return color as RGB; + const key = COLOR_ALIASES[color.toLowerCase()]; + return key ? (theme[key] as RGB) : undefined; +} + export const PREDEFINED_LAYOUTS = [ 'BLANK', 'TITLE', @@ -1274,4 +1374,549 @@ export class SlidesService { }; } }; + + /** + * Raw passthrough to presentations.batchUpdate. Escape hatch for arbitrary or + * complex edits to an existing deck that the granular tools don't cover. + * Accepts either a parsed array of requests or a JSON string of that array. + */ + public batchUpdate = async ({ + presentationId, + requests: rawRequests, + }: { + presentationId: string; + requests: string | slides_v1.Schema$Request[]; + }) => { + try { + const requests: slides_v1.Schema$Request[] = + typeof rawRequests === 'string' ? JSON.parse(rawRequests) : rawRequests; + const id = extractDocId(presentationId) || presentationId; + logToFile( + `[SlidesService] Starting batchUpdate for presentation: ${id} (${requests.length} requests)`, + ); + const slides = await this.getSlidesClient(); + const response = await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests }, + }); + logToFile(`[SlidesService] Finished batchUpdate for presentation: ${id}`); + return this.formatResult(response.data); + } catch (error) { + return this.formatError('slides.batchUpdate', error); + } + }; + + /** + * Translate a list of blueprint elements for one slide into Slides API + * requests. Placeholder image URLs are swapped for a fallback icon and the + * substitution is recorded in `warnings`. + */ + private buildSlideRequests( + slideId: string, + elements: Array<{ + type: string; + content?: string; + shape_type?: string; + url?: string; + layer?: number; + position: { x: number; y: number; w: number; h: number }; + style?: { + size?: number; + bold?: boolean; + italic?: boolean; + align?: string; + vertical_align?: string; + color?: ColorValue; + bg_color?: ColorValue; + border_color?: ColorValue; + border_weight?: number; + no_border?: boolean; + font_family?: string; + underline?: boolean; + strikethrough?: boolean; + indent?: number; + bold_phrases?: string[]; + bold_until?: number; + links?: Array<{ text: string; url: string }>; + }; + }>, + objCounter: { value: number }, + theme: Theme, + slideIndex: number, + warnings: Array<{ + slideIndex: number; + elementIndex: number; + issue: string; + }>, + ): slides_v1.Schema$Request[] { + const requests: slides_v1.Schema$Request[] = []; + + const getId = (prefix: string) => { + objCounter.value += 1; + return `${prefix}_${Date.now()}_${objCounter.value}`; + }; + + // Render order is driven by `layer` (lower renders first); element type is + // only the tiebreaker within a layer (shape < image < text). This keeps + // backgrounds behind text without manual sequencing. + const sortOrder = (el: { type: string; layer?: number }) => { + const layerVal = el.layer ?? 1; + const typeMap: Record = { shape: 0, image: 1, text: 2 }; + const typeVal = typeMap[el.type] ?? 3; + return layerVal * 10 + typeVal; + }; + + // Keep original indices so warnings point at the caller's blueprint. + const sorted = elements + .map((el, elementIndex) => ({ el, elementIndex })) + .sort((a, b) => sortOrder(a.el) - sortOrder(b.el)); + + for (const { el, elementIndex } of sorted) { + const pos = el.position; + const style = el.style || {}; + + if (el.type === 'shape') { + const objId = getId('sh'); + + requests.push({ + createShape: { + objectId: objId, + shapeType: (el.shape_type as string) || 'RECTANGLE', + elementProperties: { + pageObjectId: slideId, + size: { + height: { magnitude: pos.h, unit: 'PT' }, + width: { magnitude: pos.w, unit: 'PT' }, + }, + transform: { + scaleX: 1, + scaleY: 1, + translateX: pos.x, + translateY: pos.y, + unit: 'PT', + }, + }, + }, + }); + + const props: slides_v1.Schema$ShapeProperties = {}; + const fields: string[] = []; + + const bgColor = resolveColor(style.bg_color, theme); + if (bgColor) { + props.shapeBackgroundFill = { + solidFill: { color: { rgbColor: bgColor } }, + }; + fields.push('shapeBackgroundFill.solidFill.color'); + } + + const borderColor = resolveColor(style.border_color, theme); + if (borderColor) { + props.outline = { + outlineFill: { solidFill: { color: { rgbColor: borderColor } } }, + weight: { magnitude: style.border_weight ?? 1, unit: 'PT' }, + }; + fields.push('outline.outlineFill.solidFill.color', 'outline.weight'); + } else if (style.no_border) { + props.outline = { propertyState: 'NOT_RENDERED' }; + fields.push('outline.propertyState'); + } + + if (style.vertical_align) { + props.contentAlignment = + style.vertical_align as slides_v1.Schema$ShapeProperties['contentAlignment']; + fields.push('contentAlignment'); + } + + if (fields.length > 0) { + requests.push({ + updateShapeProperties: { + objectId: objId, + shapeProperties: props, + fields: fields.join(','), + }, + }); + } + } else if (el.type === 'image') { + const objId = getId('img'); + // Sanitize URLs that still contain unresolved template placeholders + // (common in raw LLM output) and surface the substitution to the caller. + let imageUrl = el.url ?? ''; + if (imageUrl.includes('{') || imageUrl.includes('%7B')) { + warnings.push({ + slideIndex, + elementIndex, + issue: `unresolved url placeholder, substituted fallback icon (original: ${imageUrl})`, + }); + imageUrl = 'https://img.icons8.com/m_rounded/512/4285F4/info.png'; + } + + requests.push({ + createImage: { + objectId: objId, + url: imageUrl, + elementProperties: { + pageObjectId: slideId, + size: { + height: { magnitude: pos.h, unit: 'PT' }, + width: { magnitude: pos.w, unit: 'PT' }, + }, + transform: { + scaleX: 1, + scaleY: 1, + translateX: pos.x, + translateY: pos.y, + unit: 'PT', + }, + }, + }, + }); + } else if (el.type === 'text') { + const objId = getId('tx'); + const content = el.content || ''; + + requests.push({ + createShape: { + objectId: objId, + shapeType: 'TEXT_BOX', + elementProperties: { + pageObjectId: slideId, + size: { + height: { magnitude: pos.h, unit: 'PT' }, + width: { magnitude: pos.w, unit: 'PT' }, + }, + transform: { + scaleX: 1, + scaleY: 1, + translateX: pos.x, + translateY: pos.y, + unit: 'PT', + }, + }, + }, + }); + + requests.push({ insertText: { objectId: objId, text: content } }); + + // Base text style + requests.push({ + updateTextStyle: { + objectId: objId, + style: { + fontSize: { magnitude: style.size ?? 11, unit: 'PT' }, + bold: style.bold ?? false, + italic: style.italic ?? false, + foregroundColor: { + opaqueColor: { + rgbColor: resolveColor(style.color, theme) ?? theme.text, + }, + }, + fontFamily: + style.font_family === 'theme' + ? theme.fontFamily + : (style.font_family ?? theme.fontFamily), + underline: style.underline ?? false, + strikethrough: style.strikethrough ?? false, + }, + fields: + 'fontSize,bold,italic,underline,strikethrough,foregroundColor,fontFamily', + }, + }); + + // Paragraph style + requests.push({ + updateParagraphStyle: { + objectId: objId, + style: { + alignment: style.align ?? 'START', + ...(style.indent !== undefined && { + indentStart: { magnitude: style.indent, unit: 'PT' }, + }), + }, + fields: + style.indent !== undefined + ? 'alignment,indentStart' + : 'alignment', + }, + }); + + // Vertical alignment + if (style.vertical_align) { + requests.push({ + updateShapeProperties: { + objectId: objId, + shapeProperties: { + contentAlignment: + style.vertical_align as slides_v1.Schema$ShapeProperties['contentAlignment'], + }, + fields: 'contentAlignment', + }, + }); + } + + // Bold specific phrases (every occurrence). Skip empties — an empty + // phrase makes indexOf return a zero-length match for every position, + // emitting invalid startIndex===endIndex requests the API rejects. + if (style.bold_phrases) { + for (const phrase of style.bold_phrases) { + if (!phrase) continue; + let searchFrom = 0; + for (;;) { + const idx = content.indexOf(phrase, searchFrom); + if (idx === -1) break; + requests.push({ + updateTextStyle: { + objectId: objId, + style: { bold: true }, + textRange: { + type: 'FIXED_RANGE', + startIndex: idx, + endIndex: idx + phrase.length, + }, + fields: 'bold', + }, + }); + searchFrom = idx + phrase.length; + } + } + } + + // Bold a leading character range (e.g. an inline lead-in). + if (style.bold_until) { + requests.push({ + updateTextStyle: { + objectId: objId, + style: { bold: true }, + textRange: { + type: 'FIXED_RANGE', + startIndex: 0, + endIndex: style.bold_until, + }, + fields: 'bold', + }, + }); + } + + // Hyperlinks on specific phrases (every occurrence). Skip empties for + // the same zero-length-match reason as bold_phrases. + if (style.links) { + for (const linkDef of style.links) { + if (!linkDef.text) continue; + let searchFrom = 0; + for (;;) { + const idx = content.indexOf(linkDef.text, searchFrom); + if (idx === -1) break; + requests.push({ + updateTextStyle: { + objectId: objId, + style: { link: { url: linkDef.url } }, + textRange: { + type: 'FIXED_RANGE', + startIndex: idx, + endIndex: idx + linkDef.text.length, + }, + fields: 'link', + }, + }); + searchFrom = idx + linkDef.text.length; + } + } + } + } + } + + return requests; + } + + /** + * Build one or more slides from a JSON blueprint and append them to an + * existing presentation. Speaker notes in the blueprint are written inline. + * + * When `isNewPresentation` is true, the default blank slide that Google + * creates with a brand-new presentation is removed after the blueprint slides + * are added. It is left untouched otherwise so appending to an existing deck + * never deletes the caller's content. + */ + public createFromJson = async ({ + presentationId, + slideJson: rawSlideJson, + theme: themeName, + isNewPresentation = false, + }: { + presentationId: string; + slideJson: string | Record; + theme?: string; + isNewPresentation?: boolean; + }) => { + try { + const id = extractDocId(presentationId) || presentationId; + const slideJson: Record = + typeof rawSlideJson === 'string' + ? JSON.parse(rawSlideJson) + : rawSlideJson; + + const theme: Theme = + THEMES[(themeName ?? DEFAULT_THEME).toLowerCase()] ?? + THEMES[DEFAULT_THEME]; + + // Accept either slides[] or a single top-level elements[]. Guard against + // a slide object that omits `elements` so buildSlideRequests never spreads + // undefined. + const slideDefs: Array<{ elements: unknown[]; speaker_notes?: string }> = + Array.isArray((slideJson as any).slides) + ? (slideJson as any).slides.map((s: any) => ({ + ...s, + elements: s.elements || [], + })) + : [{ elements: (slideJson as any).elements || [] }]; + + logToFile( + `[SlidesService] Starting createFromJson for presentation: ${id} (${slideDefs.length} slides)`, + ); + + const requests: slides_v1.Schema$Request[] = []; + const slideIds: string[] = []; + const objCounter = { value: 0 }; + const warnings: Array<{ + slideIndex: number; + elementIndex: number; + issue: string; + }> = []; + + for (let i = 0; i < slideDefs.length; i++) { + const slideId = `slide_${Date.now()}_${i}`; + slideIds.push(slideId); + + // No insertionIndex — append to the end. A fixed index would reverse + // order when createFromJson is called once per slide. + requests.push({ + createSlide: { + objectId: slideId, + slideLayoutReference: { predefinedLayout: 'BLANK' }, + }, + }); + + requests.push( + ...this.buildSlideRequests( + slideId, + slideDefs[i].elements as any, + objCounter, + theme, + i, + warnings, + ), + ); + } + + const slides = await this.getSlidesClient(); + const response = await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests }, + }); + + // Write speaker notes for any slide that supplied them. + const notesSlides = slideDefs + .map((def, i) => ({ notes: def.speaker_notes, slideId: slideIds[i] })) + .filter((s) => s.notes); + + if (notesSlides.length > 0) { + logToFile( + `[SlidesService] Writing speaker notes for ${notesSlides.length} slides`, + ); + const pres = await slides.presentations.get({ + presentationId: id, + fields: + 'slides(objectId,slideProperties(notesPage(notesProperties(speakerNotesObjectId),pageElements(objectId,shape(text)))))', + }); + + const noteRequests: slides_v1.Schema$Request[] = []; + for (const { notes, slideId } of notesSlides) { + const slide = pres.data.slides?.find((s) => s.objectId === slideId); + const notesObjId = + slide?.slideProperties?.notesPage?.notesProperties + ?.speakerNotesObjectId; + if (!notesObjId) continue; + + const notesShape = + slide?.slideProperties?.notesPage?.pageElements?.find( + (el) => el.objectId === notesObjId, + ); + if (notesShape?.shape?.text?.textElements?.length) { + noteRequests.push({ + deleteText: { objectId: notesObjId, textRange: { type: 'ALL' } }, + }); + } + noteRequests.push({ + insertText: { + objectId: notesObjId, + insertionIndex: 0, + text: notes, + }, + }); + } + + if (noteRequests.length > 0) { + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests: noteRequests }, + }); + logToFile( + `[SlidesService] Wrote speaker notes for ${notesSlides.length} slides`, + ); + } + } + + // Remove the default blank slide ("p") only for a brand-new presentation, + // and only after confirming it actually exists — never silently delete a + // real slide when appending to an existing deck. + if (isNewPresentation) { + try { + const pres = await slides.presentations.get({ + presentationId: id, + fields: 'slides(objectId)', + }); + const hasDefault = pres.data.slides?.some((s) => s.objectId === 'p'); + if (hasDefault) { + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests: [{ deleteObject: { objectId: 'p' } }] }, + }); + logToFile('[SlidesService] Deleted default blank slide "p"'); + } + } catch (delError) { + logToFile( + `[SlidesService] Could not remove default slide "p": ${ + delError instanceof Error ? delError.message : String(delError) + }`, + ); + } + } + + const presLink = `https://docs.google.com/presentation/d/${id}/edit`; + logToFile( + `[SlidesService] Finished createFromJson for presentation: ${id}, ${slideIds.length} slides created`, + ); + + const hasNotes = notesSlides.length > 0; + const result: Record = { + slideIds, + presentationLink: presLink, + slidesCreated: slideIds.length, + repliesCount: response.data.replies?.length ?? 0, + }; + if (warnings.length > 0) result.warnings = warnings; + + if (!hasNotes && slideIds.length > 0) { + result.speakerNotesStatus = 'MISSING'; + result.action_required = + 'No speaker notes were provided. Call slides.updateSpeakerNotes for each slideId above to add a talk track. Speaker notes are strongly recommended on every slide.'; + } else if (hasNotes) { + result.speakerNotesStatus = 'WRITTEN'; + } + + return this.formatResult(result); + } catch (error) { + return this.formatError('slides.createFromJson', error); + } + }; } From 88569496bc3e8c7c88bc0d038db3d57b9b82c3c8 Mon Sep 17 00:00:00 2001 From: Nick Losier Date: Thu, 25 Jun 2026 23:08:18 -0600 Subject: [PATCH 2/6] =?UTF-8?q?feat(slides):=20add=20slides.setText=20?= =?UTF-8?q?=E2=80=94=20safe=20in-place=20text=20replace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High-level editor for an existing shape: clears it, inserts new text, and applies each style attribute with an explicit fields mask so text never inherits stray styling. Reuses the createFromJson style vocabulary (size, bold/italic/underline/strikethrough, color aliases or RGB, font_family, align, indent, bold_phrases, bold_until, links) and finds shapes nested in groups. Empty bold_phrases/links are skipped (no zero-length ranges). Complements createFromJson for editing existing decks. --- .../__tests__/services/SlidesService.test.ts | 130 ++++++++++ .../src/features/feature-config.ts | 1 + workspace-server/src/index.ts | 74 ++++++ .../src/services/SlidesService.ts | 240 ++++++++++++++++++ 4 files changed, 445 insertions(+) diff --git a/workspace-server/src/__tests__/services/SlidesService.test.ts b/workspace-server/src/__tests__/services/SlidesService.test.ts index 3fced0e..6e389a9 100644 --- a/workspace-server/src/__tests__/services/SlidesService.test.ts +++ b/workspace-server/src/__tests__/services/SlidesService.test.ts @@ -1987,6 +1987,136 @@ describe('SlidesService', () => { expect(response.error).toBe('Create From Json Error'); }); }); + + describe('setText', () => { + beforeEach(() => { + mockSlidesAPI.presentations.batchUpdate = jest.fn(); + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ data: {} }); + }); + + it('clears existing text, inserts new text, and applies explicit style', async () => { + mockSlidesAPI.presentations.get.mockResolvedValue({ + data: { + slides: [ + { + objectId: 'slide1', + pageElements: [ + { + objectId: 'tx_3', + shape: { + text: { textElements: [{ textRun: { content: 'old' } }] }, + }, + }, + ], + }, + ], + }, + }); + + await slidesService.setText({ + presentationId: 'pres-1', + objectId: 'tx_3', + text: 'Q3 Revenue', + style: { size: 24, bold: true, color: 'primary', align: 'CENTER' }, + }); + + const call = mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0]; + const reqs = call.requestBody.requests; + expect(reqs[0]).toEqual({ + deleteText: { objectId: 'tx_3', textRange: { type: 'ALL' } }, + }); + expect(reqs[1]).toEqual({ + insertText: { objectId: 'tx_3', insertionIndex: 0, text: 'Q3 Revenue' }, + }); + const textStyleReq = reqs.find((r: any) => r.updateTextStyle); + expect(textStyleReq.updateTextStyle.fields).toBe( + 'fontSize,bold,foregroundColor', + ); + expect(textStyleReq.updateTextStyle.textRange).toEqual({ type: 'ALL' }); + expect(textStyleReq.updateTextStyle.style.foregroundColor).toBeDefined(); + const paraReq = reqs.find((r: any) => r.updateParagraphStyle); + expect(paraReq.updateParagraphStyle.fields).toBe('alignment'); + expect(paraReq.updateParagraphStyle.style.alignment).toBe('CENTER'); + }); + + it('skips deleteText when the shape is empty', async () => { + mockSlidesAPI.presentations.get.mockResolvedValue({ + data: { + slides: [ + { + objectId: 'slide1', + pageElements: [{ objectId: 'tx_3', shape: {} }], + }, + ], + }, + }); + + await slidesService.setText({ + presentationId: 'pres-1', + objectId: 'tx_3', + text: 'Hello', + }); + + const reqs = + mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody + .requests; + expect(reqs.some((r: any) => r.deleteText)).toBe(false); + expect(reqs[0]).toEqual({ + insertText: { objectId: 'tx_3', insertionIndex: 0, text: 'Hello' }, + }); + }); + + it('finds shapes nested inside groups', async () => { + mockSlidesAPI.presentations.get.mockResolvedValue({ + data: { + slides: [ + { + objectId: 'slide1', + pageElements: [ + { + objectId: 'group1', + elementGroup: { + children: [ + { + objectId: 'tx_nested', + shape: { text: { textElements: [] } }, + }, + ], + }, + }, + ], + }, + ], + }, + }); + + const result = await slidesService.setText({ + presentationId: 'pres-1', + objectId: 'tx_nested', + text: 'Nested', + }); + + expect(JSON.parse(result.content[0].text).objectId).toBe('tx_nested'); + expect(mockSlidesAPI.presentations.batchUpdate).toHaveBeenCalled(); + }); + + it('errors clearly when the shape is not found', async () => { + mockSlidesAPI.presentations.get.mockResolvedValue({ + data: { slides: [{ objectId: 'slide1', pageElements: [] }] }, + }); + + const result = await slidesService.setText({ + presentationId: 'pres-1', + objectId: 'missing', + text: 'x', + }); + + expect(JSON.parse(result.content[0].text).error).toContain( + 'Shape not found: missing', + ); + expect(mockSlidesAPI.presentations.batchUpdate).not.toHaveBeenCalled(); + }); + }); }); describe('resolveColor + THEMES', () => { diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index 81064ac..87067e6 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -231,6 +231,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'slides.updateShapeProperties', 'slides.batchUpdate', 'slides.createFromJson', + 'slides.setText', ], defaultEnabled: false, }, diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index d4d9276..f520c44 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -1027,6 +1027,80 @@ async function main() { slidesService.createFromJson, ); + registerTool( + 'slides.setText', + { + description: + 'Replaces the text of an existing shape/text box in place and applies explicit styling. Use this instead of a raw slides.batchUpdate insertText: it clears the shape, inserts the new text, and sets each style attribute you pass with an explicit fields mask, so the text never inherits stray leftover styling. Only the style fields you provide are changed — omit `style` to keep the shape\'s current look and just swap the words. Get objectId from slides.getMetadata or slides.getText. Color accepts the same aliases as createFromJson ("primary", "text", "blue", ...) or an RGB 0-1 object; font_family:"theme" uses the active theme font.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + objectId: z + .string() + .describe( + 'The object ID of the shape/text box to update (from slides.getMetadata or slides.getText). Its existing text is replaced.', + ), + text: z.string().describe('The new text content for the shape.'), + style: z + .object({ + size: z.number().optional().describe('Font size in points.'), + bold: z.boolean().optional().describe('Bold text.'), + italic: z.boolean().optional().describe('Italic text.'), + underline: z.boolean().optional().describe('Underline text.'), + strikethrough: z + .boolean() + .optional() + .describe('Strikethrough text.'), + align: z + .enum(['START', 'CENTER', 'END']) + .optional() + .describe('Horizontal text alignment.'), + indent: z + .number() + .optional() + .describe('Left indent of paragraph text in points.'), + color: slidesColorSchema + .optional() + .describe( + 'Text color: an alias ("primary", "text", "blue", ...) or an RGB 0-1 object.', + ), + font_family: z + .string() + .optional() + .describe( + 'Font family name, or "theme" for the active theme font.', + ), + bold_phrases: z + .array(z.string().min(1)) + .optional() + .describe('Phrases within the text to bold.'), + bold_until: z + .number() + .optional() + .describe('Bold text from start to this character index.'), + links: z + .array( + z.object({ + text: z + .string() + .min(1) + .describe('Link text to find in content.'), + url: z.string().describe('URL to link to.'), + }), + ) + .optional() + .describe('Hyperlinks to apply to matching text.'), + }) + .optional() + .describe( + "Optional styling. Only the fields you set are changed, each applied explicitly so text never inherits stray styling. Omit to keep the shape's existing style.", + ), + }, + }, + slidesService.setText, + ); + // Sheets tools registerTool( 'sheets.getText', diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index 44c3d6e..3526b6f 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -1919,4 +1919,244 @@ export class SlidesService { return this.formatError('slides.createFromJson', error); } }; + + /** Find a page element by id, descending into element groups. */ + private _findPageElement( + pages: slides_v1.Schema$Page[], + objectId: string, + ): slides_v1.Schema$PageElement | undefined { + const search = ( + elements: slides_v1.Schema$PageElement[] | undefined, + ): slides_v1.Schema$PageElement | undefined => { + for (const el of elements ?? []) { + if (el.objectId === objectId) return el; + const found = search(el.elementGroup?.children); + if (found) return found; + } + return undefined; + }; + for (const page of pages) { + const found = search(page.pageElements); + if (found) return found; + } + return undefined; + } + + /** + * Replace a shape's text in place and apply explicit styling. The safe analog + * of a raw insertText: it clears the shape, inserts the new text, and sets each + * style attribute with an explicit fields mask so text never inherits stray + * styling. Only the style fields provided are changed. + */ + public setText = async ({ + presentationId, + objectId, + text, + style, + }: { + presentationId: string; + objectId: string; + text: string; + style?: { + size?: number; + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + align?: string; + indent?: number; + color?: ColorValue; + font_family?: string; + bold_phrases?: string[]; + bold_until?: number; + links?: Array<{ text: string; url: string }>; + }; + }) => { + logToFile( + `[SlidesService] setText for presentation ${presentationId}, shape ${objectId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + // Locate the shape so we know whether it already has text to clear, and + // to give a clear error if the objectId is wrong. + const pres = await slides.presentations.get({ + presentationId: id, + fields: 'slides(objectId,pageElements)', + }); + const target = this._findPageElement(pres.data.slides ?? [], objectId); + if (!target) { + throw new Error( + `Shape not found: ${objectId}. Use slides.getMetadata to list element IDs.`, + ); + } + if (!target.shape) { + throw new Error( + `Object ${objectId} is not a text-capable shape (it has no shape/text).`, + ); + } + const hasText = !!target.shape.text?.textElements?.length; + + const theme: Theme = THEMES[DEFAULT_THEME]; + const requests: slides_v1.Schema$Request[] = []; + + // Clear existing text first (deleteText errors on an empty shape). + if (hasText) { + requests.push({ + deleteText: { objectId, textRange: { type: 'ALL' } }, + }); + } + + requests.push({ insertText: { objectId, insertionIndex: 0, text } }); + + if (style) { + // Base character style — only the fields the caller set, each with an + // explicit fields mask so nothing is left to inheritance. + const textStyle: slides_v1.Schema$TextStyle = {}; + const fields: string[] = []; + if (style.size !== undefined) { + textStyle.fontSize = { magnitude: style.size, unit: 'PT' }; + fields.push('fontSize'); + } + if (style.bold !== undefined) { + textStyle.bold = style.bold; + fields.push('bold'); + } + if (style.italic !== undefined) { + textStyle.italic = style.italic; + fields.push('italic'); + } + if (style.underline !== undefined) { + textStyle.underline = style.underline; + fields.push('underline'); + } + if (style.strikethrough !== undefined) { + textStyle.strikethrough = style.strikethrough; + fields.push('strikethrough'); + } + if (style.color !== undefined) { + const rgb = resolveColor(style.color, theme); + if (rgb) { + textStyle.foregroundColor = { opaqueColor: { rgbColor: rgb } }; + fields.push('foregroundColor'); + } + } + if (style.font_family !== undefined) { + textStyle.fontFamily = + style.font_family === 'theme' + ? theme.fontFamily + : style.font_family; + fields.push('fontFamily'); + } + if (fields.length) { + requests.push({ + updateTextStyle: { + objectId, + style: textStyle, + textRange: { type: 'ALL' }, + fields: fields.join(','), + }, + }); + } + + // Paragraph style + const paraStyle: slides_v1.Schema$ParagraphStyle = {}; + const paraFields: string[] = []; + if (style.align !== undefined) { + paraStyle.alignment = + style.align as slides_v1.Schema$ParagraphStyle['alignment']; + paraFields.push('alignment'); + } + if (style.indent !== undefined) { + paraStyle.indentStart = { magnitude: style.indent, unit: 'PT' }; + paraFields.push('indentStart'); + } + if (paraFields.length) { + requests.push({ + updateParagraphStyle: { + objectId, + style: paraStyle, + textRange: { type: 'ALL' }, + fields: paraFields.join(','), + }, + }); + } + + // Ranged emphasis within the new text. Skip empty phrases — an empty + // search string yields zero-length matches the API rejects. + for (const phrase of style.bold_phrases ?? []) { + if (!phrase) continue; + let from = 0; + let idx: number; + while ((idx = text.indexOf(phrase, from)) !== -1) { + requests.push({ + updateTextStyle: { + objectId, + style: { bold: true }, + textRange: { + type: 'FIXED_RANGE', + startIndex: idx, + endIndex: idx + phrase.length, + }, + fields: 'bold', + }, + }); + from = idx + phrase.length; + } + } + if (style.bold_until) { + requests.push({ + updateTextStyle: { + objectId, + style: { bold: true }, + textRange: { + type: 'FIXED_RANGE', + startIndex: 0, + endIndex: style.bold_until, + }, + fields: 'bold', + }, + }); + } + for (const linkDef of style.links ?? []) { + if (!linkDef.text) continue; + let from = 0; + let idx: number; + while ((idx = text.indexOf(linkDef.text, from)) !== -1) { + requests.push({ + updateTextStyle: { + objectId, + style: { link: { url: linkDef.url } }, + textRange: { + type: 'FIXED_RANGE', + startIndex: idx, + endIndex: idx + linkDef.text.length, + }, + fields: 'link', + }, + }); + from = idx + linkDef.text.length; + } + } + } + + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests }, + }); + + logToFile( + `[SlidesService] setText applied ${requests.length} request(s) to ${objectId}`, + ); + return this.formatResult({ + presentationId: id, + objectId, + text, + applied: requests.length, + }); + } catch (error) { + return this.formatError('slides.setText', error); + } + }; } From 71ca4bab655f893dc49d927482f759c681c0d6b0 Mon Sep 17 00:00:00 2001 From: Nick Losier Date: Fri, 26 Jun 2026 01:14:12 -0600 Subject: [PATCH 3/6] feat(slides): harden createFromJson against malformed elements An element missing a valid position {x,y,w,h} previously threw a cryptic "Cannot read properties of undefined (reading 'h')" mid-batch, failing the whole deck. Now such elements are skipped and reported under result.warnings (consistent with the placeholder-image-URL handling), so messy LLM-generated blueprints degrade gracefully instead of crashing. Adds a unit test. --- .../__tests__/services/SlidesService.test.ts | 37 +++++++++++++++++++ .../src/services/SlidesService.ts | 17 +++++++++ 2 files changed, 54 insertions(+) diff --git a/workspace-server/src/__tests__/services/SlidesService.test.ts b/workspace-server/src/__tests__/services/SlidesService.test.ts index 6e389a9..65fbdb4 100644 --- a/workspace-server/src/__tests__/services/SlidesService.test.ts +++ b/workspace-server/src/__tests__/services/SlidesService.test.ts @@ -1938,6 +1938,43 @@ describe('SlidesService', () => { expect(image.createImage.url).not.toContain('{'); }); + it('should skip (not crash on) an element missing a valid position', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.createFromJson({ + presentationId: 'pres-1', + slideJson: { + slides: [ + { + elements: [ + { + type: 'text', + content: 'ok', + position: { x: 0, y: 0, w: 100, h: 20 }, + }, + { type: 'text', content: 'bad — no position' } as any, + { type: 'shape', position: { x: 0, y: 0 } } as any, + ], + }, + ], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(result.isError).toBeUndefined(); + expect(response.slidesCreated).toBe(1); + // the two malformed elements are reported, the good one still renders + expect(response.warnings).toHaveLength(2); + const kinds = + mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody.requests.map( + (r: any) => Object.keys(r)[0], + ); + expect(kinds).toContain('createSlide'); + expect(kinds.filter((k: string) => k === 'createShape').length).toBe(1); + }); + it('should delete the default slide only when isNewPresentation and "p" exists', async () => { mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ data: { replies: [{}] }, diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index 3526b6f..34120f9 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -1473,6 +1473,23 @@ export class SlidesService { for (const { el, elementIndex } of sorted) { const pos = el.position; + // Guard malformed elements (common in raw LLM output): a missing or + // incomplete position would otherwise throw a cryptic "reading 'h'" mid-batch. + // Skip the element and record a warning instead of failing the whole deck. + if ( + !pos || + typeof pos.x !== 'number' || + typeof pos.y !== 'number' || + typeof pos.w !== 'number' || + typeof pos.h !== 'number' + ) { + warnings.push({ + slideIndex, + elementIndex, + issue: `element missing a valid position {x,y,w,h}, skipped (type: ${el.type})`, + }); + continue; + } const style = el.style || {}; if (el.type === 'shape') { From 1244b6b91199ae92fadf21c094b871bbd9d75e00 Mon Sep 17 00:00:00 2001 From: Nick Losier Date: Fri, 26 Jun 2026 02:25:23 -0600 Subject: [PATCH 4/6] feat(slides): skip empty-content text elements in createFromJson A text element with empty/whitespace content produced an empty text box and then an updateTextStyle call that the API rejects with "object has no text", aborting the whole batch. Now such elements are skipped and reported under result.warnings (matching the position/placeholder guards). Adds a unit test. --- .../__tests__/services/SlidesService.test.ts | 40 +++++++++++++++++++ .../src/services/SlidesService.ts | 13 +++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/workspace-server/src/__tests__/services/SlidesService.test.ts b/workspace-server/src/__tests__/services/SlidesService.test.ts index 65fbdb4..1a3ebed 100644 --- a/workspace-server/src/__tests__/services/SlidesService.test.ts +++ b/workspace-server/src/__tests__/services/SlidesService.test.ts @@ -1975,6 +1975,46 @@ describe('SlidesService', () => { expect(kinds.filter((k: string) => k === 'createShape').length).toBe(1); }); + it('should skip (not crash on) a text element with empty content', async () => { + mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ + data: { replies: [{}] }, + }); + + const result = await slidesService.createFromJson({ + presentationId: 'pres-1', + slideJson: { + slides: [ + { + elements: [ + { + type: 'text', + content: 'real', + position: { x: 0, y: 0, w: 100, h: 20 }, + }, + { + type: 'text', + content: ' ', + position: { x: 0, y: 30, w: 100, h: 20 }, + }, + ], + }, + ], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(result.isError).toBeUndefined(); + expect(response.warnings).toHaveLength(1); + expect(response.warnings[0].issue).toContain('empty content'); + // exactly one text box created (the empty one is skipped, so no + // insertText/updateTextStyle "object has no text" failure) + const kinds = + mockSlidesAPI.presentations.batchUpdate.mock.calls[0][0].requestBody.requests.map( + (r: any) => Object.keys(r)[0], + ); + expect(kinds.filter((k: string) => k === 'createShape').length).toBe(1); + }); + it('should delete the default slide only when isNewPresentation and "p" exists', async () => { mockSlidesAPI.presentations.batchUpdate.mockResolvedValue({ data: { replies: [{}] }, diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index 34120f9..fcc76a5 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -1589,8 +1589,19 @@ export class SlidesService { }, }); } else if (el.type === 'text') { - const objId = getId('tx'); const content = el.content || ''; + // Skip empty text elements: an empty text box has no text, so the + // follow-up updateTextStyle would fail ("object has no text") and abort + // the batch. Warn and move on (common in raw LLM output). + if (!content.trim()) { + warnings.push({ + slideIndex, + elementIndex, + issue: 'text element has empty content, skipped', + }); + continue; + } + const objId = getId('tx'); requests.push({ createShape: { From 5cb49dc24e39ffb8884e87edf0a0398f1719942b Mon Sep 17 00:00:00 2001 From: Nick Losier Date: Fri, 26 Jun 2026 02:43:45 -0600 Subject: [PATCH 5/6] feat(slides): single neutral palette for createFromJson (drop dark theme + theme param) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server stays deliberately unbranded: createFromJson resolves color aliases against one neutral built-in palette. Removed the `dark` theme and the `theme` selector param — branding (fonts, brand colors) belongs to the caller/skill, which applies it via explicit font_family/colors (or RGB objects for one-offs). Simplifies the API and addresses the "don't ship a Google theme in a public extension" review note. Tool description + test updated. --- .../__tests__/services/SlidesService.test.ts | 4 +-- workspace-server/src/index.ts | 6 +--- .../src/services/SlidesService.ts | 29 ++++--------------- 3 files changed, 8 insertions(+), 31 deletions(-) diff --git a/workspace-server/src/__tests__/services/SlidesService.test.ts b/workspace-server/src/__tests__/services/SlidesService.test.ts index 1a3ebed..9f4566b 100644 --- a/workspace-server/src/__tests__/services/SlidesService.test.ts +++ b/workspace-server/src/__tests__/services/SlidesService.test.ts @@ -2221,7 +2221,7 @@ describe('resolveColor + THEMES', () => { expect(resolveColor(undefined, THEMES.default)).toBeUndefined(); }); - it('ships exactly the default and dark themes', () => { - expect(Object.keys(THEMES).sort()).toEqual(['dark', 'default']); + it('ships only the neutral default theme (branding lives in the skill)', () => { + expect(Object.keys(THEMES)).toEqual(['default']); }); }); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index f520c44..c93ab52 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -995,7 +995,7 @@ async function main() { 'slides.createFromJson', { description: - 'Creates one or more slides from a JSON blueprint and appends them to a presentation. Speaker notes in the blueprint are written automatically.\n\nFORMATS: {"slides":[{"elements":[...],"speaker_notes":"..."},...]} for multiple slides, or {"elements":[...]} for a single slide.\n\nCANVAS: 720×405 pt (16:9), origin top-left.\n\nELEMENT TYPES: type ("text"|"shape"|"image"), position ({x,y,w,h} in points), optional content, shape_type, url (images), layer (z-index; lower renders first — backgrounds=0, boxes=1, text=2+).\n\nCOLORS: use a theme alias ("primary", "secondary", "surface", "surface_alt", "text", "text_muted", "background", or accent aliases "blue"/"red"/"yellow"/"green") OR an explicit RGB 0-1 object for one-off colors. Aliases resolve against the active theme.\n\nTHEMES: pass theme:"default" (light, the default) or theme:"dark". font_family:"theme" inherits the theme font.\n\nSPEAKER NOTES (strongly recommended): include "speaker_notes" on each slide and they are written automatically. If omitted, the response returns an action_required hint asking you to call slides.updateSpeakerNotes per slide.\n\nNEW DECKS: when you just created the presentation with slides.create, pass isNewPresentation:true to remove the default blank first slide. When appending to an existing deck, leave it false so nothing is deleted.\n\nSTYLE PROPERTIES: size, bold, italic, underline, strikethrough, align (START|CENTER|END), vertical_align (TOP|MIDDLE|BOTTOM), indent, color, bg_color, border_color, border_weight, no_border, font_family ("theme" to inherit), bold_phrases, bold_until, links ([{text,url}]). font_family defaults to the active theme font. Image URLs containing unresolved placeholders are replaced with a fallback icon and reported under "warnings".', + 'Creates one or more slides from a JSON blueprint and appends them to a presentation. Speaker notes in the blueprint are written automatically.\n\nFORMATS: {"slides":[{"elements":[...],"speaker_notes":"..."},...]} for multiple slides, or {"elements":[...]} for a single slide.\n\nCANVAS: 720×405 pt (16:9), origin top-left.\n\nELEMENT TYPES: type ("text"|"shape"|"image"), position ({x,y,w,h} in points), optional content, shape_type, url (images), layer (z-index; lower renders first — backgrounds=0, boxes=1, text=2+).\n\nCOLORS: use a built-in alias ("primary", "secondary", "surface", "surface_alt", "text", "text_muted", "background", or accent aliases "blue"/"red"/"yellow"/"green") OR an explicit RGB 0-1 object for one-off colors. Aliases resolve against a single neutral built-in palette; for a branded look set explicit font_family/colors. font_family:"theme" inherits the built-in font.\n\nSPEAKER NOTES (strongly recommended): include "speaker_notes" on each slide and they are written automatically. If omitted, the response returns an action_required hint asking you to call slides.updateSpeakerNotes per slide.\n\nNEW DECKS: when you just created the presentation with slides.create, pass isNewPresentation:true to remove the default blank first slide. When appending to an existing deck, leave it false so nothing is deleted.\n\nSTYLE PROPERTIES: size, bold, italic, underline, strikethrough, align (START|CENTER|END), vertical_align (TOP|MIDDLE|BOTTOM), indent, color, bg_color, border_color, border_weight, no_border, font_family ("theme" to inherit), bold_phrases, bold_until, links ([{text,url}]). font_family defaults to the active theme font. Image URLs containing unresolved placeholders are replaced with a fallback icon and reported under "warnings".', inputSchema: { presentationId: z .string() @@ -1012,10 +1012,6 @@ async function main() { .describe( 'The slide blueprint. Use {"slides":[{"elements":[...],"speaker_notes":"..."}]} for multiple slides or {"elements":[...]} for one. Accepts an object or a JSON string.', ), - theme: z - .enum(['default', 'dark']) - .optional() - .describe('Theme to apply. Defaults to "default" (light).'), isNewPresentation: z .boolean() .optional() diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index fcc76a5..3142bf7 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -37,9 +37,10 @@ interface Theme { } /** - * Built-in themes for createFromJson. Two ship today: a neutral light theme - * (`default`) and a dark theme (`dark`). Color aliases resolve against the - * active theme so blueprints stay theme-portable. + * Built-in palette for createFromJson — a single neutral theme. Color aliases + * resolve against it. The server stays deliberately unbranded; callers that want + * a branded look (e.g. Google Sans + brand colors) apply it themselves via + * explicit font_family / colors, or pass RGB objects for one-off colors. */ export const THEMES: Record = { default: { @@ -58,22 +59,6 @@ export const THEMES: Record = { accent3: { red: 0.984, green: 0.737, blue: 0.02 }, // yellow accent4: { red: 0.204, green: 0.659, blue: 0.325 }, // green }, - dark: { - primary: { red: 0.129, green: 0.588, blue: 0.953 }, // bright blue accent on dark - primaryText: { red: 1.0, green: 1.0, blue: 1.0 }, - secondary: { red: 0.611, green: 0.353, blue: 0.949 }, // purple accent - secondaryText: { red: 1.0, green: 1.0, blue: 1.0 }, - surface: { red: 0.157, green: 0.165, blue: 0.184 }, // #282A2F card - surfaceAlt: { red: 0.204, green: 0.212, blue: 0.235 }, // slightly lighter card - text: { red: 0.925, green: 0.933, blue: 0.945 }, // near-white body - textMuted: { red: 0.667, green: 0.678, blue: 0.698 }, // muted gray - background: { red: 0.075, green: 0.082, blue: 0.094 }, // #131517 slide bg - fontFamily: 'Arial', - accent1: { red: 0.4, green: 0.624, blue: 0.969 }, // blue - accent2: { red: 0.969, green: 0.451, blue: 0.408 }, // red - accent3: { red: 1.0, green: 0.831, blue: 0.31 }, // yellow - accent4: { red: 0.388, green: 0.776, blue: 0.494 }, // green - }, }; export const DEFAULT_THEME = 'default'; @@ -1768,12 +1753,10 @@ export class SlidesService { public createFromJson = async ({ presentationId, slideJson: rawSlideJson, - theme: themeName, isNewPresentation = false, }: { presentationId: string; slideJson: string | Record; - theme?: string; isNewPresentation?: boolean; }) => { try { @@ -1783,9 +1766,7 @@ export class SlidesService { ? JSON.parse(rawSlideJson) : rawSlideJson; - const theme: Theme = - THEMES[(themeName ?? DEFAULT_THEME).toLowerCase()] ?? - THEMES[DEFAULT_THEME]; + const theme: Theme = THEMES[DEFAULT_THEME]; // Accept either slides[] or a single top-level elements[]. Guard against // a slide object that omits `elements` so buildSlideRequests never spreads From 7ba4616202bc52a393cda7dd14d5dec3109c39b9 Mon Sep 17 00:00:00 2001 From: Nick Losier Date: Wed, 1 Jul 2026 12:17:22 -0600 Subject: [PATCH 6/6] feat(docs): add docs.createFromMarkdown + docs.updateFromMarkdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown → Google Doc conversion via the Drive API (text/markdown source → application/vnd.google-apps.document destination). Drive's native import maps headings, bold/italic, links, tables, and nested lists far more cleanly than hand-built Docs API insert requests. - createFromMarkdown: create a new Doc from a markdown string (optional parent folder) - updateFromMarkdown: replace an existing Doc's content in place, keeping the same ID/link - New docs.markdown feature group (requires the drive scope), enabled by default - Unit tests: conversion mime types, parent handling, URL id extraction, error paths --- .../__tests__/services/DocsService.test.ts | 123 ++++++++++++++++++ .../src/features/feature-config.ts | 13 +- workspace-server/src/index.ts | 40 ++++++ workspace-server/src/services/DocsService.ts | 121 ++++++++++++++++- 4 files changed, 294 insertions(+), 3 deletions(-) diff --git a/workspace-server/src/__tests__/services/DocsService.test.ts b/workspace-server/src/__tests__/services/DocsService.test.ts index d3aa66e..d33e596 100644 --- a/workspace-server/src/__tests__/services/DocsService.test.ts +++ b/workspace-server/src/__tests__/services/DocsService.test.ts @@ -24,6 +24,7 @@ describe('DocsService', () => { let docsService: DocsService; let mockAuthManager: jest.Mocked; let mockDocsAPI: any; + let mockDriveAPI: any; beforeEach(() => { // Clear all mocks before each test @@ -43,8 +44,17 @@ describe('DocsService', () => { }, }; + // Create mock Drive API (used by the Markdown import tools) + mockDriveAPI = { + files: { + create: jest.fn(), + update: jest.fn(), + }, + }; + // Mock the google constructors (google.docs as jest.Mock) = jest.fn().mockReturnValue(mockDocsAPI); + (google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI); // Create DocsService instance docsService = new DocsService(mockAuthManager); @@ -59,6 +69,119 @@ describe('DocsService', () => { jest.restoreAllMocks(); }); + describe('createFromMarkdown', () => { + it('creates a Google Doc from markdown via Drive conversion', async () => { + mockDriveAPI.files.create.mockResolvedValue({ + data: { + id: 'new-doc-id', + name: 'My Doc', + webViewLink: 'https://docs.google.com/document/d/new-doc-id/edit', + }, + }); + + const result = await docsService.createFromMarkdown({ + markdown: '# Title\n\n- a\n- b', + name: 'My Doc', + parentId: 'folder-1', + }); + + expect(mockDriveAPI.files.create).toHaveBeenCalledTimes(1); + const arg = mockDriveAPI.files.create.mock.calls[0][0]; + // Destination mimeType must be a Google Doc to trigger conversion... + expect(arg.requestBody.mimeType).toBe( + 'application/vnd.google-apps.document', + ); + expect(arg.requestBody.name).toBe('My Doc'); + expect(arg.requestBody.parents).toEqual(['folder-1']); + // ...and the source media must be text/markdown. + expect(arg.media.mimeType).toBe('text/markdown'); + + const payload = JSON.parse(result.content[0].text); + expect(payload).toMatchObject({ + documentId: 'new-doc-id', + title: 'My Doc', + webViewLink: 'https://docs.google.com/document/d/new-doc-id/edit', + }); + expect(result.isError).toBeUndefined(); + }); + + it('omits parents when parentId is not provided', async () => { + mockDriveAPI.files.create.mockResolvedValue({ data: { id: 'x' } }); + + await docsService.createFromMarkdown({ markdown: '# Hi', name: 'Doc' }); + + const arg = mockDriveAPI.files.create.mock.calls[0][0]; + expect(arg.requestBody.parents).toBeUndefined(); + }); + + it('returns an error result when the Drive API fails', async () => { + mockDriveAPI.files.create.mockRejectedValue(new Error('boom')); + + const result = await docsService.createFromMarkdown({ + markdown: '# Hi', + name: 'Doc', + }); + + expect(result.isError).toBe(true); + expect(JSON.parse(result.content[0].text).error).toBe('boom'); + }); + }); + + describe('updateFromMarkdown', () => { + it('replaces an existing doc content in place, keeping the same id', async () => { + mockDriveAPI.files.update.mockResolvedValue({ + data: { + id: 'doc-123', + name: 'Doc', + webViewLink: 'https://docs.google.com/document/d/doc-123/edit', + modifiedTime: '2026-07-01T00:00:00Z', + }, + }); + + const result = await docsService.updateFromMarkdown({ + documentId: 'doc-123', + markdown: '# Updated', + }); + + expect(mockDriveAPI.files.update).toHaveBeenCalledTimes(1); + const arg = mockDriveAPI.files.update.mock.calls[0][0]; + expect(arg.fileId).toBe('doc-123'); + expect(arg.media.mimeType).toBe('text/markdown'); + + const payload = JSON.parse(result.content[0].text); + expect(payload).toMatchObject({ + documentId: 'doc-123', + modifiedTime: '2026-07-01T00:00:00Z', + }); + expect(result.isError).toBeUndefined(); + }); + + it('extracts the doc id from a full Docs URL', async () => { + mockDriveAPI.files.update.mockResolvedValue({ data: { id: 'doc-123' } }); + + await docsService.updateFromMarkdown({ + documentId: + 'https://docs.google.com/document/d/doc-123/edit?tab=t.0', + markdown: '# Updated', + }); + + const arg = mockDriveAPI.files.update.mock.calls[0][0]; + expect(arg.fileId).toBe('doc-123'); + }); + + it('returns an error result when the Drive API fails', async () => { + mockDriveAPI.files.update.mockRejectedValue(new Error('nope')); + + const result = await docsService.updateFromMarkdown({ + documentId: 'doc-123', + markdown: '# x', + }); + + expect(result.isError).toBe(true); + expect(JSON.parse(result.content[0].text).error).toBe('nope'); + }); + }); + describe('create', () => { it('should create a blank document', async () => { const mockDoc = { diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index 87067e6..df0140b 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -38,8 +38,8 @@ export type ServiceName = export interface FeatureGroup { /** Service name (e.g., 'docs', 'gmail') */ readonly service: ServiceName; - /** Group type: read (no side effects) or write (mutations) */ - readonly group: 'read' | 'write'; + /** Group type: read (no side effects), write (mutations), or a named capability group */ + readonly group: 'read' | 'write' | 'markdown'; /** OAuth scopes required by this feature group */ readonly scopes: readonly string[]; /** Tool names belonging to this feature group */ @@ -76,6 +76,15 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ ], defaultEnabled: true, }, + { + // Markdown import runs through the Drive API (files.create/update with a + // text/markdown body converted to a Google Doc), so it needs the drive scope. + service: 'docs', + group: 'markdown', + scopes: scopes('drive'), + tools: ['docs.createFromMarkdown', 'docs.updateFromMarkdown'], + defaultEnabled: true, + }, // Drive { diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index c93ab52..53aa9cf 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -330,6 +330,46 @@ async function main() { docsService.writeText, ); + registerTool( + 'docs.createFromMarkdown', + { + description: + 'Creates a new, fully-formatted Google Doc from a Markdown string. Drive natively converts the Markdown — headings, bold/italic, links, tables, and nested lists all map cleanly (far higher fidelity than hand-built insert requests). Returns documentId and webViewLink.', + inputSchema: { + markdown: z + .string() + .describe('The Markdown content to convert into the Google Doc.'), + name: z.string().describe('The title/name for the new Google Doc.'), + parentId: z + .string() + .optional() + .describe( + 'Optional Drive folder ID to create the doc in. Defaults to root My Drive.', + ), + }, + }, + docsService.createFromMarkdown, + ); + + registerTool( + 'docs.updateFromMarkdown', + { + description: + "Replaces an existing Google Doc's entire content from a Markdown string (same clean Markdown→Doc conversion), keeping the same document ID and link. Use to refresh a doc previously created from Markdown.", + inputSchema: { + documentId: z + .string() + .describe('The ID or URL of the Google Doc to overwrite.'), + markdown: z + .string() + .describe( + 'The new Markdown content; fully replaces the current document body.', + ), + }, + }, + docsService.updateFromMarkdown, + ); + registerTool( 'drive.findFolder', { diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 72350bb..8feb7f6 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -4,7 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { google, docs_v1 } from 'googleapis'; +import { google, docs_v1, drive_v3 } from 'googleapis'; +import { Readable } from 'node:stream'; import { AuthManager } from '../auth/AuthManager'; import { logToFile } from '../utils/logger'; import { extractDocId } from '../utils/IdUtils'; @@ -71,6 +72,124 @@ export class DocsService { return google.docs({ version: 'v1', ...options }); } + private async getDriveClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.drive({ version: 'v3', ...options }); + } + + /** + * Create a new Google Doc from a Markdown string. Drive's native import + * (source mimeType text/markdown → destination application/vnd.google-apps.document) + * maps headings, bold/italic, links, tables, and nested lists cleanly — far + * higher fidelity than hand-built Docs API insert requests. + */ + public createFromMarkdown = async ({ + markdown, + name, + parentId, + }: { + markdown: string; + name: string; + parentId?: string; + }) => { + logToFile(`[DocsService] createFromMarkdown: ${name}`); + try { + const drive = await this.getDriveClient(); + const requestBody: drive_v3.Schema$File = { + name, + mimeType: 'application/vnd.google-apps.document', + }; + if (parentId) requestBody.parents = [parentId]; + + const res = await drive.files.create({ + requestBody, + media: { mimeType: 'text/markdown', body: Readable.from(markdown) }, + fields: 'id, name, webViewLink', + supportsAllDrives: true, + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: res.data.id, + title: res.data.name, + webViewLink: res.data.webViewLink, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during docs.createFromMarkdown: ${errorMessage}`); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + /** + * Replace an existing Google Doc's entire content from a Markdown string, + * keeping the same document ID and link. Uses the same clean Markdown→Doc + * conversion as createFromMarkdown. + */ + public updateFromMarkdown = async ({ + documentId, + markdown, + }: { + documentId: string; + markdown: string; + }) => { + logToFile(`[DocsService] updateFromMarkdown: ${documentId}`); + try { + const id = extractDocId(documentId) || documentId; + const drive = await this.getDriveClient(); + + const res = await drive.files.update({ + fileId: id, + media: { mimeType: 'text/markdown', body: Readable.from(markdown) }, + fields: 'id, name, webViewLink, modifiedTime', + supportsAllDrives: true, + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: res.data.id, + title: res.data.name, + webViewLink: res.data.webViewLink, + modifiedTime: res.data.modifiedTime, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during docs.updateFromMarkdown: ${errorMessage}`); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + public getSuggestions = async ({ documentId }: { documentId: string }) => { logToFile( `[DocsService] Starting getSuggestions for document: ${documentId}`,