From c7d201992a002f95d05c513c227c6b42b686b08e Mon Sep 17 00:00:00 2001 From: Eric Rich Date: Sat, 14 Feb 2026 06:35:38 -0500 Subject: [PATCH 1/2] feat(docs): Add getSuggestions and getComments methods Developed with the assistance of Claude Code (claude-opus-4-6) --- .../services/DocsService.comments.test.ts | 431 ++++++++++++++++++ workspace-server/src/index.ts | 22 + workspace-server/src/services/DocsService.ts | 189 ++++++++ 3 files changed, 642 insertions(+) create mode 100644 workspace-server/src/__tests__/services/DocsService.comments.test.ts diff --git a/workspace-server/src/__tests__/services/DocsService.comments.test.ts b/workspace-server/src/__tests__/services/DocsService.comments.test.ts new file mode 100644 index 00000000..9ce09cc8 --- /dev/null +++ b/workspace-server/src/__tests__/services/DocsService.comments.test.ts @@ -0,0 +1,431 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; +import { DocsService } from '../../services/DocsService'; +import { DriveService } from '../../services/DriveService'; +import { AuthManager } from '../../auth/AuthManager'; +import { google } from 'googleapis'; + +// Mock the googleapis module +jest.mock('googleapis'); +jest.mock('../../utils/logger'); +jest.mock('dompurify', () => { + return jest.fn().mockImplementation(() => ({ + sanitize: jest.fn((content) => content), + })); +}); + +describe('DocsService Comments and Suggestions', () => { + let docsService: DocsService; + let mockAuthManager: jest.Mocked; + let mockDriveService: jest.Mocked; + let mockDocsAPI: any; + let mockDriveAPI: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAuthManager = { + getAuthenticatedClient: jest.fn(), + } as any; + + mockDriveService = { + findFolder: jest.fn(), + } as any; + + mockDocsAPI = { + documents: { + get: jest.fn(), + create: jest.fn(), + batchUpdate: jest.fn(), + }, + }; + + mockDriveAPI = { + files: { + create: jest.fn(), + list: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }, + comments: { + list: jest.fn(), + }, + }; + + (google.docs as jest.Mock) = jest.fn().mockReturnValue(mockDocsAPI); + (google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI); + + docsService = new DocsService(mockAuthManager, mockDriveService); + + const mockAuthClient = { access_token: 'test-token' }; + mockAuthManager.getAuthenticatedClient.mockResolvedValue( + mockAuthClient as any, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getSuggestions', () => { + it('should return suggestions as type text with JSON-stringified array', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + body: { + content: [ + { + paragraph: { + elements: [ + { + textRun: { + content: 'Suggested insertion', + suggestedInsertionIds: ['ins1'], + }, + startIndex: 1, + endIndex: 20, + }, + ], + }, + }, + ], + }, + }, + }); + + const result = await docsService.getSuggestions({ + documentId: 'test-doc-id', + }); + + expect(result.content[0].type).toBe('text'); + const suggestions = JSON.parse(result.content[0].text); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual({ + type: 'insertion', + text: 'Suggested insertion', + suggestionIds: ['ins1'], + startIndex: 1, + endIndex: 20, + }); + }); + + it('should extract insertion suggestions correctly', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + body: { + content: [ + { + paragraph: { + elements: [ + { + textRun: { + content: 'new text', + suggestedInsertionIds: ['sug-1', 'sug-2'], + }, + startIndex: 5, + endIndex: 13, + }, + ], + }, + }, + ], + }, + }, + }); + + const result = await docsService.getSuggestions({ + documentId: 'test-doc-id', + }); + + const suggestions = JSON.parse(result.content[0].text); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].type).toBe('insertion'); + expect(suggestions[0].suggestionIds).toEqual(['sug-1', 'sug-2']); + }); + + it('should extract deletion suggestions correctly', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + body: { + content: [ + { + paragraph: { + elements: [ + { + textRun: { + content: 'deleted text', + suggestedDeletionIds: ['del-1'], + }, + startIndex: 1, + endIndex: 13, + }, + ], + }, + }, + ], + }, + }, + }); + + const result = await docsService.getSuggestions({ + documentId: 'test-doc-id', + }); + + const suggestions = JSON.parse(result.content[0].text); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].type).toBe('deletion'); + expect(suggestions[0].text).toBe('deleted text'); + }); + + it('should extract style change suggestions correctly', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + body: { + content: [ + { + paragraph: { + elements: [ + { + textRun: { + content: 'styled text', + suggestedTextStyleChanges: { + 'style-1': { textStyle: { bold: true } }, + }, + textStyle: { bold: true }, + }, + startIndex: 1, + endIndex: 12, + }, + ], + }, + }, + ], + }, + }, + }); + + const result = await docsService.getSuggestions({ + documentId: 'test-doc-id', + }); + + const suggestions = JSON.parse(result.content[0].text); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].type).toBe('styleChange'); + expect(suggestions[0].suggestionIds).toEqual(['style-1']); + expect(suggestions[0].textStyle).toEqual({ bold: true }); + }); + + it('should extract paragraph style change suggestions', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + body: { + content: [ + { + paragraph: { + paragraphStyle: { namedStyleType: 'HEADING_1' }, + suggestedParagraphStyleChanges: { + 'sug-para-1': { + paragraphStyle: { namedStyleType: 'HEADING_2' }, + }, + }, + elements: [ + { + textRun: { content: 'Heading Text' }, + startIndex: 1, + endIndex: 13, + }, + ], + }, + startIndex: 1, + endIndex: 13, + }, + ], + }, + }, + }); + + const result = await docsService.getSuggestions({ + documentId: 'test-doc-id', + }); + + const suggestions = JSON.parse(result.content[0].text); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].type).toBe('paragraphStyleChange'); + expect(suggestions[0].suggestionIds).toEqual(['sug-para-1']); + expect(suggestions[0].namedStyleType).toBe('HEADING_2'); + expect(suggestions[0].text).toBe('Heading Text'); + }); + + it('should handle tables with recursive element processing', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + body: { + content: [ + { + table: { + tableRows: [ + { + tableCells: [ + { + content: [ + { + paragraph: { + elements: [ + { + textRun: { + content: 'cell text', + suggestedInsertionIds: ['cell-ins-1'], + }, + startIndex: 5, + endIndex: 14, + }, + ], + }, + }, + ], + }, + ], + }, + ], + }, + }, + ], + }, + }, + }); + + const result = await docsService.getSuggestions({ + documentId: 'test-doc-id', + }); + + const suggestions = JSON.parse(result.content[0].text); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].type).toBe('insertion'); + expect(suggestions[0].text).toBe('cell text'); + }); + + it('should handle empty document body', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { body: null }, + }); + + const result = await docsService.getSuggestions({ + documentId: 'test-doc-id', + }); + + const suggestions = JSON.parse(result.content[0].text); + expect(suggestions).toEqual([]); + }); + + it('should handle API errors gracefully', async () => { + mockDocsAPI.documents.get.mockRejectedValue( + new Error('Docs API failed'), + ); + + const result = await docsService.getSuggestions({ + documentId: 'test-doc-id', + }); + + expect(result.content[0].type).toBe('text'); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual({ error: 'Docs API failed' }); + }); + + it('should handle undefined textRun.content with empty string fallback', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + body: { + content: [ + { + paragraph: { + elements: [ + { + textRun: { + content: undefined, + suggestedInsertionIds: ['ins-undef'], + }, + startIndex: 1, + endIndex: 5, + }, + ], + }, + }, + ], + }, + }, + }); + + const result = await docsService.getSuggestions({ + documentId: 'test-doc-id', + }); + + const suggestions = JSON.parse(result.content[0].text); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].text).toBe(''); + }); + }); + + describe('getComments', () => { + it('should return comments as type text with JSON-stringified array', async () => { + const mockComments = [ + { + id: 'comment1', + content: 'This is a comment.', + author: { displayName: 'Test User', emailAddress: 'test@example.com' }, + createdTime: '2025-01-01T00:00:00Z', + resolved: false, + quotedFileContent: { value: 'quoted text' }, + }, + ]; + mockDriveAPI.comments.list.mockResolvedValue({ + data: { comments: mockComments }, + }); + + const result = await docsService.getComments({ + documentId: 'test-doc-id', + }); + + expect(result.content[0].type).toBe('text'); + const comments = JSON.parse(result.content[0].text); + expect(comments).toEqual(mockComments); + }); + + it('should handle empty comments list', async () => { + mockDriveAPI.comments.list.mockResolvedValue({ + data: { comments: [] }, + }); + + const result = await docsService.getComments({ + documentId: 'test-doc-id', + }); + + const comments = JSON.parse(result.content[0].text); + expect(comments).toEqual([]); + }); + + it('should handle API errors gracefully', async () => { + mockDriveAPI.comments.list.mockRejectedValue( + new Error('Comments API failed'), + ); + + const result = await docsService.getComments({ + documentId: 'test-doc-id', + }); + + expect(result.content[0].type).toBe('text'); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual({ error: 'Comments API failed' }); + }); + }); +}); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index becce912..f077b201 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -143,6 +143,28 @@ async function main() { }, ); + server.registerTool( + 'docs.getSuggestions', + { + description: 'Retrieves suggested edits from a Google Doc.', + inputSchema: { + documentId: z.string().describe('The ID of the document to retrieve suggestions from.'), + }, + }, + docsService.getSuggestions, + ); + + server.registerTool( + 'docs.getComments', + { + description: 'Retrieves comments from a Google Doc.', + inputSchema: { + documentId: z.string().describe('The ID of the document to retrieve comments from.'), + }, + }, + docsService.getComments, + ); + server.registerTool( 'docs.create', { diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index b62b7df3..74eb97fd 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -21,6 +21,16 @@ import { processMarkdownLineBreaks, } from '../utils/markdownToDocsRequests'; +interface DocsSuggestion { + type: 'insertion' | 'deletion' | 'styleChange' | 'paragraphStyleChange'; + text?: string; + suggestionIds?: string[]; + startIndex?: number; + endIndex?: number; + namedStyleType?: string; + textStyle?: docs_v1.Schema$TextStyle; +} + export class DocsService { private purify: ReturnType; @@ -44,6 +54,185 @@ export class DocsService { return google.drive({ version: 'v3', ...options }); } + public getSuggestions = async ({ + documentId, + }: { + documentId: string; + }) => { + logToFile( + `[DocsService] Starting getSuggestions for document: ${documentId}`, + ); + try { + const id = extractDocId(documentId) || documentId; + const docs = await this.getDocsClient(); + const res = await docs.documents.get({ + documentId: id, + suggestionsViewMode: 'SUGGESTIONS_INLINE', + fields: 'body', + }); + + const suggestions: DocsSuggestion[] = this._extractSuggestions(res.data.body); + + logToFile( + `[DocsService] Found ${suggestions.length} suggestions for document: ${id}`, + ); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(suggestions, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[DocsService] Error during docs.getSuggestions: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + private _extractSuggestions( + body: docs_v1.Schema$Body | undefined | null, + ): DocsSuggestion[] { + const suggestions: DocsSuggestion[] = []; + if (!body?.content) { + return suggestions; + } + + const processElements = ( + elements: docs_v1.Schema$StructuralElement[] | undefined, + ) => { + elements?.forEach((element) => { + if (element.paragraph) { + // Handle paragraph-level style suggestions + if (element.paragraph.suggestedParagraphStyleChanges) { + const suggestionIds = Object.keys( + element.paragraph.suggestedParagraphStyleChanges, + ); + if (suggestionIds.length > 0) { + const firstSuggestion = + element.paragraph.suggestedParagraphStyleChanges[ + suggestionIds[0] + ]; + suggestions.push({ + type: 'paragraphStyleChange', + text: this._getParagraphText(element.paragraph), + suggestionIds: suggestionIds, + namedStyleType: firstSuggestion?.paragraphStyle?.namedStyleType, + startIndex: element.startIndex, + endIndex: element.endIndex, + }); + } + } + + // Handle text-run-level suggestions within the paragraph + element.paragraph.elements?.forEach((pElement) => { + if (pElement.textRun) { + const baseSuggestion = { + text: pElement.textRun.content || '', + startIndex: pElement.startIndex, + endIndex: pElement.endIndex, + }; + + if (pElement.textRun.suggestedInsertionIds) { + suggestions.push({ + ...baseSuggestion, + type: 'insertion' as const, + suggestionIds: pElement.textRun.suggestedInsertionIds, + }); + } + if (pElement.textRun.suggestedDeletionIds) { + suggestions.push({ + ...baseSuggestion, + type: 'deletion' as const, + suggestionIds: pElement.textRun.suggestedDeletionIds, + }); + } + if (pElement.textRun.suggestedTextStyleChanges) { + suggestions.push({ + ...baseSuggestion, + type: 'styleChange' as const, + suggestionIds: Object.keys( + pElement.textRun.suggestedTextStyleChanges, + ), + textStyle: pElement.textRun.textStyle, + }); + } + } + }); + } else if (element.table) { + element.table.tableRows?.forEach((row) => { + row.tableCells?.forEach((cell) => { + processElements(cell.content); + }); + }); + } + }); + }; + + processElements(body.content); + return suggestions; + } + + private _getParagraphText(paragraph: docs_v1.Schema$Paragraph | undefined | null): string { + if (!paragraph?.elements) { + return ''; + } + return paragraph.elements + .map((pElement) => pElement.textRun?.content || '') + .join(''); + } + + public getComments = async ({ documentId }: { documentId: string }) => { + logToFile(`[DocsService] Starting getComments for document: ${documentId}`); + try { + const id = extractDocId(documentId) || documentId; + const drive = await this.getDriveClient(); + const res = await drive.comments.list({ + fileId: id, + fields: + 'comments(id, content, author(displayName, emailAddress), createdTime, resolved, quotedFileContent(value))', + }); + + const comments = res.data.comments || []; + logToFile( + `[DocsService] Found ${comments.length} comments for document: ${id}`, + ); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(comments, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.getComments: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + public create = async ({ title, folderName, From 605cc4156dac3e1664a10dc10ab1f4b8855de20b Mon Sep 17 00:00:00 2001 From: Eric Rich Date: Fri, 27 Feb 2026 23:00:35 +0000 Subject: [PATCH 2/2] fix(docs): Address PR review comments for getSuggestions and getComments - Add `replies(id, content, author(...), createdTime)` field to getComments Drive API call so comment threads include reply data - Add `isError: true` to error responses in getSuggestions and getComments so MCP clients can detect failures without parsing content - Replace `DocsSuggestion` interface with a discriminated union type (DocsInsertionSuggestion, DocsDeletionSuggestion, DocsStyleChangeSuggestion, DocsParagraphStyleChangeSuggestion) for better type safety - Fix paragraph style extraction to create one entry per suggestion ID instead of collapsing all IDs into a single entry with only the first suggestion's namedStyleType, preventing data loss with multiple suggestions - Add/update tests for all of the above including isError flag, replies data, per-suggestion-ID paragraph style entries, and Drive API field checks - Run prettier format:fix to resolve CI formatting failures https://claude.ai/code/session_012u98iE2zW8ZkAyH228W1yH --- .../services/DocsService.comments.test.ts | 109 +++++++++++++++++- workspace-server/src/index.ts | 8 +- workspace-server/src/services/DocsService.ts | 65 +++++++---- 3 files changed, 154 insertions(+), 28 deletions(-) diff --git a/workspace-server/src/__tests__/services/DocsService.comments.test.ts b/workspace-server/src/__tests__/services/DocsService.comments.test.ts index 9ce09cc8..43f7419b 100644 --- a/workspace-server/src/__tests__/services/DocsService.comments.test.ts +++ b/workspace-server/src/__tests__/services/DocsService.comments.test.ts @@ -329,19 +329,65 @@ describe('DocsService Comments and Suggestions', () => { }); it('should handle API errors gracefully', async () => { - mockDocsAPI.documents.get.mockRejectedValue( - new Error('Docs API failed'), - ); + mockDocsAPI.documents.get.mockRejectedValue(new Error('Docs API failed')); const result = await docsService.getSuggestions({ documentId: 'test-doc-id', }); + expect(result.isError).toBe(true); expect(result.content[0].type).toBe('text'); const parsed = JSON.parse(result.content[0].text); expect(parsed).toEqual({ error: 'Docs API failed' }); }); + it('should create one suggestion entry per paragraph suggestion ID', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + body: { + content: [ + { + paragraph: { + suggestedParagraphStyleChanges: { + 'sug-1': { + paragraphStyle: { namedStyleType: 'HEADING_1' }, + }, + 'sug-2': { + paragraphStyle: { namedStyleType: 'HEADING_2' }, + }, + }, + elements: [ + { + textRun: { content: 'Some heading' }, + startIndex: 1, + endIndex: 13, + }, + ], + }, + startIndex: 1, + endIndex: 13, + }, + ], + }, + }, + }); + + const result = await docsService.getSuggestions({ + documentId: 'test-doc-id', + }); + + const suggestions = JSON.parse(result.content[0].text); + expect(suggestions).toHaveLength(2); + const types = suggestions.map((s: any) => s.type); + expect(types).toEqual(['paragraphStyleChange', 'paragraphStyleChange']); + const ids = suggestions.map((s: any) => s.suggestionIds[0]); + expect(ids).toContain('sug-1'); + expect(ids).toContain('sug-2'); + const named = suggestions.map((s: any) => s.namedStyleType); + expect(named).toContain('HEADING_1'); + expect(named).toContain('HEADING_2'); + }); + it('should handle undefined textRun.content with empty string fallback', async () => { mockDocsAPI.documents.get.mockResolvedValue({ data: { @@ -382,10 +428,14 @@ describe('DocsService Comments and Suggestions', () => { { id: 'comment1', content: 'This is a comment.', - author: { displayName: 'Test User', emailAddress: 'test@example.com' }, + author: { + displayName: 'Test User', + emailAddress: 'test@example.com', + }, createdTime: '2025-01-01T00:00:00Z', resolved: false, quotedFileContent: { value: 'quoted text' }, + replies: [], }, ]; mockDriveAPI.comments.list.mockResolvedValue({ @@ -401,6 +451,56 @@ describe('DocsService Comments and Suggestions', () => { expect(comments).toEqual(mockComments); }); + it('should include replies in comment threads', async () => { + const mockComments = [ + { + id: 'comment1', + content: 'Top-level comment.', + author: { displayName: 'Alice', emailAddress: 'alice@example.com' }, + createdTime: '2025-01-01T00:00:00Z', + resolved: false, + quotedFileContent: { value: 'some text' }, + replies: [ + { + id: 'reply1', + content: 'Reply to comment.', + author: { + displayName: 'Bob', + emailAddress: 'bob@example.com', + }, + createdTime: '2025-01-02T00:00:00Z', + }, + ], + }, + ]; + mockDriveAPI.comments.list.mockResolvedValue({ + data: { comments: mockComments }, + }); + + const result = await docsService.getComments({ + documentId: 'test-doc-id', + }); + + expect(result.content[0].type).toBe('text'); + const comments = JSON.parse(result.content[0].text); + expect(comments).toHaveLength(1); + expect(comments[0].replies).toHaveLength(1); + expect(comments[0].replies[0].id).toBe('reply1'); + expect(comments[0].replies[0].content).toBe('Reply to comment.'); + }); + + it('should request replies fields in the Drive API call', async () => { + mockDriveAPI.comments.list.mockResolvedValue({ data: { comments: [] } }); + + await docsService.getComments({ documentId: 'test-doc-id' }); + + expect(mockDriveAPI.comments.list).toHaveBeenCalledWith( + expect.objectContaining({ + fields: expect.stringContaining('replies('), + }), + ); + }); + it('should handle empty comments list', async () => { mockDriveAPI.comments.list.mockResolvedValue({ data: { comments: [] }, @@ -423,6 +523,7 @@ describe('DocsService Comments and Suggestions', () => { documentId: 'test-doc-id', }); + expect(result.isError).toBe(true); expect(result.content[0].type).toBe('text'); const parsed = JSON.parse(result.content[0].text); expect(parsed).toEqual({ error: 'Comments API failed' }); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index f077b201..6ea0bab5 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -148,7 +148,9 @@ async function main() { { description: 'Retrieves suggested edits from a Google Doc.', inputSchema: { - documentId: z.string().describe('The ID of the document to retrieve suggestions from.'), + documentId: z + .string() + .describe('The ID of the document to retrieve suggestions from.'), }, }, docsService.getSuggestions, @@ -159,7 +161,9 @@ async function main() { { description: 'Retrieves comments from a Google Doc.', inputSchema: { - documentId: z.string().describe('The ID of the document to retrieve comments from.'), + documentId: z + .string() + .describe('The ID of the document to retrieve comments from.'), }, }, docsService.getComments, diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 74eb97fd..ad0c6e52 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -21,16 +21,40 @@ import { processMarkdownLineBreaks, } from '../utils/markdownToDocsRequests'; -interface DocsSuggestion { - type: 'insertion' | 'deletion' | 'styleChange' | 'paragraphStyleChange'; - text?: string; - suggestionIds?: string[]; +interface BaseDocsSuggestion { + text: string; startIndex?: number; endIndex?: number; - namedStyleType?: string; +} + +interface DocsInsertionSuggestion extends BaseDocsSuggestion { + type: 'insertion'; + suggestionIds: string[]; +} + +interface DocsDeletionSuggestion extends BaseDocsSuggestion { + type: 'deletion'; + suggestionIds: string[]; +} + +interface DocsStyleChangeSuggestion extends BaseDocsSuggestion { + type: 'styleChange'; + suggestionIds: string[]; textStyle?: docs_v1.Schema$TextStyle; } +interface DocsParagraphStyleChangeSuggestion extends BaseDocsSuggestion { + type: 'paragraphStyleChange'; + suggestionIds: string[]; + namedStyleType?: string; +} + +type DocsSuggestion = + | DocsInsertionSuggestion + | DocsDeletionSuggestion + | DocsStyleChangeSuggestion + | DocsParagraphStyleChangeSuggestion; + export class DocsService { private purify: ReturnType; @@ -54,11 +78,7 @@ export class DocsService { return google.drive({ version: 'v3', ...options }); } - public getSuggestions = async ({ - documentId, - }: { - documentId: string; - }) => { + public getSuggestions = async ({ documentId }: { documentId: string }) => { logToFile( `[DocsService] Starting getSuggestions for document: ${documentId}`, ); @@ -71,7 +91,9 @@ export class DocsService { fields: 'body', }); - const suggestions: DocsSuggestion[] = this._extractSuggestions(res.data.body); + const suggestions: DocsSuggestion[] = this._extractSuggestions( + res.data.body, + ); logToFile( `[DocsService] Found ${suggestions.length} suggestions for document: ${id}`, @@ -92,6 +114,7 @@ export class DocsService { `[DocsService] Error during docs.getSuggestions: ${errorMessage}`, ); return { + isError: true, content: [ { type: 'text' as const, @@ -117,19 +140,14 @@ export class DocsService { if (element.paragraph) { // Handle paragraph-level style suggestions if (element.paragraph.suggestedParagraphStyleChanges) { - const suggestionIds = Object.keys( + for (const [suggestionId, suggestion] of Object.entries( element.paragraph.suggestedParagraphStyleChanges, - ); - if (suggestionIds.length > 0) { - const firstSuggestion = - element.paragraph.suggestedParagraphStyleChanges[ - suggestionIds[0] - ]; + )) { suggestions.push({ type: 'paragraphStyleChange', text: this._getParagraphText(element.paragraph), - suggestionIds: suggestionIds, - namedStyleType: firstSuggestion?.paragraphStyle?.namedStyleType, + suggestionIds: [suggestionId], + namedStyleType: suggestion?.paragraphStyle?.namedStyleType, startIndex: element.startIndex, endIndex: element.endIndex, }); @@ -185,7 +203,9 @@ export class DocsService { return suggestions; } - private _getParagraphText(paragraph: docs_v1.Schema$Paragraph | undefined | null): string { + private _getParagraphText( + paragraph: docs_v1.Schema$Paragraph | undefined | null, + ): string { if (!paragraph?.elements) { return ''; } @@ -202,7 +222,7 @@ export class DocsService { const res = await drive.comments.list({ fileId: id, fields: - 'comments(id, content, author(displayName, emailAddress), createdTime, resolved, quotedFileContent(value))', + 'comments(id, content, author(displayName, emailAddress), createdTime, resolved, quotedFileContent(value), replies(id, content, author(displayName, emailAddress), createdTime))', }); const comments = res.data.comments || []; @@ -223,6 +243,7 @@ export class DocsService { error instanceof Error ? error.message : String(error); logToFile(`[DocsService] Error during docs.getComments: ${errorMessage}`); return { + isError: true, content: [ { type: 'text' as const,