google-tools-mcp 1.0.17 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/googleDocsApiHelpers.js +220 -108
- package/dist/markdown-transformer/index.js +23 -3
- package/dist/markdown-transformer/markdownToDocs.js +25 -0
- package/dist/readTracker.js +84 -0
- package/dist/tools/docs/appendToGoogleDoc.js +22 -3
- package/dist/tools/docs/deleteRange.js +3 -0
- package/dist/tools/docs/findAndReplace.js +3 -0
- package/dist/tools/docs/formatting/applyParagraphStyle.js +2 -2
- package/dist/tools/docs/modifyText.js +25 -10
- package/dist/tools/docs/modifyText.test.js +90 -0
- package/dist/tools/docs/readGoogleDoc.js +2 -0
- package/dist/tools/drive/deleteFile.js +2 -0
- package/dist/tools/drive/index.js +2 -0
- package/dist/tools/drive/uploadFile.js +111 -0
- package/dist/tools/extras/index.js +2 -0
- package/dist/tools/extras/readDriveFile.js +206 -0
- package/dist/tools/extras/readFile.js +2 -0
- package/dist/tools/index.js +117 -11
- package/dist/tools/sheets/batchWrite.js +2 -0
- package/dist/tools/sheets/clearSpreadsheetRange.js +2 -0
- package/dist/tools/sheets/getSpreadsheetInfo.js +2 -0
- package/dist/tools/sheets/readSpreadsheet.js +2 -0
- package/dist/tools/sheets/writeSpreadsheet.js +2 -0
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +26 -5
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +27 -5
- package/dist/types.js +1 -2
- package/package.json +51 -4
|
@@ -160,127 +160,239 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
|
|
|
160
160
|
}
|
|
161
161
|
// --- Text Finding Helper ---
|
|
162
162
|
// This improved version is more robust in handling various text structure scenarios
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
163
|
+
/**
|
|
164
|
+
* Fetches document content and builds a flat text representation with segment mappings.
|
|
165
|
+
* Shared by findTextRange and other text-search utilities.
|
|
166
|
+
*/
|
|
167
|
+
async function getDocumentTextAndSegments(docs, documentId, tabId) {
|
|
168
|
+
const needsTabsContent = !!tabId;
|
|
169
|
+
const res = await docs.documents.get({
|
|
170
|
+
documentId,
|
|
171
|
+
...(needsTabsContent && { includeTabsContent: true }),
|
|
172
|
+
fields: needsTabsContent
|
|
173
|
+
? 'tabs(tabProperties(tabId),documentTab(body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))))'
|
|
174
|
+
: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))',
|
|
175
|
+
});
|
|
176
|
+
let bodyContent;
|
|
177
|
+
if (tabId) {
|
|
178
|
+
const targetTab = findTabById(res.data, tabId);
|
|
179
|
+
if (!targetTab) {
|
|
180
|
+
throw new UserError(`Tab with ID "${tabId}" not found in document.`);
|
|
181
|
+
}
|
|
182
|
+
if (!targetTab.documentTab?.body?.content) {
|
|
183
|
+
throw new UserError(`Tab "${tabId}" does not have content (may not be a document tab).`);
|
|
184
|
+
}
|
|
185
|
+
bodyContent = targetTab.documentTab.body.content;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
bodyContent = res.data.body?.content;
|
|
189
|
+
}
|
|
190
|
+
if (!bodyContent) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
let fullText = '';
|
|
194
|
+
const segments = [];
|
|
195
|
+
const collectTextFromContent = (content) => {
|
|
196
|
+
content.forEach((element) => {
|
|
197
|
+
if (element.paragraph?.elements) {
|
|
198
|
+
element.paragraph.elements.forEach((pe) => {
|
|
199
|
+
if (pe.textRun?.content && pe.startIndex !== undefined && pe.endIndex !== undefined) {
|
|
200
|
+
const content = pe.textRun.content;
|
|
201
|
+
fullText += content;
|
|
202
|
+
segments.push({
|
|
203
|
+
text: content,
|
|
204
|
+
start: pe.startIndex,
|
|
205
|
+
end: pe.endIndex,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
});
|
|
182
209
|
}
|
|
183
|
-
if (
|
|
184
|
-
|
|
210
|
+
if (element.table && element.table.tableRows) {
|
|
211
|
+
element.table.tableRows.forEach((row) => {
|
|
212
|
+
if (row.tableCells) {
|
|
213
|
+
row.tableCells.forEach((cell) => {
|
|
214
|
+
if (cell.content) {
|
|
215
|
+
collectTextFromContent(cell.content);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
});
|
|
185
220
|
}
|
|
186
|
-
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
collectTextFromContent(bodyContent);
|
|
224
|
+
segments.sort((a, b) => a.start - b.start);
|
|
225
|
+
return { fullText, segments };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Maps a position in the concatenated fullText back to the actual document index.
|
|
229
|
+
*/
|
|
230
|
+
function mapFullTextPositionToDocIndex(posInFullText, segments) {
|
|
231
|
+
let currentPos = 0;
|
|
232
|
+
for (const seg of segments) {
|
|
233
|
+
const segStart = currentPos;
|
|
234
|
+
const segEnd = segStart + seg.text.length;
|
|
235
|
+
if (posInFullText >= segStart && posInFullText < segEnd) {
|
|
236
|
+
return seg.start + (posInFullText - segStart);
|
|
187
237
|
}
|
|
188
|
-
|
|
189
|
-
|
|
238
|
+
// Also handle the position being exactly at segEnd (for end indices)
|
|
239
|
+
if (posInFullText === segEnd) {
|
|
240
|
+
return seg.start + seg.text.length;
|
|
190
241
|
}
|
|
191
|
-
|
|
242
|
+
currentPos = segEnd;
|
|
243
|
+
}
|
|
244
|
+
return -1;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Character-level Unicode normalization rules.
|
|
248
|
+
* Maps typographic characters to their ASCII equivalents.
|
|
249
|
+
*/
|
|
250
|
+
const NORMALIZE_MAP = {
|
|
251
|
+
'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201B': "'", '\u2032': "'", '\u2035': "'", // smart single quotes
|
|
252
|
+
'\u201C': '"', '\u201D': '"', '\u201E': '"', '\u201F': '"', '\u2033': '"', '\u2036': '"', // smart double quotes
|
|
253
|
+
'\u2014': '--', // em dash
|
|
254
|
+
'\u2013': '-', // en dash
|
|
255
|
+
'\u2026': '...', // ellipsis
|
|
256
|
+
'\u00A0': ' ', // non-breaking space
|
|
257
|
+
'\u000B': '\n', // vertical tab (Google Docs soft return)
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Normalizes a string for search, returning both the normalized text
|
|
261
|
+
* and a position map from normalized-index → original-index.
|
|
262
|
+
*/
|
|
263
|
+
function normalizeWithPositionMap(text) {
|
|
264
|
+
let normalized = '';
|
|
265
|
+
const posMap = []; // posMap[normalizedIdx] = originalIdx
|
|
266
|
+
for (let i = 0; i < text.length; i++) {
|
|
267
|
+
const ch = text[i];
|
|
268
|
+
const replacement = NORMALIZE_MAP[ch];
|
|
269
|
+
if (replacement) {
|
|
270
|
+
for (let j = 0; j < replacement.length; j++) {
|
|
271
|
+
posMap.push(i);
|
|
272
|
+
normalized += replacement[j];
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
posMap.push(i);
|
|
276
|
+
normalized += ch;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Sentinel for end-of-string mapping
|
|
280
|
+
posMap.push(text.length);
|
|
281
|
+
return { normalized, posMap };
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Simple normalization without position map (for normalizing the search query).
|
|
285
|
+
*/
|
|
286
|
+
function normalizeForSearch(text) {
|
|
287
|
+
let result = '';
|
|
288
|
+
for (let i = 0; i < text.length; i++) {
|
|
289
|
+
const replacement = NORMALIZE_MAP[text[i]];
|
|
290
|
+
result += replacement ?? text[i];
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Finds all occurrences of textToFind in the document and returns them with
|
|
296
|
+
* surrounding context and mapped document indices.
|
|
297
|
+
*/
|
|
298
|
+
function findAllOccurrences(fullText, segments, textToFind) {
|
|
299
|
+
const CONTEXT_CHARS = 30;
|
|
300
|
+
const occurrences = [];
|
|
301
|
+
let searchFrom = 0;
|
|
302
|
+
while (true) {
|
|
303
|
+
const idx = fullText.indexOf(textToFind, searchFrom);
|
|
304
|
+
if (idx === -1)
|
|
305
|
+
break;
|
|
306
|
+
const docStart = mapFullTextPositionToDocIndex(idx, segments);
|
|
307
|
+
const docEnd = mapFullTextPositionToDocIndex(idx + textToFind.length, segments);
|
|
308
|
+
// Extract surrounding context
|
|
309
|
+
const contextStart = Math.max(0, idx - CONTEXT_CHARS);
|
|
310
|
+
const contextEnd = Math.min(fullText.length, idx + textToFind.length + CONTEXT_CHARS);
|
|
311
|
+
const before = fullText.slice(contextStart, idx).replace(/\n/g, '\\n');
|
|
312
|
+
const match = fullText.slice(idx, idx + textToFind.length).replace(/\n/g, '\\n');
|
|
313
|
+
const after = fullText.slice(idx + textToFind.length, contextEnd).replace(/\n/g, '\\n');
|
|
314
|
+
const context = `${contextStart > 0 ? '...' : ''}${before}[${match}]${after}${contextEnd < fullText.length ? '...' : ''}`;
|
|
315
|
+
occurrences.push({
|
|
316
|
+
instance: occurrences.length + 1,
|
|
317
|
+
startIndex: docStart,
|
|
318
|
+
endIndex: docEnd,
|
|
319
|
+
context,
|
|
320
|
+
});
|
|
321
|
+
searchFrom = idx + 1;
|
|
322
|
+
}
|
|
323
|
+
return occurrences;
|
|
324
|
+
}
|
|
325
|
+
export async function findTextRange(docs, documentId, textToFind, instance, tabId) {
|
|
326
|
+
try {
|
|
327
|
+
const result = await getDocumentTextAndSegments(docs, documentId, tabId);
|
|
328
|
+
if (!result) {
|
|
192
329
|
logger.warn(`No content found in document ${documentId}${tabId ? ` (tab: ${tabId})` : ''}`);
|
|
193
330
|
return null;
|
|
194
331
|
}
|
|
195
|
-
|
|
196
|
-
let fullText = '';
|
|
197
|
-
const segments = [];
|
|
198
|
-
// Process all content elements, including structural ones
|
|
199
|
-
const collectTextFromContent = (content) => {
|
|
200
|
-
content.forEach((element) => {
|
|
201
|
-
// Handle paragraph elements
|
|
202
|
-
if (element.paragraph?.elements) {
|
|
203
|
-
element.paragraph.elements.forEach((pe) => {
|
|
204
|
-
if (pe.textRun?.content && pe.startIndex !== undefined && pe.endIndex !== undefined) {
|
|
205
|
-
const content = pe.textRun.content;
|
|
206
|
-
fullText += content;
|
|
207
|
-
segments.push({
|
|
208
|
-
text: content,
|
|
209
|
-
start: pe.startIndex,
|
|
210
|
-
end: pe.endIndex,
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
// Handle table elements - this is simplified and might need expansion
|
|
216
|
-
if (element.table && element.table.tableRows) {
|
|
217
|
-
element.table.tableRows.forEach((row) => {
|
|
218
|
-
if (row.tableCells) {
|
|
219
|
-
row.tableCells.forEach((cell) => {
|
|
220
|
-
if (cell.content) {
|
|
221
|
-
collectTextFromContent(cell.content);
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
// Add handling for other structural elements as needed
|
|
228
|
-
});
|
|
229
|
-
};
|
|
230
|
-
collectTextFromContent(bodyContent);
|
|
231
|
-
// Sort segments by starting position to ensure correct ordering
|
|
232
|
-
segments.sort((a, b) => a.start - b.start);
|
|
332
|
+
const { fullText, segments } = result;
|
|
233
333
|
logger.debug(`Document ${documentId} contains ${segments.length} text segments and ${fullText.length} characters in total.`);
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
startIndex
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
logger.debug(`Mapped end to segment ${seg.start}-${seg.end}, position ${endIndex}`);
|
|
266
|
-
break;
|
|
267
|
-
}
|
|
268
|
-
currentPosInFullText = segEndInFullText;
|
|
269
|
-
}
|
|
270
|
-
if (startIndex === -1 || endIndex === -1) {
|
|
271
|
-
logger.warn(`Failed to map text "${textToFind}" instance ${instance} to actual document indices`);
|
|
272
|
-
return { startIndex, endIndex };
|
|
334
|
+
let allOccurrences = findAllOccurrences(fullText, segments, textToFind);
|
|
335
|
+
// Fallback: try normalized matching if exact match fails (issue #11)
|
|
336
|
+
if (allOccurrences.length === 0) {
|
|
337
|
+
const normalizedSearch = normalizeForSearch(textToFind);
|
|
338
|
+
const { normalized: normalizedFull, posMap } = normalizeWithPositionMap(fullText);
|
|
339
|
+
if (normalizedSearch !== textToFind || normalizedFull !== fullText) {
|
|
340
|
+
logger.debug(`Exact match failed, trying normalized match`);
|
|
341
|
+
// Find in normalized text, then map positions back to original
|
|
342
|
+
const CONTEXT_CHARS = 30;
|
|
343
|
+
let searchFrom = 0;
|
|
344
|
+
while (true) {
|
|
345
|
+
const idx = normalizedFull.indexOf(normalizedSearch, searchFrom);
|
|
346
|
+
if (idx === -1) break;
|
|
347
|
+
// Map normalized positions back to original fullText positions
|
|
348
|
+
const origStart = posMap[idx];
|
|
349
|
+
const origEnd = posMap[idx + normalizedSearch.length];
|
|
350
|
+
const docStart = mapFullTextPositionToDocIndex(origStart, segments);
|
|
351
|
+
const docEnd = mapFullTextPositionToDocIndex(origEnd, segments);
|
|
352
|
+
const contextStart = Math.max(0, origStart - CONTEXT_CHARS);
|
|
353
|
+
const contextEnd = Math.min(fullText.length, origEnd + CONTEXT_CHARS);
|
|
354
|
+
const before = fullText.slice(contextStart, origStart).replace(/\n/g, '\\n');
|
|
355
|
+
const match = fullText.slice(origStart, origEnd).replace(/\n/g, '\\n');
|
|
356
|
+
const after = fullText.slice(origEnd, contextEnd).replace(/\n/g, '\\n');
|
|
357
|
+
const context = `${contextStart > 0 ? '...' : ''}${before}[${match}]${after}${contextEnd < fullText.length ? '...' : ''}`;
|
|
358
|
+
allOccurrences.push({
|
|
359
|
+
instance: allOccurrences.length + 1,
|
|
360
|
+
startIndex: docStart,
|
|
361
|
+
endIndex: docEnd,
|
|
362
|
+
context,
|
|
363
|
+
});
|
|
364
|
+
searchFrom = idx + 1;
|
|
273
365
|
}
|
|
274
|
-
logger.debug(`Successfully mapped "${textToFind}" to document range ${startIndex}-${endIndex}`);
|
|
275
|
-
return { startIndex, endIndex };
|
|
276
366
|
}
|
|
277
|
-
// Prepare for next search iteration
|
|
278
|
-
searchStartIndex = currentIndex + 1;
|
|
279
367
|
}
|
|
280
|
-
|
|
281
|
-
|
|
368
|
+
if (allOccurrences.length === 0) {
|
|
369
|
+
logger.warn(`Text "${textToFind}" not found in document ${documentId}`);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
// If instance is not specified and there are multiple matches, return all of them
|
|
373
|
+
// so the caller can disambiguate
|
|
374
|
+
if (instance === undefined && allOccurrences.length > 1) {
|
|
375
|
+
const listing = allOccurrences.map((o) => ` ${o.instance}. index ${o.startIndex}-${o.endIndex}: ${o.context}`).join('\n');
|
|
376
|
+
throw new UserError(`Found ${allOccurrences.length} instances of "${textToFind}". ` +
|
|
377
|
+
`Specify matchInstance to target the correct one:\n${listing}`);
|
|
378
|
+
}
|
|
379
|
+
// Use instance 1 if not specified (single match case)
|
|
380
|
+
const targetInstance = instance ?? 1;
|
|
381
|
+
if (targetInstance > allOccurrences.length) {
|
|
382
|
+
logger.warn(`Requested instance ${targetInstance} but only ${allOccurrences.length} found`);
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
const match = allOccurrences[targetInstance - 1];
|
|
386
|
+
if (match.startIndex === -1 || match.endIndex === -1) {
|
|
387
|
+
logger.warn(`Failed to map text "${textToFind}" instance ${targetInstance} to actual document indices`);
|
|
388
|
+
return { startIndex: match.startIndex, endIndex: match.endIndex };
|
|
389
|
+
}
|
|
390
|
+
logger.debug(`Successfully mapped "${textToFind}" instance ${targetInstance} to document range ${match.startIndex}-${match.endIndex}`);
|
|
391
|
+
return { startIndex: match.startIndex, endIndex: match.endIndex };
|
|
282
392
|
}
|
|
283
393
|
catch (error) {
|
|
394
|
+
if (error instanceof UserError)
|
|
395
|
+
throw error;
|
|
284
396
|
logger.error(`Error finding text "${textToFind}" in doc ${documentId}: ${error.message || 'Unknown error'}`);
|
|
285
397
|
if (error.code === 404)
|
|
286
398
|
throw new UserError(`Document not found while searching text (ID: ${documentId}).`);
|
|
@@ -85,10 +85,30 @@ export async function insertMarkdown(docs, documentId, markdown, options) {
|
|
|
85
85
|
const overallStart = performance.now();
|
|
86
86
|
const startIndex = options?.startIndex ?? 1;
|
|
87
87
|
const tabId = options?.tabId;
|
|
88
|
+
// Fetch the document's default text style so we can explicitly set
|
|
89
|
+
// foreground color on inserted text (fixes issue #14 — text without
|
|
90
|
+
// explicit color shows "no color selected" in the Docs color picker).
|
|
91
|
+
let defaultForegroundColor;
|
|
92
|
+
try {
|
|
93
|
+
const styleRes = await docs.documents.get({
|
|
94
|
+
documentId,
|
|
95
|
+
fields: 'namedStyles',
|
|
96
|
+
});
|
|
97
|
+
const normalTextStyle = styleRes.data.namedStyles?.styles?.find(
|
|
98
|
+
(s) => s.namedStyleType === 'NORMAL_TEXT'
|
|
99
|
+
);
|
|
100
|
+
const fg = normalTextStyle?.textStyle?.foregroundColor?.color?.rgbColor;
|
|
101
|
+
if (fg) {
|
|
102
|
+
defaultForegroundColor = fg;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Non-fatal — if we can't read styles, proceed without explicit color
|
|
106
|
+
}
|
|
88
107
|
const parseStart = performance.now();
|
|
89
|
-
const conversionOptions =
|
|
90
|
-
|
|
91
|
-
|
|
108
|
+
const conversionOptions = {
|
|
109
|
+
...(options?.firstHeadingAsTitle && { firstHeadingAsTitle: true }),
|
|
110
|
+
...(defaultForegroundColor && { defaultForegroundColor }),
|
|
111
|
+
};
|
|
92
112
|
const requests = convertMarkdownToRequests(markdown, startIndex, tabId, conversionOptions);
|
|
93
113
|
const parseElapsedMs = Math.round(performance.now() - parseStart);
|
|
94
114
|
// Count requests by type
|
|
@@ -70,6 +70,7 @@ export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, optio
|
|
|
70
70
|
const parser = createParser();
|
|
71
71
|
const tokens = parser.parse(markdown, {});
|
|
72
72
|
const context = {
|
|
73
|
+
startIndex,
|
|
73
74
|
currentIndex: startIndex,
|
|
74
75
|
insertRequests: [],
|
|
75
76
|
formatRequests: [],
|
|
@@ -88,6 +89,7 @@ export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, optio
|
|
|
88
89
|
tabId,
|
|
89
90
|
titleConsumed: false,
|
|
90
91
|
firstHeadingAsTitle: options?.firstHeadingAsTitle ?? false,
|
|
92
|
+
defaultForegroundColor: options?.defaultForegroundColor ?? null,
|
|
91
93
|
};
|
|
92
94
|
try {
|
|
93
95
|
for (const token of tokens) {
|
|
@@ -624,6 +626,29 @@ function popFormatting(context, type) {
|
|
|
624
626
|
}
|
|
625
627
|
// --- Finalization ---
|
|
626
628
|
function finalizeFormatting(context) {
|
|
629
|
+
// Apply the document's default foreground color to the entire inserted range
|
|
630
|
+
// so text has an explicit color value in Google Docs (fixes issue #14).
|
|
631
|
+
// This goes first so intentional colors (code blocks, links) override it.
|
|
632
|
+
if (context.defaultForegroundColor && context.currentIndex > context.startIndex) {
|
|
633
|
+
const baseRange = {
|
|
634
|
+
startIndex: context.startIndex,
|
|
635
|
+
endIndex: context.currentIndex,
|
|
636
|
+
};
|
|
637
|
+
if (context.tabId) {
|
|
638
|
+
baseRange.tabId = context.tabId;
|
|
639
|
+
}
|
|
640
|
+
context.formatRequests.push({
|
|
641
|
+
updateTextStyle: {
|
|
642
|
+
range: baseRange,
|
|
643
|
+
textStyle: {
|
|
644
|
+
foregroundColor: {
|
|
645
|
+
color: { rgbColor: context.defaultForegroundColor },
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
fields: 'foregroundColor',
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
}
|
|
627
652
|
// Character-level formatting (bold, italic, strikethrough, code, links)
|
|
628
653
|
for (const range of context.textRanges) {
|
|
629
654
|
const rangeLocation = {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// In-memory tracker for read-before-edit guards (issue #18).
|
|
2
|
+
// Tracks which files have been read in this session and when.
|
|
3
|
+
import { UserError } from 'fastmcp';
|
|
4
|
+
import { getDriveClient } from './clients.js';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
|
|
7
|
+
// Map of fileId → { readAt: Date, modifiedTime: string (ISO) }
|
|
8
|
+
const readLog = new Map();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Record that a file was read. Call from all read tools.
|
|
12
|
+
*/
|
|
13
|
+
export function trackRead(fileId, modifiedTime) {
|
|
14
|
+
readLog.set(fileId, {
|
|
15
|
+
readAt: new Date(),
|
|
16
|
+
modifiedTime: modifiedTime || null,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Guard a mutation. Call from all mutating tools before making changes.
|
|
22
|
+
* Throws UserError if the file hasn't been read, or if it was modified externally.
|
|
23
|
+
*
|
|
24
|
+
* @param fileId - The file/document/spreadsheet ID
|
|
25
|
+
* @param opts.skipExternalCheck - If true, skip the Drive API modifiedTime check (for performance)
|
|
26
|
+
*/
|
|
27
|
+
export async function guardMutation(fileId, opts) {
|
|
28
|
+
const entry = readLog.get(fileId);
|
|
29
|
+
if (!entry) {
|
|
30
|
+
throw new UserError(
|
|
31
|
+
`This file (${fileId}) has not been read in this session. ` +
|
|
32
|
+
'Read it first before making changes to ensure you have current content. ' +
|
|
33
|
+
'Use readDocument, readSpreadsheet, readFile, or readDriveFile.'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Optionally check if file was modified externally since last read
|
|
38
|
+
if (!opts?.skipExternalCheck) {
|
|
39
|
+
try {
|
|
40
|
+
const drive = await getDriveClient();
|
|
41
|
+
const res = await drive.files.get({
|
|
42
|
+
fileId,
|
|
43
|
+
fields: 'modifiedTime',
|
|
44
|
+
supportsAllDrives: true,
|
|
45
|
+
});
|
|
46
|
+
const currentModifiedTime = res.data.modifiedTime;
|
|
47
|
+
if (entry.modifiedTime && currentModifiedTime !== entry.modifiedTime) {
|
|
48
|
+
const readAt = entry.readAt.toISOString();
|
|
49
|
+
throw new UserError(
|
|
50
|
+
`This file was modified externally since you last read it ` +
|
|
51
|
+
`(last read: ${readAt}, last modified: ${currentModifiedTime}). ` +
|
|
52
|
+
'Read the file again before editing to ensure you have current content.'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
// Update modifiedTime on successful check
|
|
56
|
+
entry.modifiedTime = currentModifiedTime;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof UserError) throw error;
|
|
59
|
+
// If we can't check, log warning but allow the mutation
|
|
60
|
+
logger.warn(`Could not verify modifiedTime for ${fileId}: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Update the read tracker after a successful mutation (so subsequent mutations
|
|
67
|
+
* don't fail the external-change check against our own changes).
|
|
68
|
+
*/
|
|
69
|
+
export function trackMutation(fileId) {
|
|
70
|
+
const entry = readLog.get(fileId);
|
|
71
|
+
if (entry) {
|
|
72
|
+
entry.readAt = new Date();
|
|
73
|
+
// Clear modifiedTime — it will be stale after our mutation.
|
|
74
|
+
// The next guardMutation call will fetch fresh modifiedTime.
|
|
75
|
+
entry.modifiedTime = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if a file has been read (without throwing).
|
|
81
|
+
*/
|
|
82
|
+
export function hasBeenRead(fileId) {
|
|
83
|
+
return readLog.has(fileId);
|
|
84
|
+
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
1
2
|
import { UserError } from 'fastmcp';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { getDocsClient } from '../../clients.js';
|
|
4
5
|
import { DocumentIdParameter, NotImplementedError } from '../../types.js';
|
|
5
6
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
7
|
+
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
6
8
|
export function register(server) {
|
|
7
9
|
server.addTool({
|
|
8
10
|
name: 'appendText',
|
|
9
|
-
description: 'Appends plain text to the end of a document. For formatted content, use appendMarkdown instead.'
|
|
11
|
+
description: 'Appends plain text to the end of a document. For formatted content, use appendMarkdown instead. ' +
|
|
12
|
+
'To edit existing content, use modifyText (single-location) or replaceDocumentWithMarkdown (section/full rewrite).',
|
|
10
13
|
parameters: DocumentIdParameter.extend({
|
|
11
|
-
text: z.string().
|
|
14
|
+
text: z.string().optional().describe('The plain text to append to the end of the document. For content longer than ~2000 characters, prefer writing to a local file first and passing filePath instead.'),
|
|
15
|
+
filePath: z.string().optional().describe('Path to a local text file to use as content. Takes precedence over the text parameter.'),
|
|
12
16
|
addNewlineIfNeeded: z
|
|
13
17
|
.boolean()
|
|
14
18
|
.optional()
|
|
@@ -20,7 +24,21 @@ export function register(server) {
|
|
|
20
24
|
.describe('The ID of the specific tab to append to. If not specified, appends to the first tab (or legacy document.body for documents without tabs).'),
|
|
21
25
|
}),
|
|
22
26
|
execute: async (args, { log }) => {
|
|
27
|
+
await guardMutation(args.documentId);
|
|
23
28
|
const docs = await getDocsClient();
|
|
29
|
+
// Resolve text content from filePath or inline parameter
|
|
30
|
+
let text = args.text;
|
|
31
|
+
if (args.filePath) {
|
|
32
|
+
try {
|
|
33
|
+
text = await fs.readFile(args.filePath, 'utf-8');
|
|
34
|
+
log.info(`Read ${text.length} chars from file: ${args.filePath}`);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new UserError(`Failed to read file at "${args.filePath}": ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!text || text.length === 0) {
|
|
40
|
+
throw new UserError('Either text or filePath must be provided with non-empty content.');
|
|
41
|
+
}
|
|
24
42
|
log.info(`Appending to Google Doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
|
|
25
43
|
try {
|
|
26
44
|
// Determine if we need tabs content
|
|
@@ -54,7 +72,7 @@ export function register(server) {
|
|
|
54
72
|
}
|
|
55
73
|
}
|
|
56
74
|
// Simpler approach: Always assume insertion is needed unless explicitly told not to add newline
|
|
57
|
-
const textToInsert = (args.addNewlineIfNeeded && endIndex > 1 ? '\n' : '') +
|
|
75
|
+
const textToInsert = (args.addNewlineIfNeeded && endIndex > 1 ? '\n' : '') + text;
|
|
58
76
|
if (!textToInsert)
|
|
59
77
|
return 'Nothing to append.';
|
|
60
78
|
const location = { index: endIndex };
|
|
@@ -65,6 +83,7 @@ export function register(server) {
|
|
|
65
83
|
insertText: { location, text: textToInsert },
|
|
66
84
|
};
|
|
67
85
|
await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
|
|
86
|
+
trackMutation(args.documentId);
|
|
68
87
|
log.info(`Successfully appended to doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
|
|
69
88
|
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
70
89
|
return `${docUrl}\nSuccessfully appended text to ${args.tabId ? `tab ${args.tabId} in ` : ''}document ${args.documentId}.`;
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getDocsClient } from '../../clients.js';
|
|
4
4
|
import { DocumentIdParameter } from '../../types.js';
|
|
5
5
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
6
7
|
export function register(server) {
|
|
7
8
|
server.addTool({
|
|
8
9
|
name: 'deleteRange',
|
|
@@ -27,6 +28,7 @@ export function register(server) {
|
|
|
27
28
|
path: ['endIndex'],
|
|
28
29
|
}),
|
|
29
30
|
execute: async (args, { log }) => {
|
|
31
|
+
await guardMutation(args.documentId);
|
|
30
32
|
const docs = await getDocsClient();
|
|
31
33
|
log.info(`Deleting range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
|
|
32
34
|
if (args.endIndex <= args.startIndex) {
|
|
@@ -59,6 +61,7 @@ export function register(server) {
|
|
|
59
61
|
deleteContentRange: { range },
|
|
60
62
|
};
|
|
61
63
|
await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
|
|
64
|
+
trackMutation(args.documentId);
|
|
62
65
|
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
63
66
|
return `${docUrl}\nSuccessfully deleted content in range ${args.startIndex}-${args.endIndex}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
|
|
64
67
|
}
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getDocsClient } from '../../clients.js';
|
|
4
4
|
import { DocumentIdParameter } from '../../types.js';
|
|
5
5
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
6
7
|
const FindAndReplaceParameters = DocumentIdParameter.extend({
|
|
7
8
|
findText: z.string().min(1).describe('The text to search for in the document.'),
|
|
8
9
|
replaceText: z
|
|
@@ -24,6 +25,7 @@ export function register(server) {
|
|
|
24
25
|
'Returns the number of replacements made. Use an empty replaceText to delete all matches.',
|
|
25
26
|
parameters: FindAndReplaceParameters,
|
|
26
27
|
execute: async (args, { log }) => {
|
|
28
|
+
await guardMutation(args.documentId);
|
|
27
29
|
const docs = await getDocsClient();
|
|
28
30
|
log.info(`findAndReplace in doc ${args.documentId}: "${args.findText}" → "${args.replaceText}"` +
|
|
29
31
|
`${args.matchCase ? ' (case-sensitive)' : ''}` +
|
|
@@ -40,6 +42,7 @@ export function register(server) {
|
|
|
40
42
|
},
|
|
41
43
|
};
|
|
42
44
|
const response = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
|
|
45
|
+
trackMutation(args.documentId);
|
|
43
46
|
const changed = response.replies?.[0]?.replaceAllText?.occurrencesChanged ?? 0;
|
|
44
47
|
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
45
48
|
return `${docUrl}\nReplaced ${changed} occurrence(s) of "${args.findText}" with "${args.replaceText}".`;
|
|
@@ -18,8 +18,8 @@ export function register(server) {
|
|
|
18
18
|
// STEP 1: Determine the target paragraph's range based on the targeting method
|
|
19
19
|
if ('textToFind' in args.target) {
|
|
20
20
|
// Find the text first
|
|
21
|
-
log.info(`Finding text "${args.target.textToFind}" (instance ${args.target.matchInstance
|
|
22
|
-
const textRange = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance
|
|
21
|
+
log.info(`Finding text "${args.target.textToFind}" (instance ${args.target.matchInstance ?? 'auto'})${args.tabId ? ` in tab ${args.tabId}` : ''}`);
|
|
22
|
+
const textRange = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance, args.tabId);
|
|
23
23
|
if (!textRange) {
|
|
24
24
|
throw new UserError(`Could not find "${args.target.textToFind}" in the document${args.tabId ? ` (tab: ${args.tabId})` : ''}.`);
|
|
25
25
|
}
|