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'}`
        );
      }
    },
  });
}