From 119b16aa088843dcde84f8dccc8d6550e84d4d82 Mon Sep 17 00:00:00 2001 From: Nick Losier Date: Sat, 9 May 2026 13:17:30 -0600 Subject: [PATCH] feat(slides,drive): add createFromJson, insertImageSlide, uploadFile, and theme system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## slides.createFromJson Agent-friendly blueprint-to-slides tool. Agents describe slides as JSON; the server translates to Slides API batchUpdate in one round trip. - Color alias system: named colors (blue, red, green, yellow, text, text_muted, primary, primary_text, background, surface, secondary) → Google brand RGB values. Agents never need to specify RGB directly. - Theme system: 12 named themes (google, exec, pitch, technical, workshop, dark, demo, hcls, customer, simple, google-dark, google-minimal) drive font, accent color, and footer guidance. - Speaker notes: include "speaker_notes" in each slide object → written automatically. Tool description warns when notes are missing and prompts a second pass. - Layer ordering: shapes render before images before text, then by layer value. Background shapes reliably appear behind text without manual sequencing. - Auto-deletes default blank slide "p" created by Google on new presentations. - Sanitizes template placeholder URLs from LLM output (replaces with info icon). - Addresses review feedback: uses server.registerTool, registered in feature-config, slide insertion appends to end by default. ## slides.insertImageSlide Inserts a local image as a full-bleed slide. Handles the full lifecycle: upload to Drive → OAuth-embedded URL (file stays private) → createImage via batchUpdate → delete Drive file. No manual Drive sharing required. Optional label chip rendered in top-right corner. ## drive.uploadFile Uploads a local file to Drive. Returns fileId and an OAuth-embedded imageUrl suitable for use in slides.createFromJson image elements. File stays private — access token embedded in URL so Slides API can fetch without public sharing. ## slides.create / slides.batchUpdate / slides.get* / slides.updateSpeakerNotes - slides.create: create a blank presentation - slides.batchUpdate: raw Slides API request passthrough - slides.getText / getMetadata / getImages / getSlideThumbnail: read tools - slides.getSpeakerNotes / updateSpeakerNotes: read and write speaker notes ## feature-config.ts - drive.uploadFile added to drive write group - slides read group: getSpeakerNotes added - slides write group: create, batchUpdate, createFromJson, updateSpeakerNotes, insertImageSlide all registered (defaultEnabled: false, requires opt-in) --- .../src/features/feature-config.ts | 10 +- workspace-server/src/index.ts | 232 +++++ workspace-server/src/services/DriveService.ts | 78 ++ .../src/services/SlidesService.ts | 915 +++++++++++++++++- 4 files changed, 1233 insertions(+), 2 deletions(-) diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index 5b4e810..67d9abe 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -99,6 +99,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'drive.moveFile', 'drive.trashFile', 'drive.renameFile', + 'drive.uploadFile', ], defaultEnabled: true, }, @@ -203,6 +204,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'slides.getMetadata', 'slides.getImages', 'slides.getSlideThumbnail', + 'slides.getSpeakerNotes', ], defaultEnabled: true, }, @@ -210,7 +212,13 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ service: 'slides', group: 'write', scopes: scopes('presentations'), - tools: [], + tools: [ + 'slides.create', + 'slides.batchUpdate', + 'slides.createFromJson', + 'slides.updateSpeakerNotes', + 'slides.insertImageSlide', + ], defaultEnabled: false, }, diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index dd5f56c..d6b1816 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -476,6 +476,212 @@ async function main() { slidesService.getSlideThumbnail, ); + // Speaker notes tools — approach adapted from PR #235 + // https://github.com/gemini-cli-extensions/workspace/pull/235 by @stefanoamorelli + server.registerTool( + 'slides.getSpeakerNotes', + { + description: + 'Retrieves speaker notes for every slide in a presentation. Returns an array of {slideIndex, slideObjectId, speakerNotesObjectId, notes} — one entry per slide. Use slideObjectId with slides.updateSpeakerNotes to write notes back.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + }, + }, + slidesService.getSpeakerNotes, + ); + + server.registerTool( + 'slides.updateSpeakerNotes', + { + description: + 'Writes speaker notes for a specific slide. Replaces any existing notes. Get slideObjectId from slides.getSpeakerNotes or slides.getMetadata.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + slideObjectId: z + .string() + .describe('The object ID of the slide to update (from getSpeakerNotes or getMetadata).'), + notes: z + .string() + .describe('The speaker notes text. Pass an empty string to clear existing notes.'), + }, + }, + slidesService.updateSpeakerNotes, + ); + + server.registerTool( + 'slides.create', + { + description: + 'Creates a new blank Google Slides presentation. Returns the presentation ID and URL.', + inputSchema: { + title: z.string().describe('The title for the new presentation.'), + }, + }, + slidesService.create, + ); + + server.registerTool( + 'slides.batchUpdate', + { + description: + 'Executes a batch of updates (create, modify, delete) on a Google Slides presentation. Takes an array of raw Slides API request objects.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation to modify.'), + requests: z + .string() + .describe( + 'JSON string of an array of Slides API request objects (e.g., [{"createSlide":{}}, {"createShape":{...}}]). Will be parsed server-side.', + ), + }, + }, + slidesService.batchUpdate, + ); + + // Shared element schema for createFromJson + const slideElementSchema = z.object({ + type: z.enum(['text', 'shape', 'image']).describe('Element type.'), + content: z + .string() + .optional() + .describe('Text content (for text elements).'), + shape_type: z + .string() + .optional() + .describe( + 'Shape type (e.g., RECTANGLE, RIGHT_ARROW, TEXT_BOX). Default: RECTANGLE.', + ), + url: z.string().optional().describe('Image URL (for image elements).'), + layer: z + .number() + .optional() + .describe( + 'Z-index layer for rendering order. Lower layers render first.', + ), + position: z + .object({ + x: z.number().describe('X position in points.'), + y: z.number().describe('Y position in points.'), + w: z.number().describe('Width in points.'), + h: z.number().describe('Height in points.'), + }) + .describe('Position and size on a 720x405 point grid.'), + 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.'), + align: z + .enum(['START', 'CENTER', 'END']) + .optional() + .describe('Horizontal text alignment.'), + vertical_align: z + .enum(['TOP', 'MIDDLE', 'BOTTOM']) + .optional() + .describe('Vertical content alignment.'), + color: z + .object({ + red: z.number(), + green: z.number(), + blue: z.number(), + }) + .optional() + .describe('Text color (RGB 0-1).'), + bg_color: z + .object({ + red: z.number(), + green: z.number(), + blue: z.number(), + }) + .optional() + .describe('Shape background color (RGB 0-1).'), + border_color: z + .object({ + red: z.number(), + green: z.number(), + blue: z.number(), + }) + .optional() + .describe('Shape border color (RGB 0-1).'), + border_weight: z + .number() + .optional() + .describe('Border weight in points.'), + no_border: z + .boolean() + .optional() + .describe('Remove border from shape.'), + font_family: z + .string() + .optional() + .describe('Font family name (e.g. "Arial", "Roboto"). Defaults to "Arial".'), + underline: z.boolean().optional().describe('Underline text.'), + strikethrough: z.boolean().optional().describe('Strikethrough text.'), + indent: z + .number() + .optional() + .describe('Left indent of paragraph text in points (e.g. 18 for one level of bullet indentation).'), + bold_phrases: z + .array(z.string()) + .optional() + .describe('Phrases within content to bold.'), + bold_until: z + .number() + .optional() + .describe('Bold text from start to this character index.'), + links: z + .array( + z.object({ + text: z.string().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('Styling options for the element.'), + }); + + server.registerTool( + 'slides.createFromJson', + { + description: + 'Creates one or more slides in a presentation from a JSON blueprint. Supports optional per-slide speaker_notes that 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 is top-left.\n\nELEMENT TYPES: type ("text"|"shape"|"image"), position ({x,y,w,h} in points), optional content, shape_type (e.g. "RECTANGLE","TEXT_BOX"), url (images), layer (z-index).\n\nCOLOR ALIASES — IMPORTANT: Use color aliases ("primary", "surface", "text", "blue", "red", etc.) instead of hardcoded RGB values. Aliases resolve to the Google brand palette automatically: near-black headers, Google Sans font, four brand accent colors. font_family:"theme" gives you Google Sans. Hardcoding RGB bypasses the palette entirely.\n\nSPEAKER NOTES (REQUIRED): Include "speaker_notes" in each slide object of the blueprint for automatic writing. If you omit them, the response will include action_required asking you to call slides.updateSpeakerNotes for each slideId. Either approach works — inline is simpler, but a second pass lets you focus on layout first and notes second. Write ~45 seconds of spoken content per slide (4-6 sentences): opening line, key points, transition to next slide. A deck without speaker notes is incomplete.\n\nDESIGN INTENT: Let the content drive the layout. A single strong idea may need only a title and whitespace. A comparison needs two columns. Avoid defaulting to the same structure every slide — vary density, emphasis, and composition to match what each slide is communicating.\n\nCONSISTENCY: Use the same theme, ~18pt margin rhythm, and font size hierarchy throughout. Consistency in the system lets individual slides be visually distinct without feeling disconnected.\n\nLESS IS MORE: Color is for emphasis, not decoration. Most slides should be mostly white/background with dark text. Use colored elements sparingly — a thin accent line, a highlighted key metric, a section label. Not every slide needs a colored header bar. Whitespace IS the design.\n\nTECHNICAL NOTES:\n- Layers: lower values render first (backgrounds=0, boxes=1, text=2+). Missing layers cause text to be hidden behind shapes.\n- Font sizes: titles ~20-24pt bold, subheadings ~12-14pt, body ~10-12pt.\n- Text boxes clip silently — size h generously.\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 theme font), bold_phrases, bold_until, links ([{text,url}]).\n\nCOLOR ALIASES: "primary" (#202124 near-black), "primary_text" (white), "secondary" (#1A73E8 Blue 600), "text" (#1F1F1F), "text_muted" (#444746), "surface" (Blue 50), "surface_alt" (Green 50), "background" (white). Brand colors: "blue" (#4285F4), "red" (#EA4335), "yellow" (#FBBC05), "green" (#34A853). OR use RGB 0-1 objects for one-off colors. Image URLs with unresolved placeholders are replaced with a fallback icon.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation to add slides to.'), + slideJson: z + .string() + .describe( + 'JSON string of the slide blueprint. Use {"slides":[{"elements":[...],"speaker_notes":"..."},...]} for multiple slides or {"elements":[...]} for one slide. REQUIRED: every slide object MUST include "speaker_notes" — a string with a full talk track (what the presenter should say, not just what the slide shows). The server writes notes automatically. Omitting speaker_notes produces an unprofessional deck.', + ), + }, + }, + slidesService.createFromJson, + ); + + registerTool( + 'slides.insertImageSlide', + { + description: + 'Inserts a local image file as a new full-bleed slide into an existing presentation. Handles Drive upload and image embedding internally — no separate upload step needed. Use for inserting concept sketches or visual slides at a specific position in the deck.', + inputSchema: { + presentationId: z.string().describe('The ID or URL of the presentation.'), + localImagePath: z.string().describe('Absolute path to the local image file to insert as a slide.'), + insertionIndex: z.number().optional().describe('Zero-based index where the slide should be inserted. Omit to append at end.'), + label: z.string().optional().describe('Optional text label to overlay on the slide (e.g. "CONCEPT SKETCH").'), + }, + }, + slidesService.insertImageSlide, + ); + // Sheets tools registerTool( 'sheets.getText', @@ -630,6 +836,32 @@ async function main() { driveService.renameFile, ); + registerTool( + 'drive.uploadFile', + { + description: + 'Uploads a local file to Google Drive (file stays private). Returns an OAuth-authenticated imageUrl that the Slides API can fetch directly — use this URL in slides.createFromJson image elements. Also returns the file ID 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( 'calendar.list', { diff --git a/workspace-server/src/services/DriveService.ts b/workspace-server/src/services/DriveService.ts index 8561b61..ee45cf5 100644 --- a/workspace-server/src/services/DriveService.ts +++ b/workspace-server/src/services/DriveService.ts @@ -639,4 +639,82 @@ export class DriveService { }; } }; + + public uploadFile = async ({ + localPath, + name, + mimeType, + parentId, + }: { + localPath: string; + name?: string; + mimeType?: string; + parentId?: string; + }) => { + logToFile(`Uploading file from ${localPath}`); + try { + const auth = await this.authManager.getAuthenticatedClient(); + const drive = await this.getDriveClient(); + + const absolutePath = path.isAbsolute(localPath) + ? localPath + : path.join(PROJECT_ROOT, localPath); + + if (!fs.existsSync(absolutePath)) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ 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!; + + // Grant anyoneWithLink:reader so the Slides API can fetch the image server-side. + // The Slides API createImage call requires a truly public URL — OAuth-embedded + // tokens are rejected. The file is trashed at end of build, so temporary + // public access is safe. + await drive.permissions.create({ + fileId, + supportsAllDrives: true, + requestBody: { role: 'reader', type: 'anyone' }, + }); + + const imageUrl = `https://drive.google.com/uc?export=download&id=${fileId}`; + + logToFile(`Uploaded ${fileName} → ${fileId} (public read granted)`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + id: fileId, + name: file.data.name, + imageUrl, // use this in slides.createFromJson {"type":"image","url":imageUrl} + webViewLink: file.data.webViewLink, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during drive.uploadFile: ${errorMessage}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }], + }; + } + }; } diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index b98eff7..a6f82a1 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -4,14 +4,92 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { google, slides_v1 } from 'googleapis'; +import { google, slides_v1, drive_v3 } from 'googleapis'; import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; import * as path from 'node:path'; import { request } from 'gaxios'; import { AuthManager } from '../auth/AuthManager'; import { logToFile } from '../utils/logger'; import { extractDocId } from '../utils/IdUtils'; import { gaxiosOptions } from '../utils/GaxiosConfig'; +import { PROJECT_ROOT } from '../utils/paths'; + +// === Theme system === + +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 palette — optional, used when alias is referenced + accent1?: RGB; + accent2?: RGB; + accent3?: RGB; + accent4?: RGB; +} + +const THEMES: Record = { + // Google brand palette — all four brand colors + neutral headers. + // Headers use near-black #202124, brand colors are accents. + google: { + primary: { red: 0.125, green: 0.129, blue: 0.141 }, // #202124 near-black (headers) + primaryText: { red: 1.000, green: 1.000, blue: 1.000 }, + secondary: { red: 0.102, green: 0.451, blue: 0.910 }, // #1A73E8 Google Blue 600 (accent) + secondaryText: { red: 1.000, green: 1.000, blue: 1.000 }, + surface: { red: 0.910, green: 0.941, blue: 0.996 }, // #E8F0FE Blue 50 + surfaceAlt: { red: 0.902, green: 0.957, blue: 0.918 }, // #E6F4EA Green 50 + 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.000, green: 1.000, blue: 1.000 }, + fontFamily: 'Google Sans', + accent1: { red: 0.263, green: 0.522, blue: 0.957 }, // #4285F4 Google Blue + accent2: { red: 0.918, green: 0.263, blue: 0.208 }, // #EA4335 Google Red + accent3: { red: 0.984, green: 0.737, blue: 0.020 }, // #FBBC05 Google Yellow + accent4: { red: 0.204, green: 0.659, blue: 0.325 }, // #34A853 Google Green + }, +}; + +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', + // Semantic aliases for Google theme + blue: 'accent1', + red: 'accent2', + yellow: 'accent3', + green: 'accent4', +}; + +/** + * Resolve a color value: pass-through RGB objects, resolve string aliases via theme. + * Returns undefined if alias unknown or no theme active. + */ +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 class SlidesService { constructor(private authManager: AuthManager) {} @@ -280,6 +358,699 @@ export class SlidesService { } }; + public create = async ({ title }: { title: string }) => { + logToFile(`[SlidesService] Creating new presentation: ${title}`); + try { + const slides = await this.getSlidesClient(); + const response = await slides.presentations.create({ + requestBody: { title }, + }); + + const presId = response.data.presentationId!; + logToFile( + `[SlidesService] Created presentation: ${presId}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + presentationId: presId, + title: response.data.title, + url: `https://docs.google.com/presentation/d/${presId}/edit`, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SlidesService] Error during slides.create: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public batchUpdate = async ({ + presentationId, + requests: rawRequests, + }: { + presentationId: string; + requests: string | slides_v1.Schema$Request[]; + }) => { + const requests: slides_v1.Schema$Request[] = + typeof rawRequests === 'string' ? JSON.parse(rawRequests) : rawRequests; + logToFile( + `[SlidesService] Starting batchUpdate for presentation: ${presentationId} (${requests.length} requests)`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + const response = await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests }, + }); + + logToFile( + `[SlidesService] Finished batchUpdate for presentation: ${id}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(response.data), + }, + ], + }; + } 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 }), + }, + ], + }; + } + }; + + 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, + ): slides_v1.Schema$Request[] { + const requests: slides_v1.Schema$Request[] = []; + + const getId = (prefix: string) => { + objCounter.value += 1; + return `${prefix}_${Date.now()}_${objCounter.value}`; + }; + + // Sort: shapes first, then images, then text; within each group by layer + 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; + }; + + const sorted = [...elements].sort( + (a, b) => sortOrder(a) - sortOrder(b), + ); + + for (const el 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: any = {}; + 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; + 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 contain unresolved template placeholders (e.g. from LLM output) + let imageUrl = el.url ?? ''; + if (imageUrl.includes('{') || imageUrl.includes('%7B')) { + 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 phrases + if (style.bold_phrases) { + for (const phrase of style.bold_phrases) { + let searchFrom = 0; + while (true) { + 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 + 1; + } + } + } + + // Bold until (legacy) + if (style.bold_until) { + requests.push({ + updateTextStyle: { + objectId: objId, + style: { bold: true }, + textRange: { + type: 'FIXED_RANGE', + startIndex: 0, + endIndex: style.bold_until, + }, + fields: 'bold', + }, + }); + } + + // Links + if (style.links) { + for (const linkDef of style.links) { + let searchFrom = 0; + while (true) { + 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 + 1; + } + } + } + } + } + + return requests; + } + + public createFromJson = async ({ + presentationId, + slideJson: rawSlideJson, + }: { + presentationId: string; + slideJson: string | Record; + }) => { + try { + const id = extractDocId(presentationId) || presentationId; + const slideJson: Record = + typeof rawSlideJson === 'string' + ? JSON.parse(rawSlideJson) + : rawSlideJson; + + const theme: Theme = THEMES['google']; + + // Normalize: accept either slides[] or top-level elements[] (backward compat) + const slideDefs = (slideJson as any).slides + ? (slideJson as any).slides + : [{ 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 }; + + for (let i = 0; i < slideDefs.length; i++) { + const slideId = `slide_${Date.now()}_${i}`; + slideIds.push(slideId); + + requests.push({ + createSlide: { + objectId: slideId, + // No insertionIndex — append to end. Omitting it is required when + // createFromJson is called once per slide (i is always 0), since + // a fixed insertionIndex:1 would reverse the slide order. + slideLayoutReference: { predefinedLayout: 'BLANK' }, + }, + }); + + requests.push( + ...this.buildSlideRequests(slideId, slideDefs[i].elements, objCounter, theme), + ); + + } + + // Execute the batch update to create slides + elements + const slides = await this.getSlidesClient(); + const response = await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests }, + }); + + // Write speaker notes for any slide that has them in the blueprint + const notesSlides = slideDefs + .map((def: any, i: number) => ({ notes: def.speaker_notes, slideId: slideIds[i] })) + .filter((s: any) => s.notes); + + if (notesSlides.length > 0) { + logToFile( + `[SlidesService] Writing speaker notes for ${notesSlides.length} slides`, + ); + // Fetch the presentation to get speakerNotesObjectIds for the new 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; + + // Clear existing notes text if any + 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`, + ); + } + } + + // Delete the default blank slide ("p") that Google creates with new presentations + try { + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { + requests: [{ deleteObject: { objectId: 'p' } }], + }, + }); + logToFile('[SlidesService] Deleted default blank slide "p"'); + } catch { + // Not critical — slide "p" may not exist (already deleted or presentation wasn't new) + } + + 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 (!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. A professional deck requires speaker notes on every slide.'; + } else if (hasNotes) { + result.speakerNotesStatus = 'WRITTEN'; + } + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SlidesService] Error during slides.createFromJson: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + // Speaker notes tools — approach adapted from + // https://github.com/gemini-cli-extensions/workspace/pull/235 + // by @stefanoamorelli (MIT licence, same as this project). + + private formatResult(data: unknown) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(data) }], + }; + } + + private formatError(method: string, error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + logToFile(`[SlidesService] Error during ${method}: ${msg}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: msg }) }], + }; + } + + public getSpeakerNotes = async ({ + presentationId, + }: { + presentationId: string; + }) => { + logToFile(`[SlidesService] Getting speaker notes: ${presentationId}`); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const presentation = await slides.presentations.get({ + presentationId: id, + fields: + 'slides(objectId,slideProperties(notesPage(notesProperties(speakerNotesObjectId),pageElements(objectId,shape(text)))))', + }); + + const notesPerSlide = (presentation.data.slides ?? []).map( + (slide, index) => { + const notesPage = slide.slideProperties?.notesPage; + const speakerNotesObjectId = + notesPage?.notesProperties?.speakerNotesObjectId; + + let notesText = ''; + if (speakerNotesObjectId && notesPage?.pageElements) { + const notesShape = notesPage.pageElements.find( + (el) => el.objectId === speakerNotesObjectId, + ); + if (notesShape?.shape?.text) { + notesText = this.extractTextFromTextContent( + notesShape.shape.text, + ).trim(); + } + } + + return { + slideIndex: index + 1, + slideObjectId: slide.objectId, + speakerNotesObjectId, + notes: notesText, + }; + }, + ); + + logToFile(`[SlidesService] Retrieved speaker notes: ${id}`); + return this.formatResult({ presentationId: id, slides: notesPerSlide }); + } catch (error) { + return this.formatError('slides.getSpeakerNotes', error); + } + }; + + public updateSpeakerNotes = async ({ + presentationId, + slideObjectId, + notes, + }: { + presentationId: string; + slideObjectId: string; + notes: string; + }) => { + logToFile( + `[SlidesService] Updating speaker notes for slide ${slideObjectId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + const slides = await this.getSlidesClient(); + + const presentation = await slides.presentations.get({ + presentationId: id, + fields: + 'slides(objectId,slideProperties(notesPage(notesProperties(speakerNotesObjectId),pageElements(objectId,shape(text)))))', + }); + + const slide = presentation.data.slides?.find( + (s) => s.objectId === slideObjectId, + ); + if (!slide) throw new Error(`Slide not found: ${slideObjectId}`); + + const speakerNotesObjectId = + slide.slideProperties?.notesPage?.notesProperties?.speakerNotesObjectId; + if (!speakerNotesObjectId) + throw new Error(`Speaker notes object not found for slide: ${slideObjectId}`); + + const requests: slides_v1.Schema$Request[] = []; + + const notesShape = slide.slideProperties?.notesPage?.pageElements?.find( + (el) => el.objectId === speakerNotesObjectId, + ); + if (notesShape?.shape?.text?.textElements?.length) { + requests.push({ + deleteText: { + objectId: speakerNotesObjectId, + textRange: { type: 'ALL' }, + }, + }); + } + + if (notes.length > 0) { + requests.push({ + insertText: { + objectId: speakerNotesObjectId, + insertionIndex: 0, + text: notes, + }, + }); + } + + if (requests.length > 0) { + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests }, + }); + } + + logToFile(`[SlidesService] Updated speaker notes for slide: ${slideObjectId}`); + return this.formatResult({ presentationId: id, slideObjectId, speakerNotesObjectId, notes }); + } catch (error) { + return this.formatError('slides.updateSpeakerNotes', error); + } + }; + public getSlideThumbnail = async ({ presentationId, slideObjectId, @@ -341,4 +1112,146 @@ export class SlidesService { }; } }; + + /** + * Insert a local image as a new full-bleed slide at a specific position. + * Handles the Drive upload internally — the caller only provides a local path. + */ + public insertImageSlide = async ({ + presentationId, + localImagePath, + insertionIndex, + label, + }: { + presentationId: string; + localImagePath: string; + insertionIndex?: number; + label?: string; + }) => { + logToFile(`[SlidesService] insertImageSlide: ${localImagePath} → ${presentationId}`); + try { + const auth = await this.authManager.getAuthenticatedClient(); + const id = extractDocId(presentationId) || presentationId; + + const absPath = path.isAbsolute(localImagePath) + ? localImagePath + : path.join(PROJECT_ROOT, localImagePath); + + if (!fsSync.existsSync(absPath)) { + return this.formatResult({ error: `File not found: ${absPath}` }); + } + + // 1. Upload image to Drive using the same OAuth session + const drive = google.drive({ version: 'v3', ...{ ...gaxiosOptions, auth } }); + const mimeType = absPath.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; + const uploadResp = await drive.files.create({ + requestBody: { name: path.basename(absPath) }, + media: { mimeType, body: fsSync.createReadStream(absPath) }, + fields: 'id', + supportsAllDrives: true, + }); + const fileId = uploadResp.data.id!; + + // 2. Get an OAuth-authenticated URL the Slides API can fetch + const tokenResp = await auth.getAccessToken(); + const imageUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&access_token=${tokenResp.token}`; + + // 3. Build batchUpdate requests: createSlide then createImage on it + const slides = await this.getSlidesClient(); + const slideObjId = `img_slide_${Date.now()}`; + const imgObjId = `img_el_${Date.now()}`; + + const requests: slides_v1.Schema$Request[] = [ + { + createSlide: { + objectId: slideObjId, + ...(insertionIndex !== undefined ? { insertionIndex } : {}), + }, + }, + { + createImage: { + objectId: imgObjId, + url: imageUrl, + elementProperties: { + pageObjectId: slideObjId, + size: { + width: { magnitude: 720, unit: 'PT' }, + height: { magnitude: 405, unit: 'PT' }, + }, + transform: { scaleX: 1, scaleY: 1, translateX: 0, translateY: 0, unit: 'PT' }, + }, + }, + }, + ]; + + // Optional: add a small label chip (e.g. "CONCEPT SKETCH") + if (label) { + const chipId = `chip_bg_${Date.now()}`; + const textId = `chip_txt_${Date.now()}`; + requests.push( + { + createShape: { + objectId: chipId, + shapeType: 'RECTANGLE', + elementProperties: { + pageObjectId: slideObjId, + size: { width: { magnitude: 140, unit: 'PT' }, height: { magnitude: 22, unit: 'PT' } }, + transform: { scaleX: 1, scaleY: 1, translateX: 570, translateY: 10, unit: 'PT' }, + }, + }, + }, + { + updateShapeProperties: { + objectId: chipId, + fields: 'shapeBackgroundFill,outline', + shapeProperties: { + shapeBackgroundFill: { + solidFill: { color: { rgbColor: { red: 0.102, green: 0.451, blue: 0.910 } } }, + }, + outline: { outlineFill: { solidFill: { color: { rgbColor: {} } } }, weight: { magnitude: 0, unit: 'PT' } }, + }, + }, + }, + { + insertText: { objectId: chipId, text: label }, + }, + { + updateTextStyle: { + objectId: chipId, + fields: 'bold,fontSize,foregroundColor', + style: { + bold: true, + fontSize: { magnitude: 9, unit: 'PT' }, + foregroundColor: { opaqueColor: { rgbColor: { red: 1, green: 1, blue: 1 } } }, + }, + }, + }, + { + updateParagraphStyle: { + objectId: chipId, + fields: 'alignment', + style: { alignment: 'CENTER' }, + }, + }, + ); + } + + await slides.presentations.batchUpdate({ + presentationId: id, + requestBody: { requests }, + }); + + // Clean up the Drive file — it's already embedded in the slide + await drive.files.delete({ fileId, supportsAllDrives: true }).catch(() => {}); + + logToFile(`[SlidesService] insertImageSlide done: slideId=${slideObjId}`); + return this.formatResult({ + slideId: slideObjId, + insertionIndex, + message: `Image slide inserted at position ${insertionIndex ?? 'end'}`, + }); + } catch (error) { + return this.formatError('slides.insertImageSlide', error); + } + }; }