Spaces:
Sleeping
Sleeping
File size: 7,501 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 | import type { FastMCP } from 'fastmcp';
import { UserError } from 'fastmcp';
import { z } from 'zod';
import { getDocsClient } from '../../clients.js';
import { DocumentIdParameter, MarkdownConversionError } from '../../types.js';
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
import { insertMarkdown, formatInsertResult } from '../../markdown-transformer/index.js';
import { TAB_BODY_RANGE_FIELDS } from '../docs/tabFieldMasks.js';
export function register(server: FastMCP) {
server.addTool({
name: 'replaceDocumentWithMarkdown',
description:
"Replaces the entire document body with content parsed from markdown. Supports headings, bold, italic, strikethrough, links, and bullet/numbered lists. Use readDocument with format='markdown' first to get the current content, edit it, then call this tool to apply changes.",
parameters: DocumentIdParameter.extend({
markdown: z
.string()
.min(1)
.max(500000)
.describe('The markdown content to apply to the document.'),
preserveTitle: z
.boolean()
.optional()
.default(false)
.describe('If true, preserves the first heading/title and replaces content after it.'),
tabId: z
.string()
.optional()
.describe(
'The ID of the specific tab to replace content in. If not specified, replaces content in the first tab.'
),
firstHeadingAsTitle: z
.boolean()
.optional()
.default(true)
.describe(
'If true (default), the first H1 heading (# ...) in the markdown is styled as a Google Docs TITLE instead of Heading 1. Useful when the markdown represents a full document whose first line is the document title. Set to false if the first H1 should remain a Heading 1.'
),
}),
execute: async (args, { log }) => {
const docs = await getDocsClient();
log.info(
`Replacing doc ${args.documentId} with markdown (${args.markdown.length} chars)${args.tabId ? ` in tab ${args.tabId}` : ''}`
);
try {
// 1. Get document structure
const doc = await docs.documents.get({
documentId: args.documentId,
includeTabsContent: !!args.tabId,
suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS',
fields: args.tabId ? TAB_BODY_RANGE_FIELDS : 'body(content(startIndex,endIndex))',
});
// 2. Calculate replacement range
let startIndex = 1;
let bodyContent: any;
if (args.tabId) {
const targetTab = GDocsHelpers.findTabById(doc.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).`
);
}
bodyContent = targetTab.documentTab.body?.content;
} else {
bodyContent = doc.data.body?.content;
}
if (!bodyContent) {
throw new UserError('No content found in document/tab');
}
let endIndex = bodyContent[bodyContent.length - 1].endIndex! - 1;
if (args.preserveTitle) {
// Find first content element that's a heading or paragraph
for (const element of bodyContent) {
if (element.paragraph && element.endIndex) {
startIndex = element.endIndex;
break;
}
}
}
// 3. Delete existing content
if (endIndex > startIndex) {
const deleteRange: any = { startIndex, endIndex };
if (args.tabId) {
deleteRange.tabId = args.tabId;
}
log.info(`Deleting content from index ${startIndex} to ${endIndex}`);
await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [
{
deleteContentRange: { range: deleteRange },
},
]);
log.info(`Delete complete.`);
}
// 4. Clean the surviving trailing paragraph.
// deleteContentRange always leaves one trailing paragraph that cannot
// be deleted. If it has bullet list membership or text formatting from
// the old content, all subsequently inserted text inherits those
// properties, corrupting the new document. We strip both bullets and
// text styles from the survivor before inserting.
{
// Re-read to get the survivor's endIndex (always a short document now)
const docAfterDelete = await docs.documents.get({
documentId: args.documentId,
includeTabsContent: !!args.tabId,
suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS',
fields: args.tabId ? TAB_BODY_RANGE_FIELDS : 'body(content(startIndex,endIndex))',
});
let survivorContent: any;
if (args.tabId) {
const tab = GDocsHelpers.findTabById(docAfterDelete.data, args.tabId);
survivorContent = tab?.documentTab?.body?.content;
} else {
survivorContent = docAfterDelete.data.body?.content;
}
const survivorEnd = survivorContent
? survivorContent[survivorContent.length - 1].endIndex!
: startIndex + 1;
const survivorRange: any = { startIndex, endIndex: survivorEnd };
if (args.tabId) {
survivorRange.tabId = args.tabId;
}
const cleanupRequests: any[] = [
{ deleteParagraphBullets: { range: survivorRange } },
{
updateTextStyle: {
range: survivorRange,
textStyle: {
underline: false,
bold: false,
italic: false,
strikethrough: false,
foregroundColor: {},
backgroundColor: {},
},
fields: 'underline,bold,italic,strikethrough,foregroundColor,backgroundColor',
},
},
];
try {
await GDocsHelpers.executeBatchUpdate(docs, args.documentId, cleanupRequests);
log.info(
`Cleaned surviving paragraph (bullets + text style) at range ${startIndex}-${survivorEnd}`
);
} catch (e: any) {
log.info(`Survivor cleanup skipped: ${e.message}`);
}
}
// 5. Convert markdown and insert (indices calculated for empty document)
log.info(
`Inserting markdown starting at index ${startIndex} (after delete, document should be empty)`
);
const result = await insertMarkdown(docs, args.documentId, args.markdown, {
startIndex,
tabId: args.tabId,
firstHeadingAsTitle: args.firstHeadingAsTitle,
});
const debugSummary = formatInsertResult(result);
log.info(debugSummary);
return `Successfully replaced document content with ${args.markdown.length} characters of markdown.\n\n${debugSummary}`;
} catch (error: any) {
log.error(`Error replacing document with markdown: ${error.message}`);
if (error instanceof UserError || error instanceof MarkdownConversionError) {
throw error;
}
throw new UserError(`Failed to apply markdown: ${error.message || 'Unknown error'}`);
}
},
});
}
|