Spaces:
Sleeping
Sleeping
File size: 6,525 Bytes
7dc28be | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | import type { FastMCP } from 'fastmcp';
import { UserError } from 'fastmcp';
import { z } from 'zod';
import { docs_v1 } from 'googleapis';
import { getDocsClient } from '../../clients.js';
import { DocumentIdParameter } from '../../types.js';
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
// --- Table Index Math ---
// Google Docs API table index layout for an R×C table inserted at T:
// cellContentIndex(T, r, c, C) = T + 4 + r * (1 + 2*C) + 2*c
// (Verified empirically; see markdownToDocs.ts lines 640-658)
/**
* Builds the Google Docs API requests to insert a table and populate its cells.
* Exported for testability (same pattern as convertMarkdownToRequests).
*/
export function buildInsertTableWithDataRequests(
data: string[][],
index: number,
hasHeaderRow: boolean,
tabId?: string
): docs_v1.Schema$Request[] {
const numRows = data.length;
const numCols = data.reduce((max, row) => Math.max(max, row.length), 0);
if (numRows === 0 || numCols === 0) {
throw new UserError(
'Table data must contain at least one non-empty row with at least one cell.'
);
}
// Pad ragged rows to uniform column count
const normalizedData = data.map((row) => {
const padded = [...row];
while (padded.length < numCols) padded.push('');
return padded;
});
const insertRequests: docs_v1.Schema$Request[] = [];
const formatRequests: docs_v1.Schema$Request[] = [];
// 1. Insert the empty table structure
const location: Record<string, unknown> = { index };
if (tabId) location.tabId = tabId;
insertRequests.push({
insertTable: {
location: location as docs_v1.Schema$Location,
rows: numRows,
columns: numCols,
},
});
// 2. Insert text into each cell, tracking cumulative offset
let cumulativeTextLength = 0;
for (let r = 0; r < numRows; r++) {
for (let c = 0; c < numCols; c++) {
const cellText = normalizedData[r][c];
if (!cellText) continue;
const baseCellIndex = index + 4 + r * (1 + 2 * numCols) + 2 * c;
const adjustedIndex = baseCellIndex + cumulativeTextLength;
const cellLocation: Record<string, unknown> = { index: adjustedIndex };
if (tabId) cellLocation.tabId = tabId;
insertRequests.push({
insertText: {
location: cellLocation as docs_v1.Schema$Location,
text: cellText,
},
});
// 3. Bold header row cells
if (hasHeaderRow && r === 0) {
const styleReq = GDocsHelpers.buildUpdateTextStyleRequest(
adjustedIndex,
adjustedIndex + cellText.length,
{ bold: true },
tabId
);
if (styleReq) formatRequests.push(styleReq.request);
}
cumulativeTextLength += cellText.length;
}
}
return [...insertRequests, ...formatRequests];
}
export function register(server: FastMCP) {
server.addTool({
name: 'insertTableWithData',
description:
'Inserts a table pre-populated with data at a specific index in the document. ' +
'All cell content is inserted in a single operation. ' +
'Optionally bolds the first row as a header. ' +
'Ragged rows are padded with empty cells to match the widest row.',
parameters: DocumentIdParameter.extend({
data: z
.array(z.array(z.string()).max(50))
.min(1)
.max(200)
.describe(
'A 2D array of strings representing the table contents. Each inner array is one row. ' +
'Example: [["Name", "Age"], ["Alice", "30"], ["Bob", "25"]]'
),
index: z
.number()
.int()
.min(1)
.describe(
'1-based character index within the document body where the table should be inserted. ' +
"Use readDocument with format='json' to inspect indices."
),
hasHeaderRow: z
.boolean()
.optional()
.default(false)
.describe('If true, the first row is treated as a header and its text will be bolded.'),
tabId: z
.string()
.optional()
.describe(
'The ID of the specific tab to insert into. Use listDocumentTabs to get tab IDs. ' +
'If not specified, inserts into the first tab.'
),
}),
execute: async (args, { log }) => {
const docs = await getDocsClient();
const numRows = args.data.length;
const numCols = args.data.reduce((max, row) => Math.max(max, row.length), 0);
log.info(
`Inserting ${numRows}x${numCols} table with data in doc ${args.documentId} at index ${args.index}${args.tabId ? ` (tab: ${args.tabId})` : ''}`
);
try {
// Validate tab if specified (same pattern as insertTable.ts)
if (args.tabId) {
const docInfo = await docs.documents.get({
documentId: args.documentId,
includeTabsContent: true,
suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS',
fields: 'tabs(tabProperties(tabId),documentTab(body(content(startIndex,endIndex))))',
});
const targetTab = GDocsHelpers.findTabById(docInfo.data, args.tabId);
if (!targetTab) {
throw new UserError(`Tab with ID "${args.tabId}" not found in document.`);
}
if (!targetTab.documentTab) {
throw new UserError(
`Tab "${args.tabId}" does not have content (may not be a document tab).`
);
}
}
const requests = buildInsertTableWithDataRequests(
args.data,
args.index,
args.hasHeaderRow ?? false,
args.tabId
);
const metadata = await GDocsHelpers.executeBatchUpdateWithSplitting(
docs,
args.documentId,
requests,
log
);
return (
`Successfully inserted a ${numRows}x${numCols} table with data at index ${args.index}` +
`${args.tabId ? ` in tab ${args.tabId}` : ''}. ` +
`${args.hasHeaderRow ? 'Header row bolded. ' : ''}` +
`(${metadata.totalRequests} requests in ${metadata.totalApiCalls} API calls, ${metadata.totalElapsedMs}ms)`
);
} catch (error: any) {
log.error(
`Error inserting table with data in doc ${args.documentId}: ${error.message || error}`
);
if (error instanceof UserError) throw error;
throw new UserError(
`Failed to insert table with data: ${error.message || 'Unknown error'}`
);
}
},
});
}
|