diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 674d678..e03fcf2 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -312,6 +312,83 @@ async function main() { docsService.replaceText, ); + server.registerTool( + 'docs.insertImage', + { + description: + 'Inserts an image into a Google Doc at a specified position or at the end of the document.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + imageUrl: z + .string() + .url() + .describe('The URL of the image to insert. Must be publicly accessible.'), + positionIndex: z + .number() + .optional() + .describe( + 'The index position to insert the image. If not provided, inserts at the end.', + ), + tabId: z + .string() + .optional() + .describe('The ID of the tab to modify. If not provided, modifies the first tab.'), + widthPt: z + .number() + .optional() + .describe('The width of the image in points (pt).'), + heightPt: z + .number() + .optional() + .describe('The height of the image in points (pt).'), + }, + }, + docsService.insertImage, + ); + + server.registerTool( + 'docs.insertTable', + { + description: + 'Inserts a table into a Google Doc at a specified position or at the end of the document.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + rows: z.number().min(1).describe('The number of rows in the table.'), + columns: z.number().min(1).describe('The number of columns in the table.'), + tabId: z + .string() + .optional() + .describe('The ID of the tab to modify. If not provided, modifies the first tab.'), + positionIndex: z + .number() + .optional() + .describe( + 'The index position to insert the table. If not provided, inserts at the end.', + ), + }, + }, + docsService.insertTable, + ); + + server.registerTool( + 'docs.createHeaderFooter', + { + description: + 'Creates a header or footer in a Google Doc with optional initial text.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + type: z + .enum(['header', 'footer']) + .describe('The type of element to create: "header" or "footer".'), + text: z + .string() + .optional() + .describe('Optional text to insert into the header or footer.'), + }, + }, + docsService.createHeaderFooter, + ); + server.registerTool( 'docs.formatText', { diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index ea55591..4e9abfc 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -112,6 +112,46 @@ export class DocsService { } }; + /** + * Calculates the appropriate insertion index for new content in a document. + * If positionIndex is provided, uses that. Otherwise, finds the end of the + * target tab's content (or first tab when tabId is not provided) and + * calculates an appropriate insertion point. + */ + private async _calculateInsertionIndex( + documentId: string, + positionIndex?: number, + tabId?: string, + ): Promise { + if (positionIndex !== undefined) { + return positionIndex; + } + + const docs = await this.getDocsClient(); + const res = await docs.documents.get({ + documentId, + fields: 'tabs', + includeTabsContent: true, + }); + + const tabs = this._flattenTabs(res.data.tabs || []); + let content: docs_v1.Schema$StructuralElement[] | undefined; + + if (tabId) { + const tab = tabs.find((t) => t.tabProperties?.tabId === tabId); + if (!tab) { + throw new Error(`Tab with ID ${tabId} not found.`); + } + content = tab.documentTab?.body?.content; + } else if (tabs.length > 0) { + content = tabs[0].documentTab?.body?.content; + } + + const lastElement = content?.[content.length - 1]; + const endIndex = lastElement?.endIndex ?? 1; + return Math.max(1, endIndex - 1); + } + private _extractSuggestions( body: docs_v1.Schema$Body | undefined | null, ): DocsSuggestion[] { @@ -257,6 +297,7 @@ export class DocsService { error instanceof Error ? error.message : String(error); logToFile(`Error during docs.create: ${errorMessage}`); return { + isError: true, content: [ { type: 'text' as const, @@ -649,6 +690,252 @@ export class DocsService { } }; + public insertImage = async ({ + documentId, + imageUrl, + positionIndex, + tabId, + widthPt, + heightPt, + }: { + documentId: string; + imageUrl: string; + positionIndex?: number; + tabId?: string; + widthPt?: number; + heightPt?: number; + }) => { + logToFile( + `[DocsService] Starting insertImage for document: ${documentId}, tabId: ${tabId}`, + ); + try { + const id = extractDocId(documentId) || documentId; + const docs = await this.getDocsClient(); + + const insertIndex = await this._calculateInsertionIndex(id, positionIndex, tabId); + + const imageRequest: docs_v1.Schema$Request = { + insertInlineImage: { + uri: imageUrl, + location: { + index: insertIndex, + tabId, + }, + }, + }; + + // Only set explicit dimensions if provided. If only one dimension is + // supplied, API behavior for the missing dimension may vary. + if (widthPt !== undefined || heightPt !== undefined) { + const objectSize: docs_v1.Schema$Size = {}; + if (widthPt !== undefined) { + objectSize.width = { magnitude: widthPt, unit: 'PT' }; + } + if (heightPt !== undefined) { + objectSize.height = { magnitude: heightPt, unit: 'PT' }; + } + imageRequest.insertInlineImage!.objectSize = objectSize; + + // Log dimension info for debugging + if (widthPt !== undefined && heightPt !== undefined) { + logToFile(`[DocsService] Setting explicit dimensions: ${widthPt}pt x ${heightPt}pt`); + } else if (widthPt !== undefined) { + logToFile(`[DocsService] Setting width only: ${widthPt}pt`); + } else { + logToFile(`[DocsService] Setting height only: ${heightPt}pt`); + } + } + + const update = await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests: [imageRequest], + }, + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: update.data.documentId, + insertedAt: insertIndex, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.insertImage: ${errorMessage}`); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public insertTable = async ({ + documentId, + rows, + columns, + tabId, + positionIndex, + }: { + documentId: string; + rows: number; + columns: number; + tabId?: string; + positionIndex?: number; + }) => { + logToFile( + `[DocsService] Starting insertTable for document: ${documentId}, tabId: ${tabId}`, + ); + try { + const id = extractDocId(documentId) || documentId; + const docs = await this.getDocsClient(); + + const insertIndex = await this._calculateInsertionIndex(id, positionIndex, tabId); + + const update = await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests: [ + { + insertTable: { + rows, + columns, + location: { + index: insertIndex, + tabId, + }, + }, + }, + ], + }, + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: update.data.documentId, + rows, + columns, + insertedAt: insertIndex, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.insertTable: ${errorMessage}`); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public createHeaderFooter = async ({ + documentId, + type, + text, + }: { + documentId: string; + type: 'header' | 'footer'; + text?: string; + }) => { + logToFile( + `[DocsService] Starting createHeaderFooter for document: ${documentId}, type: ${type}`, + ); + try { + const id = extractDocId(documentId) || documentId; + const docs = await this.getDocsClient(); + + const createRequest: docs_v1.Schema$Request = + type === 'header' + ? ({ createHeader: { type: 'DEFAULT' } } as docs_v1.Schema$Request) + : ({ createFooter: { type: 'DEFAULT' } } as docs_v1.Schema$Request); + + const createResult = await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests: [createRequest], + }, + }); + + const segmentId = + type === 'header' + ? createResult.data.replies?.[0]?.createHeader?.headerId + : createResult.data.replies?.[0]?.createFooter?.footerId; + + if (text && !segmentId) { + throw new Error( + `Created ${type} but could not retrieve its ID from the API response. The provided text was not inserted.`, + ); + } + + if (text && segmentId) { + await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests: [ + { + insertText: { + endOfSegmentLocation: { + segmentId, + }, + text, + }, + }, + ], + }, + }); + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: id, + type, + segmentId, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[DocsService] Error during docs.createHeaderFooter: ${errorMessage}`, + ); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + private _readStructuralElement( element: docs_v1.Schema$StructuralElement, ): string {