Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions frontend/src/api/__tests__/googleDocsEditorAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
24 changes: 24 additions & 0 deletions frontend/src/api/googleDocsEditorAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string[]> {
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<void> {
return Promise.reject(
new Error('applyEdit is not implemented for Google Docs yet'),
);
},
};

/**
Expand Down
157 changes: 157 additions & 0 deletions frontend/src/api/wordEditorAPI.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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<string> {
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<string[]> {
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<void> {
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();
});
},
};
6 changes: 6 additions & 0 deletions frontend/src/contexts/editorContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@ export const EditorContext = createContext<EditorAPI>({
console.warn('selectPhrase is not implemented yet');
return new Promise<void>((resolve) => resolve());
},
getDocText: () => Promise.resolve(''),
getParagraphs: () => Promise.resolve([]),
applyEdit: () => {
console.warn('applyEdit is not implemented yet');
return Promise.resolve();
},
});
16 changes: 16 additions & 0 deletions frontend/src/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@
console.warn('selectPhrase is not implemented yet');
return new Promise<void>((resolve) => resolve());
},

getDocText: async (): Promise<string> => {

Check failure on line 68 in frontend/src/editor/index.tsx

View workflow job for this annotation

GitHub Actions / Run linters

Async method 'getDocText' has no 'await' expression
const ctx = docContextRef.current;
return `${ctx.beforeCursor}${ctx.selectedText}${ctx.afterCursor}`;
},
getParagraphs: async (): Promise<string[]> => {

Check failure on line 72 in frontend/src/editor/index.tsx

View workflow job for this annotation

GitHub Actions / Run linters

Async method 'getParagraphs' has no 'await' expression
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'),
);
},
}),
[],
);
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocContext>;
addSelectionChangeHandler: (handler: () => void) => void;
removeSelectionChangeHandler: (handler: () => void) => void;
selectPhrase: (text: string) => Promise<void>;
/** Full document text. Host-agnostic accessor for the corpus + `view` tool. */
getDocText(this: void): Promise<string>;
/**
* 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<string[]>;
/** Apply a validated edit to the document. */
applyEdit(this: void, edit: DocEdit): Promise<void>;
}

interface ReflectionResponseItem {
Expand Down
Loading