diff --git a/docs/index.md b/docs/index.md index 7d7b622..78b3481 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,9 @@ The extension provides the following tools: - `docs.getText`: Retrieves the text content of a Google Doc. - `docs.replaceText`: Replaces all occurrences of a given text with new text in a Google Doc. +- `docs.appendMarkdown`: Appends markdown-formatted content as natively + formatted Docs text (headings, bold/italic/strikethrough/code, links, lists) + in a single call. - `docs.formatText`: Applies formatting (bold, italic, headings, etc.) to text ranges in a Google Doc. diff --git a/workspace-server/src/__tests__/services/DocsService.test.ts b/workspace-server/src/__tests__/services/DocsService.test.ts index d3aa66e..67f9029 100644 --- a/workspace-server/src/__tests__/services/DocsService.test.ts +++ b/workspace-server/src/__tests__/services/DocsService.test.ts @@ -1158,4 +1158,183 @@ describe('DocsService', () => { }); }); }); + + describe('appendMarkdown', () => { + const emptyDocTabs = { + data: { + tabs: [ + { + tabProperties: { tabId: 't.0' }, + documentTab: { + body: { content: [{ startIndex: 1, endIndex: 2 }] }, + }, + }, + ], + }, + }; + + it('inserts stripped text once and styles headings, inline ranges, and merged bullets', async () => { + mockDocsAPI.documents.get.mockResolvedValue(emptyDocTabs); + mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} }); + + const result = await docsService.appendMarkdown({ + documentId: 'test-doc-id', + markdown: '# Title\nHello **world**\n- a\n- b', + }); + + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledTimes(2); + + const insertCall = mockDocsAPI.documents.batchUpdate.mock.calls[0][0]; + expect(insertCall.requestBody.requests).toEqual([ + { + insertText: { + location: { index: 1 }, + text: 'Title\nHello world\na\nb\n', + }, + }, + ]); + + const styleCall = mockDocsAPI.documents.batchUpdate.mock.calls[1][0]; + expect(styleCall.requestBody.requests).toEqual([ + { + updateParagraphStyle: { + range: { startIndex: 1, endIndex: 7 }, + paragraphStyle: { namedStyleType: 'HEADING_1' }, + fields: 'namedStyleType', + }, + }, + { + updateTextStyle: { + range: { startIndex: 13, endIndex: 18 }, + textStyle: { bold: true }, + fields: 'bold', + }, + }, + { + createParagraphBullets: { + range: { startIndex: 19, endIndex: 23 }, + bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE', + }, + }, + ]); + + const payload = JSON.parse(result.content[0].text); + expect(payload).toEqual({ + documentId: 'test-doc-id', + blocksProcessed: 4, + headings: 1, + styledRanges: 1, + bulletLists: 1, + skippedHorizontalRules: 0, + }); + }); + + it('threads tabId through locations and ranges', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + tabs: [ + { + tabProperties: { tabId: 't.0' }, + documentTab: { + body: { content: [{ startIndex: 1, endIndex: 2 }] }, + }, + }, + { + tabProperties: { tabId: 't.target' }, + documentTab: { + body: { content: [{ startIndex: 1, endIndex: 10 }] }, + }, + }, + ], + }, + }); + mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} }); + + await docsService.appendMarkdown({ + documentId: 'test-doc-id', + markdown: '# Hi', + tabId: 't.target', + }); + + const insertCall = mockDocsAPI.documents.batchUpdate.mock.calls[0][0]; + const insertReq = insertCall.requestBody.requests[0].insertText; + expect(insertReq.location).toEqual({ index: 9, tabId: 't.target' }); + // Non-empty tab: a newline is prepended so the heading does not merge + // into the existing last paragraph, shifting the style range by 1. + expect(insertReq.text).toBe('\nHi\n'); + const styleCall = mockDocsAPI.documents.batchUpdate.mock.calls[1][0]; + expect( + styleCall.requestBody.requests[0].updateParagraphStyle.range, + ).toEqual({ startIndex: 10, endIndex: 13, tabId: 't.target' }); + }); + + it('skips the separator when the trailing paragraph is already empty', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + tabs: [ + { + tabProperties: { tabId: 't.0' }, + documentTab: { + body: { content: [{ startIndex: 8, endIndex: 9 }] }, + }, + }, + ], + }, + }); + mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} }); + + await docsService.appendMarkdown({ + documentId: 'test-doc-id', + markdown: 'plain', + }); + + const insertCall = mockDocsAPI.documents.batchUpdate.mock.calls[0][0]; + expect(insertCall.requestBody.requests[0].insertText).toEqual({ + location: { index: 8 }, + text: 'plain\n', + }); + }); + + it('errors when the tab is not found', async () => { + mockDocsAPI.documents.get.mockResolvedValue(emptyDocTabs); + + const result = await docsService.appendMarkdown({ + documentId: 'test-doc-id', + markdown: 'hello', + tabId: 't.missing', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Tab with ID t.missing'); + expect(mockDocsAPI.documents.batchUpdate).not.toHaveBeenCalled(); + }); + + it('returns zero counts without API calls when only horizontal rules remain', async () => { + const result = await docsService.appendMarkdown({ + documentId: 'test-doc-id', + markdown: '---\n\n***', + }); + + expect(mockDocsAPI.documents.get).not.toHaveBeenCalled(); + expect(mockDocsAPI.documents.batchUpdate).not.toHaveBeenCalled(); + const payload = JSON.parse(result.content[0].text); + expect(payload.blocksProcessed).toBe(0); + expect(payload.skippedHorizontalRules).toBe(2); + }); + + it('handles errors gracefully', async () => { + mockDocsAPI.documents.get.mockResolvedValue(emptyDocTabs); + mockDocsAPI.documents.batchUpdate.mockRejectedValue( + new Error('Markdown Error'), + ); + + const result = await docsService.appendMarkdown({ + documentId: 'test-doc-id', + markdown: 'hello', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Markdown Error'); + }); + }); }); diff --git a/workspace-server/src/__tests__/utils/markdown.test.ts b/workspace-server/src/__tests__/utils/markdown.test.ts new file mode 100644 index 0000000..e5ca2b2 --- /dev/null +++ b/workspace-server/src/__tests__/utils/markdown.test.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from '@jest/globals'; +import { parseMarkdownBlocks, stripInlineMarkdown } from '../../utils/markdown'; + +describe('stripInlineMarkdown', () => { + it('strips bold, italic, strikethrough, and code markers and records ranges', () => { + const out = stripInlineMarkdown('a **b** *c* ~~d~~ `e`'); + expect(out.text).toBe('a b c d e'); + expect(out.formats).toEqual([ + { type: 'bold', start: 2, end: 3 }, + { type: 'italic', start: 4, end: 5 }, + { type: 'strikethrough', start: 6, end: 7 }, + { type: 'code', start: 8, end: 9 }, + ]); + }); + + it('records links with their URL', () => { + const out = stripInlineMarkdown('see [the docs](https://example.com) now'); + expect(out.text).toBe('see the docs now'); + expect(out.formats).toEqual([ + { type: 'link', start: 4, end: 12, url: 'https://example.com' }, + ]); + }); + + it('supports underscore italics', () => { + const out = stripInlineMarkdown('_hi_'); + expect(out.text).toBe('hi'); + expect(out.formats).toEqual([{ type: 'italic', start: 0, end: 2 }]); + }); + + it('supports double-underscore bold', () => { + const out = stripInlineMarkdown('__hi__'); + expect(out.text).toBe('hi'); + expect(out.formats).toEqual([{ type: 'bold', start: 0, end: 2 }]); + }); + + it('leaves markers with no closer as literal text', () => { + const out = stripInlineMarkdown('a *b and c'); + expect(out.text).toBe('a *b and c'); + expect(out.formats).toEqual([]); + }); + + it('degrades an unclosed double marker to a literal star plus a paired single marker', () => { + // "**" has no closing "**", so the first star stays literal and the + // second pairs with the lone star later in the line. + const out = stripInlineMarkdown('a **b and *c'); + expect(out.text).toBe('a *b and c'); + expect(out.formats).toEqual([{ type: 'italic', start: 3, end: 9 }]); + }); + + it('treats empty markers as literal text', () => { + const out = stripInlineMarkdown('****'); + expect(out.text).toBe('****'); + expect(out.formats).toEqual([]); + }); +}); + +describe('parseMarkdownBlocks', () => { + it('parses headings with levels and stripped inline formats', () => { + const blocks = parseMarkdownBlocks('# Title\n###### **Deep**'); + expect(blocks).toEqual([ + { type: 'heading', level: 1, text: 'Title', formats: [] }, + { + type: 'heading', + level: 6, + text: 'Deep', + formats: [{ type: 'bold', start: 0, end: 4 }], + }, + ]); + }); + + it('emits one block per list item, allowing leading whitespace', () => { + const blocks = parseMarkdownBlocks('- a\n - b\n1. one\n2. two'); + expect(blocks).toEqual([ + { type: 'bullet', text: 'a', formats: [] }, + { type: 'bullet', text: 'b', formats: [] }, + { type: 'numbered', text: 'one', formats: [] }, + { type: 'numbered', text: 'two', formats: [] }, + ]); + }); + + it('parses horizontal rules and skips blank lines', () => { + const blocks = parseMarkdownBlocks('\n---\n\n***\n'); + expect(blocks).toEqual([{ type: 'hr' }, { type: 'hr' }]); + }); + + it('falls through to paragraphs (including pipe rows, which are not parsed as tables)', () => { + const blocks = parseMarkdownBlocks('plain text\n| a | b |'); + expect(blocks).toEqual([ + { type: 'paragraph', text: 'plain text', formats: [] }, + { type: 'paragraph', text: '| a | b |', formats: [] }, + ]); + }); + + it('parses a mixed document in order', () => { + const blocks = parseMarkdownBlocks('# T\n\nintro **x**\n\n- a\n- b'); + expect(blocks.map((b) => b.type)).toEqual([ + 'heading', + 'paragraph', + 'bullet', + 'bullet', + ]); + }); +}); diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index dc9eef5..f534c57 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -73,6 +73,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'docs.writeText', 'docs.replaceText', 'docs.formatText', + 'docs.appendMarkdown', ], defaultEnabled: true, }, diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index e7a8077..23c8d35 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -444,6 +444,25 @@ async function main() { docsService.formatText, ); + registerTool( + 'docs.appendMarkdown', + { + description: + 'Appends markdown-formatted content to a Google Doc as natively formatted text in one call: headings (#-######), **bold**, *italic*, ~~strikethrough~~, `inline code`, [links](url), bullet (- item) and numbered (1. item) lists. Prefer this over docs.writeText + docs.formatText when writing new formatted content, since no character-index math is needed. Horizontal rules are skipped; tables degrade to plain text.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + markdown: z.string().min(1).describe('The markdown content to append.'), + tabId: z + .string() + .optional() + .describe( + 'The ID of the tab to append to. If not provided, appends to the first tab.', + ), + }, + }, + docsService.appendMarkdown, + ); + // Slides tools registerTool( 'slides.getText', diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 72350bb..b742506 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -10,6 +10,7 @@ import { logToFile } from '../utils/logger'; import { extractDocId } from '../utils/IdUtils'; import { gaxiosOptions } from '../utils/GaxiosConfig'; import { extractDocumentId as validateAndExtractDocId } from '../utils/validation'; +import { parseMarkdownBlocks } from '../utils/markdown'; // Field mask for documents.get when reading tab content. Selects only the // structural fields we use; broader masks like 'tabs' alone trigger @@ -402,6 +403,251 @@ export class DocsService { strikethrough: { strikethrough: true }, }; + /** + * Appends markdown-formatted content as natively formatted Docs content. + * One insertText with the stripped plain text, then one batchUpdate with + * style requests computed from parse offsets, so callers never do index + * arithmetic. Horizontal rules are skipped (no Docs API request exists for + * them); tables are not parsed and degrade to plain paragraphs. + */ + public appendMarkdown = async ({ + documentId, + markdown, + tabId, + }: { + documentId: string; + markdown: string; + tabId?: string; + }) => { + logToFile( + `[DocsService] Starting appendMarkdown for document: ${documentId}, tabId: ${tabId}`, + ); + try { + const id = extractDocId(documentId) || documentId; + const blocks = parseMarkdownBlocks(markdown); + const renderable = blocks.filter((b) => b.type !== 'hr') as Array< + Exclude[number], { type: 'hr' }> + >; + const skippedHorizontalRules = blocks.length - renderable.length; + + if (renderable.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: id, + blocksProcessed: 0, + skippedHorizontalRules, + }), + }, + ], + }; + } + + const docs = await this.getDocsClient(); + + // Discover the end index of the target tab's body (same approach as + // writeText's end-position branch). + const res = await docs.documents.get({ + documentId: id, + fields: TABS_FIELD_MASK, + includeTabsContent: true, + }); + const tabs = this._flattenTabs(res.data.tabs || []); + let bodyContent: docs_v1.Schema$StructuralElement[] | undefined; + if (tabId) { + const tab = tabs.find((t) => t.tabProperties?.tabId === tabId); + if (!tab) { + throw new Error(`Tab with ID ${tabId} not found.`); + } + bodyContent = tab.documentTab?.body?.content; + } else if (tabs.length > 0) { + bodyContent = tabs[0].documentTab?.body?.content; + } + const lastElement = bodyContent?.[bodyContent.length - 1]; + const insertAt = Math.max(1, (lastElement?.endIndex || 1) - 1); + + // When the last paragraph has visible content (insertAt past its + // start), a separating newline keeps the first markdown block from + // merging into it (and its block style from leaking onto existing + // text). An already-empty trailing paragraph needs no separator: a + // prefix there would leave a stray blank line. + const prefix = insertAt > (lastElement?.startIndex ?? 0) ? '\n' : ''; + + // One insertText with the full plain text; offsets let us compute every + // style range afterwards. + let fullText = prefix; + const offsets: number[] = []; + for (const block of renderable) { + offsets.push(fullText.length); + fullText += block.text + '\n'; + } + + const loc = (index: number) => (tabId ? { index, tabId } : { index }); + const range = (startIndex: number, endIndex: number) => + tabId ? { startIndex, endIndex, tabId } : { startIndex, endIndex }; + + await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests: [ + { insertText: { location: loc(insertAt), text: fullText } }, + ], + }, + }); + + const HEADING_TYPES = [ + '', + 'HEADING_1', + 'HEADING_2', + 'HEADING_3', + 'HEADING_4', + 'HEADING_5', + 'HEADING_6', + ]; + const requests: docs_v1.Schema$Request[] = []; + let headings = 0; + let styledRanges = 0; + + renderable.forEach((block, k) => { + const bStart = insertAt + offsets[k]; + const bEnd = bStart + block.text.length + 1; + + if (block.type === 'heading') { + requests.push({ + updateParagraphStyle: { + range: range(bStart, bEnd), + paragraphStyle: { + namedStyleType: HEADING_TYPES[block.level] || 'HEADING_1', + }, + fields: 'namedStyleType', + }, + }); + headings++; + } + + for (const f of block.formats) { + const fS = bStart + f.start; + const fE = bStart + f.end; + if (fS >= fE) continue; + let textStyle: docs_v1.Schema$TextStyle; + let fields: string; + switch (f.type) { + case 'bold': + textStyle = { bold: true }; + fields = 'bold'; + break; + case 'italic': + textStyle = { italic: true }; + fields = 'italic'; + break; + case 'strikethrough': + textStyle = { strikethrough: true }; + fields = 'strikethrough'; + break; + case 'link': + textStyle = { link: { url: f.url } }; + fields = 'link'; + break; + case 'code': + textStyle = { + weightedFontFamily: { fontFamily: 'Roboto Mono' }, + backgroundColor: { + color: { rgbColor: { red: 0.95, green: 0.95, blue: 0.95 } }, + }, + }; + fields = 'weightedFontFamily,backgroundColor'; + break; + } + requests.push({ + updateTextStyle: { range: range(fS, fE), textStyle, fields }, + }); + styledRanges++; + } + }); + + // Merge consecutive list items of the same kind into single + // createParagraphBullets ranges, applied last in the batch. + let bulletLists = 0; + let runType: 'bullet' | 'numbered' | null = null; + let runStart = 0; + let runEnd = 0; + const flushRun = () => { + if (!runType) return; + requests.push({ + createParagraphBullets: { + range: range(runStart, runEnd), + bulletPreset: + runType === 'bullet' + ? 'BULLET_DISC_CIRCLE_SQUARE' + : 'NUMBERED_DECIMAL_ALPHA_ROMAN', + }, + }); + bulletLists++; + runType = null; + }; + renderable.forEach((block, k) => { + const bStart = insertAt + offsets[k]; + const bEnd = bStart + block.text.length + 1; + if (block.type === 'bullet' || block.type === 'numbered') { + if (runType === block.type) { + runEnd = bEnd; + } else { + flushRun(); + runType = block.type; + runStart = bStart; + runEnd = bEnd; + } + } else { + flushRun(); + } + }); + flushRun(); + + if (requests.length > 0) { + await docs.documents.batchUpdate({ + documentId: id, + requestBody: { requests }, + }); + } + + logToFile( + `[DocsService] Finished appendMarkdown for document: ${id} (${renderable.length} blocks)`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: id, + blocksProcessed: renderable.length, + headings, + styledRanges, + bulletLists, + skippedHorizontalRules, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[DocsService] Error during docs.appendMarkdown: ${errorMessage}`, + ); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + public formatText = async ({ documentId, formats, diff --git a/workspace-server/src/utils/markdown.ts b/workspace-server/src/utils/markdown.ts new file mode 100644 index 0000000..1feb938 --- /dev/null +++ b/workspace-server/src/utils/markdown.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Minimal markdown parser for docs.appendMarkdown. + * + * Parses a markdown string into a flat list of blocks (headings, list items, + * paragraphs, horizontal rules) with inline formatting recorded as character + * ranges over the stripped plain text. The ranges map directly onto Docs API + * updateTextStyle requests, so no index arithmetic ever reaches the model. + * + * Intentionally simple, single-pass semantics: + * - Inline markers do not nest (e.g. italic inside bold is not split out). + * - Tables are not parsed; pipe rows fall through as plain paragraphs. + */ + +export interface InlineFormat { + type: 'bold' | 'italic' | 'strikethrough' | 'code' | 'link'; + /** Start offset (inclusive) within the stripped plain text of the block. */ + start: number; + /** End offset (exclusive) within the stripped plain text of the block. */ + end: number; + url?: string; +} + +export type MarkdownBlock = + | { + type: 'heading'; + level: number; + text: string; + formats: InlineFormat[]; + } + | { + type: 'bullet' | 'numbered' | 'paragraph'; + text: string; + formats: InlineFormat[]; + } + | { type: 'hr' }; + +const LINK_RE = /^\[([^\]]+)\]\(([^)]+)\)/; + +/** + * Strips inline markdown markers from a single line of text, recording the + * formatted ranges over the stripped output. + */ +export function stripInlineMarkdown(input: string): { + text: string; + formats: InlineFormat[]; +} { + const formats: InlineFormat[] = []; + let out = ''; + let i = 0; + + const tryMarker = (marker: string, type: InlineFormat['type']): boolean => { + if (!input.startsWith(marker, i)) return false; + const close = input.indexOf(marker, i + marker.length); + // No closer, or empty content ("****"): treat as literal text. + if (close === -1 || close === i + marker.length) return false; + const inner = input.slice(i + marker.length, close); + const start = out.length; + out += inner; + formats.push({ type, start, end: out.length }); + i = close + marker.length; + return true; + }; + + while (i < input.length) { + if (input[i] === '[') { + const m = LINK_RE.exec(input.slice(i)); + if (m) { + const start = out.length; + out += m[1]; + formats.push({ type: 'link', start, end: out.length, url: m[2] }); + i += m[0].length; + continue; + } + } + if (tryMarker('**', 'bold')) continue; + if (tryMarker('__', 'bold')) continue; + if (tryMarker('~~', 'strikethrough')) continue; + if (tryMarker('`', 'code')) continue; + if (tryMarker('*', 'italic')) continue; + if (tryMarker('_', 'italic')) continue; + out += input[i]; + i++; + } + + return { text: out, formats }; +} + +/** + * Parses markdown into a flat list of blocks. One block per list item + * (consecutive items are merged into a single createParagraphBullets range by + * the consumer), blank lines are skipped. + */ +export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] { + const blocks: MarkdownBlock[] = []; + + for (const line of markdown.split('\n')) { + if (line.trim() === '') continue; + + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line.trim())) { + blocks.push({ type: 'hr' }); + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + const parsed = stripInlineMarkdown(heading[2]); + blocks.push({ + type: 'heading', + level: heading[1].length, + text: parsed.text, + formats: parsed.formats, + }); + continue; + } + + const bullet = line.match(/^\s*[-*+]\s+(.*)$/); + if (bullet) { + const parsed = stripInlineMarkdown(bullet[1]); + blocks.push({ + type: 'bullet', + text: parsed.text, + formats: parsed.formats, + }); + continue; + } + + const numbered = line.match(/^\s*\d+\.\s+(.*)$/); + if (numbered) { + const parsed = stripInlineMarkdown(numbered[1]); + blocks.push({ + type: 'numbered', + text: parsed.text, + formats: parsed.formats, + }); + continue; + } + + const parsed = stripInlineMarkdown(line); + blocks.push({ + type: 'paragraph', + text: parsed.text, + formats: parsed.formats, + }); + } + + return blocks; +}