File size: 7,303 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
195
196
197
198
199
200
201
202
203
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, TextFindParameter, TextStyleParameters } from '../../types.js';
import type { TextStyleArgs } from '../../types.js';
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';

const RangeTarget = z
  .object({
    startIndex: z.number().int().min(1).describe('Start of range (inclusive, 1-based).'),
    endIndex: z.number().int().min(1).describe('End of range (exclusive).'),
  })
  .refine((d) => d.endIndex > d.startIndex, {
    message: 'endIndex must be greater than startIndex',
    path: ['endIndex'],
  });

const InsertionTarget = z.strictObject({
  insertionIndex: z.number().int().min(1).describe('Index to insert at (1-based).'),
});

const ModifyTextParameters = DocumentIdParameter.extend({
  target: z
    .union([RangeTarget, TextFindParameter, InsertionTarget])
    .describe('Target by range indices, text search, or insertion index.'),
  text: z.string().optional().describe('New text to insert or replace with.'),
  style: TextStyleParameters.optional().describe('Text formatting to apply.'),
  tabId: z
    .string()
    .optional()
    .describe(
      'The ID of the specific tab to operate on. If not specified, operates on the first tab.'
    ),
})
  .refine((args) => args.text !== undefined || args.style !== undefined, {
    message: 'At least one of text or style must be provided.',
  })
  .refine(
    (args) => {
      if ('insertionIndex' in args.target && args.text === undefined) return false;
      return true;
    },
    { message: 'text is required when using insertionIndex target (no existing range to format).' }
  );

export interface BuildModifyTextOpts {
  startIndex: number;
  endIndex?: number;
  text?: string;
  style?: TextStyleArgs;
  tabId?: string;
}

/**
 * Pure, sync function that builds the array of Google Docs API requests for a
 * modifyText operation. Indices must already be resolved (no text-search here).
 */
export function buildModifyTextRequests(opts: BuildModifyTextOpts): docs_v1.Schema$Request[] {
  const { startIndex, endIndex, text, style, tabId } = opts;
  const requests: docs_v1.Schema$Request[] = [];

  if (!text && !style) return requests;

  // 1. Delete existing content (only when replacing, not insert-only)
  if (endIndex !== undefined && text !== undefined) {
    const range: any = { startIndex, endIndex };
    if (tabId) range.tabId = tabId;
    requests.push({ deleteContentRange: { range } });
  }

  // 2. Insert new text
  if (text !== undefined) {
    const location: any = { index: startIndex };
    if (tabId) location.tabId = tabId;
    requests.push({ insertText: { location, text } });
  }

  // 3. Apply formatting
  if (style) {
    const formatStart = startIndex;
    const formatEnd =
      text !== undefined
        ? startIndex + text.length
        : endIndex !== undefined
          ? endIndex
          : startIndex;

    if (formatEnd > formatStart) {
      const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest(
        formatStart,
        formatEnd,
        style,
        tabId
      );
      if (requestInfo) {
        requests.push(requestInfo.request);
      }
    }
  }

  return requests;
}

export function register(server: FastMCP) {
  server.addTool({
    name: 'modifyText',
    description:
      'Combines text replacement/insertion and formatting in one atomic operation. ' +
      'Can insert text at a position, replace a range or found text, apply formatting, ' +
      "or any combination. Use readGoogleDoc with format='json' to determine indices.",
    parameters: ModifyTextParameters,
    execute: async (args, { log }) => {
      const docs = await getDocsClient();
      log.info(
        `modifyText on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
          `${args.text !== undefined ? `, text="${args.text.substring(0, 50)}"` : ''}` +
          `${args.style ? `, style=${JSON.stringify(args.style)}` : ''}` +
          `${args.tabId ? `, tab=${args.tabId}` : ''}`
      );

      try {
        // Verify tab exists if specified
        if (args.tabId) {
          const docInfo = await docs.documents.get({
            documentId: args.documentId,
            includeTabsContent: true,
            suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS',
            fields: 'tabs(tabProperties(tabId))',
          });
          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).`
            );
          }
        }

        // Resolve target to numeric indices
        let startIndex: number;
        let endIndex: number | undefined;

        if ('insertionIndex' in args.target) {
          startIndex = args.target.insertionIndex;
          endIndex = undefined;
        } else if ('textToFind' in args.target) {
          const range = await GDocsHelpers.findTextRange(
            docs,
            args.documentId,
            args.target.textToFind,
            args.target.matchInstance,
            args.tabId
          );
          if (!range) {
            throw new UserError(
              `Could not find instance ${args.target.matchInstance ?? 1} of text "${args.target.textToFind}"${args.tabId ? ` in tab ${args.tabId}` : ''}.`
            );
          }
          startIndex = range.startIndex;
          endIndex = range.endIndex;
          log.info(`Found text "${args.target.textToFind}" at range ${startIndex}-${endIndex}`);
        } else {
          startIndex = (args.target as { startIndex: number; endIndex: number }).startIndex;
          endIndex = (args.target as { startIndex: number; endIndex: number }).endIndex;
        }

        // Clamp to minimum 1 (index 0 is the document section break)
        if (startIndex < 1) startIndex = 1;

        const requests = buildModifyTextRequests({
          startIndex,
          endIndex,
          text: args.text,
          style: args.style,
          tabId: args.tabId,
        });

        if (requests.length === 0) {
          return 'No operations to perform.';
        }

        await GDocsHelpers.executeBatchUpdate(docs, args.documentId, requests);

        // Build descriptive result
        const actions: string[] = [];
        if (endIndex !== undefined && args.text !== undefined) actions.push('replaced text');
        else if (args.text !== undefined) actions.push('inserted text');
        if (args.style) actions.push('applied formatting');

        return `Successfully ${actions.join(' and ')} at range ${startIndex}-${endIndex ?? startIndex + (args.text?.length ?? 0)}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
      } catch (error: any) {
        log.error(`Error in modifyText for doc ${args.documentId}: ${error.message || error}`);
        if (error instanceof UserError) throw error;
        throw new UserError(`Failed to modify text: ${error.message || 'Unknown error'}`);
      }
    },
  });
}