From 37209c57017df2d93c9b07a20ee20e56286eeb20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 15:52:12 +0000 Subject: [PATCH] Add shared editor API primitives: getDocText, getParagraphs, applyEdit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the host-agnostic document primitives that three separate feature branches (My Words v1, the My Words interaction design, and the voice spike) each independently re-added with identical shape. Landing the shared surface first stops those copies from drifting further and lets each feature branch shrink to just its feature logic on a common base. The new EditorAPI members: - getDocText(): full document text - getParagraphs(): document split into ordered paragraphs — the coordinate system the `view` tool numbers and paragraph-targeted inserts index into - applyEdit(edit: DocEdit): apply a structured str_replace/insert edit DocEdit is a host-agnostic edit type; each host lowers it to its own primitives. The Word implementation is the reviewed-text-aware version (reads tracked-changes "current" text via getReviewedText on WordApi 1.4, falling back to raw .text), so the AI never sees struck-through text. The Google Docs and standalone-editor hosts implement the read accessors and stub applyEdit until their bridges are wired up. Scoped deliberately to the primitives shared by all three branches: the My Words scratchpad persistence and the voice-only applySplice optimization stay with their feature branches. Added unit tests for the Google Docs read accessors and applyEdit stub. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcidL4aeC5xV59XVXsipCF --- .../api/__tests__/googleDocsEditorAPI.test.ts | 39 +++++ frontend/src/api/googleDocsEditorAPI.ts | 24 +++ frontend/src/api/wordEditorAPI.ts | 157 ++++++++++++++++++ frontend/src/contexts/editorContext.tsx | 6 + frontend/src/editor/index.tsx | 16 ++ frontend/src/types.d.ts | 41 +++++ 6 files changed, 283 insertions(+) diff --git a/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts b/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts index ec1ee625..961a498a 100644 --- a/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts +++ b/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts @@ -169,3 +169,42 @@ describe('googleDocsEditorAPI selection polling', () => { expect(getDocContextMock).toHaveBeenCalledTimes(callsBeforeRemoval); }); }); + +describe('googleDocsEditorAPI document accessors', () => { + it('getDocText concatenates the document context in reading order', async () => { + getDocContextMock.mockResolvedValue({ + beforeCursor: 'one\ntwo', + selectedText: '\nthree', + afterCursor: '\nfour', + } satisfies DocContext); + + await expect(googleDocsEditorAPI.getDocText()).resolves.toBe( + 'one\ntwo\nthree\nfour', + ); + }); + + it('getParagraphs splits the full document on newlines', async () => { + getDocContextMock.mockResolvedValue({ + beforeCursor: 'one\ntwo', + selectedText: '\nthree', + afterCursor: '\nfour', + } satisfies DocContext); + + await expect(googleDocsEditorAPI.getParagraphs()).resolves.toEqual([ + 'one', + 'two', + 'three', + 'four', + ]); + }); + + it('applyEdit rejects until the Apps Script bridge is wired up', async () => { + await expect( + googleDocsEditorAPI.applyEdit({ + type: 'str_replace', + oldStr: 'a', + newStr: 'b', + }), + ).rejects.toThrow(/not implemented for Google Docs/); + }); +}); diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index baee6846..f3cc2072 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -236,6 +236,30 @@ export const googleDocsEditorAPI: EditorAPI = { throw new Error('Phrase not found'); } }, + + /** Full document text, used for the corpus and the `view` tool. */ + async getDocText(): Promise { + const ctx = await window.GoogleAppsScript.getDocContext(); + return `${ctx.beforeCursor || ''}${ctx.selectedText || ''}${ctx.afterCursor || ''}`; + }, + + /** Paragraphs in order — the coordinate system for `view` and inserts. */ + async getParagraphs(): Promise { + const ctx = await window.GoogleAppsScript.getDocContext(); + const text = `${ctx.beforeCursor || ''}${ctx.selectedText || ''}${ctx.afterCursor || ''}`; + return text.split('\n'); + }, + + /** + * Not yet implemented for Google Docs. The Word host applies edits via + * Office.js; wiring the Apps Script bridge (selectPhrase + replaceSelection + * for str_replace, insertTextAtCursor for insert) is the follow-up. + */ + applyEdit(_edit: DocEdit): Promise { + return Promise.reject( + new Error('applyEdit is not implemented for Google Docs yet'), + ); + }, }; /** diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index 9e85d03b..93333715 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -1,3 +1,16 @@ +/** + * Whether the host supports `getReviewedText` (WordApi 1.4). We use it to read + * the document as if tracked changes were accepted, so the AI never sees deleted + * text in the corpus or `view`. Falls back to raw `.text` on older hosts. + */ +function supportsReviewedText(): boolean { + try { + return Office.context.requirements.isSetSupported('WordApi', '1.4'); + } catch { + return false; + } +} + export const wordEditorAPI: EditorAPI = { addSelectionChangeHandler: (handler: () => void) => { @@ -88,4 +101,148 @@ export const wordEditorAPI: EditorAPI = { } }); }, + + /** + * Full document text, used for the corpus and the `view` tool. Reads the + * "current" reviewed text so tracked-change deletions are excluded — the AI + * should only ever see the writer's accepted words, not struck-through text. + */ + async getDocText(): Promise { + return Word.run(async (context: Word.RequestContext) => { + const body = context.document.body; + if (supportsReviewedText()) { + const reviewed = body.getReviewedText('Current'); + await context.sync(); + return reviewed.value.replace(/\r/g, '\n'); + } + context.load(body, 'text'); + await context.sync(); + return body.text.replace(/\r/g, '\n'); + }); + }, + + /** + * Paragraphs in order — the coordinate system for `view` and inserts. Like + * getDocText, each paragraph is read as its reviewed ("current") text so + * tracked deletions don't leak to the AI. + */ + async getParagraphs(): Promise { + return Word.run(async (context: Word.RequestContext) => { + const paragraphs = context.document.body.paragraphs; + if (!supportsReviewedText()) { + context.load(paragraphs, 'items/text'); + await context.sync(); + return paragraphs.items.map((p) => p.text.replace(/\r/g, '\n')); + } + context.load(paragraphs, 'items'); + await context.sync(); + const reviewed = paragraphs.items.map((p) => + p.getRange().getReviewedText('Current'), + ); + await context.sync(); + return reviewed.map((r) => r.value.replace(/\r/g, '\n')); + }); + }, + + /** + * Apply a validated edit to the Word document. If the user has Track Changes + * on (Review ribbon → changeTrackingMode = TrackAll), these edits are + * recorded as revisions they can accept or reject — no extra work here. + * + * Note: Word's body.search() is limited to ~255 characters and does not match + * across paragraph breaks, so this supports sentence/phrase-level edits (how + * the AI already works), not multi-paragraph spans. + */ + applyEdit(edit: DocEdit): Promise { + return Word.run(async (context: Word.RequestContext) => { + const body = context.document.body; + const searchOptions: Word.SearchOptions | object = { + matchCase: false, + matchWildcards: false, + ignorePunct: false, + ignoreSpace: false, + }; + + if (edit.type === 'str_replace') { + // Scope the search to one paragraph when given — disambiguates + // repeated text and dodges the body-search length limit. + let scope: Word.Body | Word.Range = body; + if (edit.paragraph !== undefined) { + const paragraphs = body.paragraphs; + context.load(paragraphs, 'items'); + await context.sync(); + if ( + edit.paragraph < 1 || + edit.paragraph > paragraphs.items.length + ) { + throw new Error( + `Paragraph ${edit.paragraph} is out of range (1–${paragraphs.items.length}).`, + ); + } + scope = paragraphs.items[edit.paragraph - 1].getRange(); + } + const results = scope.search(edit.oldStr, searchOptions); + context.load(results, 'items'); + await context.sync(); + if (results.items.length === 0) { + throw new Error( + edit.paragraph !== undefined + ? `Could not find "${edit.oldStr}" in paragraph ${edit.paragraph}.` + : `Could not find the text to replace: "${edit.oldStr}"`, + ); + } + results.items[0].insertText( + edit.newStr, + Word.InsertLocation.replace, + ); + await context.sync(); + return; + } + + // insert — by paragraph number (robust; avoids the search limit) + if (edit.paragraph !== undefined) { + const paragraphs = body.paragraphs; + context.load(paragraphs, 'items'); + await context.sync(); + if ( + edit.paragraph < 1 || + edit.paragraph > paragraphs.items.length + ) { + throw new Error( + `Paragraph ${edit.paragraph} is out of range (1–${paragraphs.items.length}).`, + ); + } + paragraphs.items[edit.paragraph - 1].insertParagraph( + edit.text, + edit.position === 'before' + ? Word.InsertLocation.before + : Word.InsertLocation.after, + ); + await context.sync(); + return; + } + + // insert — after an anchor string (within a paragraph) + if (edit.after !== undefined && edit.after !== '') { + const results = body.search(edit.after, searchOptions); + context.load(results, 'items'); + await context.sync(); + if (results.items.length === 0) { + throw new Error( + `Could not find the anchor text: "${edit.after}"`, + ); + } + results.items[0].insertText( + edit.text, + Word.InsertLocation.after, + ); + } else { + // No anchor: insert at the current cursor / replace the selection. + context.document + .getSelection() + .insertText(edit.text, Word.InsertLocation.replace); + } + await context.sync(); + }); + }, }; diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx index 4bf73f39..2bff6f1d 100644 --- a/frontend/src/contexts/editorContext.tsx +++ b/frontend/src/contexts/editorContext.tsx @@ -16,4 +16,10 @@ export const EditorContext = createContext({ console.warn('selectPhrase is not implemented yet'); return new Promise((resolve) => resolve()); }, + getDocText: () => Promise.resolve(''), + getParagraphs: () => Promise.resolve([]), + applyEdit: () => { + console.warn('applyEdit is not implemented yet'); + return Promise.resolve(); + }, }); diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index 4ad98801..abf9d2f9 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -64,6 +64,22 @@ export function EditorScreen({ console.warn('selectPhrase is not implemented yet'); return new Promise((resolve) => resolve()); }, + + getDocText: async (): Promise => { + const ctx = docContextRef.current; + return `${ctx.beforeCursor}${ctx.selectedText}${ctx.afterCursor}`; + }, + getParagraphs: async (): Promise => { + const ctx = docContextRef.current; + const text = `${ctx.beforeCursor}${ctx.selectedText}${ctx.afterCursor}`; + return text.split('\n'); + }, + applyEdit(_edit) { + console.warn('applyEdit is not implemented yet'); + return Promise.reject( + new Error('applyEdit is not implemented for the standalone editor yet'), + ); + }, }), [], ); diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index d93e9e0e..f7f20491 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -22,11 +22,52 @@ interface SavedItem { dateSaved: Date; } +/** + * A structured, host-agnostic document edit. Editor API implementations lower + * this to their host's primitives (Office.js for Word, the Apps Script bridge + * for Google Docs). Callers build these; each host validates and applies them. + */ +type DocEdit = + | { + type: 'str_replace'; + oldStr: string; + newStr: string; + /** + * Optional 1-based paragraph number (from `view`) to scope the search + * to. Far less fragile than searching the whole body — it disambiguates + * repeated text and dodges the host search-length limit. If oldStr isn't + * in that paragraph (e.g. numbers shifted), the edit fails loudly. + */ + paragraph?: number; + } + | { + type: 'insert'; + text: string; + /** Insert right after this existing text (within a paragraph). */ + after?: string; + /** + * 1-based paragraph number (as shown by the `view` tool) to position a + * new paragraph relative to. More robust than `after` for placement. + */ + paragraph?: number; + /** Where to insert relative to `paragraph`. Defaults to 'after'. */ + position?: 'before' | 'after'; + }; + interface EditorAPI { getDocContext(this: void): Promise; addSelectionChangeHandler: (handler: () => void) => void; removeSelectionChangeHandler: (handler: () => void) => void; selectPhrase: (text: string) => Promise; + /** Full document text. Host-agnostic accessor for the corpus + `view` tool. */ + getDocText(this: void): Promise; + /** + * Document split into paragraphs, in order. This is the shared coordinate + * system the `view` tool numbers and paragraph-targeted inserts index into. + */ + getParagraphs(this: void): Promise; + /** Apply a validated edit to the document. */ + applyEdit(this: void, edit: DocEdit): Promise; } interface ReflectionResponseItem {