From e394291a3f45c514abdec512cdda2bbbd65e5178 Mon Sep 17 00:00:00 2001 From: MrwanBaghdad Date: Thu, 12 Feb 2026 03:39:10 +0100 Subject: [PATCH 1/5] Enable parsing of rich (smart) tags in google docs Currently docs.getText skip over smart chip elements within Google docs. Leaving out information. This commit adds support to render smart chip elements in google docs to simple text --- .../__tests__/services/DocsService.test.ts | 134 ++++++++++++++++++ workspace-server/src/services/DocsService.ts | 10 ++ 2 files changed, 144 insertions(+) diff --git a/workspace-server/src/__tests__/services/DocsService.test.ts b/workspace-server/src/__tests__/services/DocsService.test.ts index f03551c5..06b49eea 100644 --- a/workspace-server/src/__tests__/services/DocsService.test.ts +++ b/workspace-server/src/__tests__/services/DocsService.test.ts @@ -736,6 +736,140 @@ describe('DocsService', () => { }); }); + it('should extract text from smart chips (date, person, rich link)', async () => { + const mockDoc = { + data: { + tabs: [ + { + documentTab: { + body: { + content: [ + { + paragraph: { + elements: [ + { + textRun: { content: 'Meeting on ' }, + }, + { + dateElement: { + dateElementProperties: { + displayText: 'Jan 15, 2025', + timestamp: '1736899200', + }, + }, + }, + { + textRun: { content: ' with ' }, + }, + { + person: { + personProperties: { + name: 'John Doe', + email: 'john@example.com', + }, + }, + }, + { + textRun: { content: ' - see ' }, + }, + { + richLink: { + richLinkProperties: { + title: 'Project Plan', + uri: 'https://docs.google.com/document/d/abc123', + }, + }, + }, + { + textRun: { content: '\n' }, + }, + ], + }, + }, + ], + }, + }, + }, + ], + }, + }; + mockDocsAPI.documents.get.mockResolvedValue(mockDoc); + + const result = await docsService.getText({ documentId: 'test-doc-id' }); + + expect(result.content[0].text).toBe( + 'Meeting on Jan 15, 2025 with [John Doe](mailto:john@example.com) - see [Project Plan](https://docs.google.com/document/d/abc123)\n', + ); + }); + + it.each([ + { + name: 'person without name falls back to email', + element: { + person: { + personProperties: { + email: 'jane@example.com', + }, + }, + }, + expected: '[jane@example.com](mailto:jane@example.com)', + }, + { + name: 'rich link without title falls back to uri', + element: { + richLink: { + richLinkProperties: { + uri: 'https://docs.google.com/spreadsheets/d/xyz', + }, + }, + }, + expected: + '[https://docs.google.com/spreadsheets/d/xyz](https://docs.google.com/spreadsheets/d/xyz)', + }, + { + name: 'date without displayText falls back to timestamp', + element: { + dateElement: { + dateElementProperties: { + timestamp: '1736899200', + }, + }, + }, + expected: '1736899200', + }, + ])( + 'should fall back correctly when $name', + async ({ element, expected }) => { + const mockDoc = { + data: { + tabs: [ + { + documentTab: { + body: { + content: [ + { + paragraph: { + elements: [element], + }, + }, + ], + }, + }, + }, + ], + }, + }; + mockDocsAPI.documents.get.mockResolvedValue(mockDoc); + + const result = await docsService.getText({ + documentId: 'test-doc-id', + }); + + expect(result.content[0].text).toBe(expected); + }, + ); + + it('should include text from nested child tabs', async () => { const mockDoc = { data: { diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 750dc15d..109f0ad6 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -816,6 +816,16 @@ export class DocsService { element.paragraph.elements?.forEach((pElement) => { if (pElement.textRun && pElement.textRun.content) { text += pElement.textRun.content; + } else if (pElement.person?.personProperties) { + const { name, email } = pElement.person.personProperties; + text += name || email || ''; + } else if (pElement.richLink?.richLinkProperties) { + const { title, uri } = pElement.richLink.richLinkProperties; + text += title || uri || ''; + } else if (pElement.dateElement?.dateElementProperties) { + const { displayText, timestamp } = + pElement.dateElement.dateElementProperties; + text += displayText || timestamp || ''; } }); } else if (element.table) { From e30b8b6200e28715ccc3730735fa0ba2baa6ab72 Mon Sep 17 00:00:00 2001 From: MrwanBaghdad Date: Thu, 12 Feb 2026 04:05:27 +0100 Subject: [PATCH 2/5] Update the text output --- workspace-server/src/services/DocsService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 109f0ad6..bc754083 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -818,10 +818,10 @@ export class DocsService { text += pElement.textRun.content; } else if (pElement.person?.personProperties) { const { name, email } = pElement.person.personProperties; - text += name || email || ''; + text += `[${name || email}](mailto:${email})`; } else if (pElement.richLink?.richLinkProperties) { const { title, uri } = pElement.richLink.richLinkProperties; - text += title || uri || ''; + text += `[${title}](${uri})`; } else if (pElement.dateElement?.dateElementProperties) { const { displayText, timestamp } = pElement.dateElement.dateElementProperties; From 46f9d71f9362587f8e72a4f01d1a59bef036aa1e Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Wed, 4 Mar 2026 13:45:33 -0800 Subject: [PATCH 3/5] feat: parse rich smart chips (person, date, rich link) in Google Docs Adds smart chip parsing to the DocsService getText method, rendering: - Person chips as markdown mailto links with name fallback to email - Rich link chips as markdown links with title fallback to URI - Date chips as displayText with fallback to timestamp Based on PR #215 by @MrwanBaghdad. Fixes reviewed items: - Rich link title fallback bug (title || uri) - Missing rich link fallback test - Refactored fallback tests to it.each - Prettier formatting applied Co-authored-by: MrwanBaghdad --- workspace-server/src/__tests__/services/DocsService.test.ts | 1 - workspace-server/src/services/DocsService.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/workspace-server/src/__tests__/services/DocsService.test.ts b/workspace-server/src/__tests__/services/DocsService.test.ts index 06b49eea..7c3c7cb9 100644 --- a/workspace-server/src/__tests__/services/DocsService.test.ts +++ b/workspace-server/src/__tests__/services/DocsService.test.ts @@ -869,7 +869,6 @@ describe('DocsService', () => { }, ); - it('should include text from nested child tabs', async () => { const mockDoc = { data: { diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index bc754083..3cb67e17 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -821,7 +821,7 @@ export class DocsService { text += `[${name || email}](mailto:${email})`; } else if (pElement.richLink?.richLinkProperties) { const { title, uri } = pElement.richLink.richLinkProperties; - text += `[${title}](${uri})`; + text += `[${title || uri}](${uri})`; } else if (pElement.dateElement?.dateElementProperties) { const { displayText, timestamp } = pElement.dateElement.dateElementProperties; From c9ca052b0290a6d00b9a8cc3a17803cf3dd259aa Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Wed, 4 Mar 2026 16:17:37 -0800 Subject: [PATCH 4/5] fix: add defensive guards for missing email and uri in smart chips - Person chips: guard against missing email, fall back to name only - Rich link chips: guard against missing uri, fall back to title only - Added 2 new it.each test cases for these edge cases Addresses review feedback from Gemini Code Assist on PR #263 --- .../__tests__/services/DocsService.test.ts | 22 +++++++++++++++++++ workspace-server/src/services/DocsService.ts | 12 ++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/workspace-server/src/__tests__/services/DocsService.test.ts b/workspace-server/src/__tests__/services/DocsService.test.ts index 7c3c7cb9..be5d42f3 100644 --- a/workspace-server/src/__tests__/services/DocsService.test.ts +++ b/workspace-server/src/__tests__/services/DocsService.test.ts @@ -814,6 +814,17 @@ describe('DocsService', () => { }, expected: '[jane@example.com](mailto:jane@example.com)', }, + { + name: 'person without email falls back to name only', + element: { + person: { + personProperties: { + name: 'John Doe', + }, + }, + }, + expected: 'John Doe', + }, { name: 'rich link without title falls back to uri', element: { @@ -826,6 +837,17 @@ describe('DocsService', () => { expected: '[https://docs.google.com/spreadsheets/d/xyz](https://docs.google.com/spreadsheets/d/xyz)', }, + { + name: 'rich link without uri falls back to title only', + element: { + richLink: { + richLinkProperties: { + title: 'Some Document', + }, + }, + }, + expected: 'Some Document', + }, { name: 'date without displayText falls back to timestamp', element: { diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 3cb67e17..85692e0c 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -818,10 +818,18 @@ export class DocsService { text += pElement.textRun.content; } else if (pElement.person?.personProperties) { const { name, email } = pElement.person.personProperties; - text += `[${name || email}](mailto:${email})`; + if (email) { + text += `[${name || email}](mailto:${email})`; + } else if (name) { + text += name; + } } else if (pElement.richLink?.richLinkProperties) { const { title, uri } = pElement.richLink.richLinkProperties; - text += `[${title || uri}](${uri})`; + if (uri) { + text += `[${title || uri}](${uri})`; + } else if (title) { + text += title; + } } else if (pElement.dateElement?.dateElementProperties) { const { displayText, timestamp } = pElement.dateElement.dateElementProperties; From 05c4abcbd6797dd96542fc92c9598b44b98a9deb Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Thu, 5 Mar 2026 08:32:23 -0800 Subject: [PATCH 5/5] refactor: improve smart chip readability with helper methods - Extract _renderPersonChip, _renderRichLinkChip, _renderDateChip from _readStructuralElement for cleaner dispatch logic - Add mockDocWithElements test helper to reduce mock boilerplate Addresses readability nits from @abhipatel12 on PR #263 --- .../__tests__/services/DocsService.test.ts | 120 +++++++----------- workspace-server/src/services/DocsService.ts | 45 ++++--- 2 files changed, 76 insertions(+), 89 deletions(-) diff --git a/workspace-server/src/__tests__/services/DocsService.test.ts b/workspace-server/src/__tests__/services/DocsService.test.ts index be5d42f3..ca9795c9 100644 --- a/workspace-server/src/__tests__/services/DocsService.test.ts +++ b/workspace-server/src/__tests__/services/DocsService.test.ts @@ -736,63 +736,52 @@ describe('DocsService', () => { }); }); - it('should extract text from smart chips (date, person, rich link)', async () => { - const mockDoc = { - data: { - tabs: [ - { - documentTab: { - body: { - content: [ - { - paragraph: { - elements: [ - { - textRun: { content: 'Meeting on ' }, - }, - { - dateElement: { - dateElementProperties: { - displayText: 'Jan 15, 2025', - timestamp: '1736899200', - }, - }, - }, - { - textRun: { content: ' with ' }, - }, - { - person: { - personProperties: { - name: 'John Doe', - email: 'john@example.com', - }, - }, - }, - { - textRun: { content: ' - see ' }, - }, - { - richLink: { - richLinkProperties: { - title: 'Project Plan', - uri: 'https://docs.google.com/document/d/abc123', - }, - }, - }, - { - textRun: { content: '\n' }, - }, - ], - }, - }, - ], - }, + /** Helper to wrap paragraph elements in the standard mock doc structure. */ + const mockDocWithElements = (...elements: Record[]) => ({ + data: { + tabs: [ + { + documentTab: { + body: { + content: [{ paragraph: { elements } }], }, }, - ], + }, + ], + }, + }); + + it('should extract text from smart chips (date, person, rich link)', async () => { + const mockDoc = mockDocWithElements( + { textRun: { content: 'Meeting on ' } }, + { + dateElement: { + dateElementProperties: { + displayText: 'Jan 15, 2025', + timestamp: '1736899200', + }, + }, }, - }; + { textRun: { content: ' with ' } }, + { + person: { + personProperties: { + name: 'John Doe', + email: 'john@example.com', + }, + }, + }, + { textRun: { content: ' - see ' } }, + { + richLink: { + richLinkProperties: { + title: 'Project Plan', + uri: 'https://docs.google.com/document/d/abc123', + }, + }, + }, + { textRun: { content: '\n' } }, + ); mockDocsAPI.documents.get.mockResolvedValue(mockDoc); const result = await docsService.getText({ documentId: 'test-doc-id' }); @@ -862,26 +851,9 @@ describe('DocsService', () => { ])( 'should fall back correctly when $name', async ({ element, expected }) => { - const mockDoc = { - data: { - tabs: [ - { - documentTab: { - body: { - content: [ - { - paragraph: { - elements: [element], - }, - }, - ], - }, - }, - }, - ], - }, - }; - mockDocsAPI.documents.get.mockResolvedValue(mockDoc); + mockDocsAPI.documents.get.mockResolvedValue( + mockDocWithElements(element), + ); const result = await docsService.getText({ documentId: 'test-doc-id', diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 85692e0c..36a52a18 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -817,23 +817,15 @@ export class DocsService { if (pElement.textRun && pElement.textRun.content) { text += pElement.textRun.content; } else if (pElement.person?.personProperties) { - const { name, email } = pElement.person.personProperties; - if (email) { - text += `[${name || email}](mailto:${email})`; - } else if (name) { - text += name; - } + text += this._renderPersonChip(pElement.person.personProperties); } else if (pElement.richLink?.richLinkProperties) { - const { title, uri } = pElement.richLink.richLinkProperties; - if (uri) { - text += `[${title || uri}](${uri})`; - } else if (title) { - text += title; - } + text += this._renderRichLinkChip( + pElement.richLink.richLinkProperties, + ); } else if (pElement.dateElement?.dateElementProperties) { - const { displayText, timestamp } = - pElement.dateElement.dateElementProperties; - text += displayText || timestamp || ''; + text += this._renderDateChip( + pElement.dateElement.dateElementProperties, + ); } }); } else if (element.table) { @@ -848,6 +840,29 @@ export class DocsService { return text; } + private _renderPersonChip(props: docs_v1.Schema$PersonProperties): string { + const { name, email } = props; + if (email) { + return `[${name || email}](mailto:${email})`; + } + return name || ''; + } + + private _renderRichLinkChip( + props: docs_v1.Schema$RichLinkProperties, + ): string { + const { title, uri } = props; + if (uri) { + return `[${title || uri}](${uri})`; + } + return title || ''; + } + + private _renderDateChip(props: docs_v1.Schema$DateElementProperties): string { + const { displayText, timestamp } = props; + return displayText || timestamp || ''; + } + public replaceText = async ({ documentId, findText,