Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions workspace-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand Down
287 changes: 287 additions & 0 deletions workspace-server/src/services/DocsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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[] {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading