diff --git a/packages/docx-core/SUPPORT.md b/packages/docx-core/SUPPORT.md index e77021c..b94d1c2 100644 --- a/packages/docx-core/SUPPORT.md +++ b/packages/docx-core/SUPPORT.md @@ -17,7 +17,6 @@ The "OOXML revision element" column uses ECMA-376 element names from the tracked | `layout.ts` — `setTableRowHeight` | `packages/docx-core/src/primitives/layout.ts` | `w:trPrChange` | Row geometry changes belong under row-property revisions. **Verified by [120.8] (#143) regression test.** | | `layout.ts` — `setTableCellPadding` | `packages/docx-core/src/primitives/layout.ts` | `w:tcPrChange` | Cell padding changes belong under cell-property revisions. **Verified by [120.8] (#143) regression test.** | | `comments.ts` — `addComment` | `packages/docx-core/src/primitives/comments.ts` | `w:ins` | The comment anchor inserted into the body story and the initial comment text are revisionable content. Companion package bootstrap is listed in Table B. **Verified by [120.8] (#143) regression test.** | -| `comments.ts` — `addCommentReply` | `packages/docx-core/src/primitives/comments.ts` | *no body markup (see #174)* | Replies are side-part-only operations (no body anchor exists per-reply); the implementation accepts `ctx` as plumbing only. Classification revisit is tracked as **#174**. **Verified by [120.8] (#143) regression test (locks current side-part-only behavior).** | | `comments.ts` — `deleteComment` | `packages/docx-core/src/primitives/comments.ts` | `w:del` | Deleting a comment removes body anchors and comment/reply text. Cleanup of `commentsExtended.xml` remains the Table B companion. **Verified by [120.8] (#143) regression test.** | | `footnotes.ts` — `addFootnote` | `packages/docx-core/src/primitives/footnotes.ts` | `w:ins` | The inserted `w:footnoteReference` in the body story and the new footnote text both belong to the revisionable surface. Companion package bootstrap is listed in Table B. **Verified by [120.8] (#143) regression test.** | | `footnotes.ts` — `updateFootnoteText` | `packages/docx-core/src/primitives/footnotes.ts` | `w:ins`, `w:del` | Updating note text is a content revision inside the footnote body. **Verified by [120.8] (#143) regression test.** | @@ -42,7 +41,7 @@ Use the alternate contract below whenever a primitive/tool mutates relationships | Primitive / tool | Source path | What it mutates | Alternate contract | | --- | --- | --- | --- | | `comments.ts` — `bootstrapCommentParts` | `packages/docx-core/src/primitives/comments.ts` | Creates `word/comments.xml`, `word/commentsExtended.xml`, and `word/people.xml`; updates `word/_rels/document.xml.rels`; updates `[Content_Types].xml`. Companion body/comment-text rows are in Table A. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. | -| `comments.ts` — threaded comment metadata (`addComment`, `addCommentReply`, `deleteComment`) | `packages/docx-core/src/primitives/comments.ts` | Maintains `commentsExtended.xml` reply graph and `people.xml` author metadata that Word needs for comments and threaded replies. `addComment` always writes author metadata to `people.xml`, even for root comments without a thread. Companion content rows are in Table A. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. | +| `comments.ts` — threaded comment metadata (`addComment`, `addCommentReply`, `deleteComment`) | `packages/docx-core/src/primitives/comments.ts` | Maintains `commentsExtended.xml` reply graph and `people.xml` author metadata that Word needs for comments and threaded replies. `addCommentReply` is classified here because replies are side-part metadata writes with no body anchor per reply; `addComment` always writes author metadata to `people.xml`, even for root comments without a thread. Companion root-comment/deletion content rows are in Table A. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. | | `footnotes.ts` — `bootstrapFootnoteParts` | `packages/docx-core/src/primitives/footnotes.ts` | Creates `word/footnotes.xml`; updates `word/_rels/document.xml.rels`; updates `[Content_Types].xml`. Companion reference/note-text rows are in Table A. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. | | `add_comment` | `packages/docx-mcp/src/tools/add_comment.ts` | Always writes author metadata to `people.xml`. May also trigger comment-part bootstrap (`comments.xml`, `commentsExtended.xml`, relationships, content types) when the package lacks comment infrastructure, and writes to `commentsExtended.xml` for threaded replies. Companion anchor/text row is in Table A. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. | | `delete_comment` | `packages/docx-mcp/src/tools/delete_comment.ts` | Removes threaded-comment companion metadata from `commentsExtended.xml` alongside the Table A content deletion. | Recorded in the session's non-revision change manifest (per #122) and surfaced in the save report. Not wrapped in OOXML revision markup. | @@ -56,7 +55,7 @@ These files are intentionally outside the revisionable-surface contract. Some pe ### `docx-core` primitive files -- `accept_changes.ts` — tracked-change consumer that accepts existing `w:ins` / `w:del` / property-change markup instead of creating new AI-authored revisions. +- `accept_changes.ts` — tracked-change consumer that accepts existing `w:ins` / `w:del` / property-change markup in `document.xml` and supported side-story parts (`footnotes.xml`, `endnotes.xml`, `comments.xml`) instead of creating new AI-authored revisions. - `bookmarks.ts` — internal paragraph-bookmark scaffolding for stable selectors and anchor lookup, not user-visible AI content authorship. - `document.ts` — `DocxDocument` facade that routes to lower-level primitives; the contract is defined at the delegated primitive level, not this wrapper. - `document_view.ts` — read-only projection layer for toon/json/simple views, style discovery, and footnote marker display. @@ -71,7 +70,7 @@ These files are intentionally outside the revisionable-surface contract. Some pe - `namespaces.ts` — OOXML namespace constants only. - `numbering.ts` — read-only numbering model parser and label formatter. - `prevent_double_elevation.ts` — internal style-normalization helper for reference styles, not a user-directed AI mutation contract. -- `reject_changes.ts` — tracked-change consumer that rejects existing revisions instead of creating new ones. +- `reject_changes.ts` — tracked-change consumer that rejects existing revisions in `document.xml` and supported side-story parts (`footnotes.xml`, `endnotes.xml`, `comments.xml`) instead of creating new ones. - `relationships.ts` — read-only relationship parser. - `semantic_tags.ts` — string-level inline-tag helpers. - `simplify_redlines.ts` — post-processing helper that simplifies existing tracked-change markup instead of originating it. diff --git a/packages/docx-core/src/primitives/accept_changes.ts b/packages/docx-core/src/primitives/accept_changes.ts index 4180dfd..781206e 100644 --- a/packages/docx-core/src/primitives/accept_changes.ts +++ b/packages/docx-core/src/primitives/accept_changes.ts @@ -166,21 +166,21 @@ const PR_CHANGE_LOCALS = [ // ── Public API ────────────────────────────────────────────────────── /** - * Accept all tracked changes in the document body, producing a clean - * document with no revision markup. + * Accept all tracked changes in the document body or story root, producing a + * clean document with no revision markup. * * Mutates the Document in place (same convention as simplifyRedlines * and mergeRuns). */ export function acceptChanges(doc: Document): AcceptChangesResult { - const body = doc.getElementsByTagNameNS(W_NS, 'body').item(0); - if (!body) { + const root = doc.getElementsByTagNameNS(W_NS, 'body').item(0) ?? doc.documentElement; + if (!root) { return { insertionsAccepted: 0, deletionsAccepted: 0, movesResolved: 0, propertyChangesResolved: 0 }; } // Phase A — Identify paragraphs to remove const paragraphsToRemove = new Set(); - const allParagraphs = collectByLocalName(body, 'p'); + const allParagraphs = collectByLocalName(root, 'p'); for (const p of allParagraphs) { // Paragraph-level deletion marker: w:p > w:pPr > w:rPr > w:del @@ -195,26 +195,26 @@ export function acceptChanges(doc: Document): AcceptChangesResult { } // Phase B — Remove deletions and move sources - const deletionsAccepted = removeAllByLocalName(body, 'del'); - const moveFromRemoved = removeAllByLocalName(body, 'moveFrom'); - removeAllByLocalName(body, 'moveFromRangeStart'); - removeAllByLocalName(body, 'moveFromRangeEnd'); - removeAllByLocalName(body, 'moveToRangeStart'); - removeAllByLocalName(body, 'moveToRangeEnd'); + const deletionsAccepted = removeAllByLocalName(root, 'del'); + const moveFromRemoved = removeAllByLocalName(root, 'moveFrom'); + removeAllByLocalName(root, 'moveFromRangeStart'); + removeAllByLocalName(root, 'moveFromRangeEnd'); + removeAllByLocalName(root, 'moveToRangeStart'); + removeAllByLocalName(root, 'moveToRangeEnd'); // Phase C — Unwrap insertions and move destinations (depth-sorted) - const insertionsAccepted = unwrapAllByLocalName(body, 'ins'); - const moveToUnwrapped = unwrapAllByLocalName(body, 'moveTo'); + const insertionsAccepted = unwrapAllByLocalName(root, 'ins'); + const moveToUnwrapped = unwrapAllByLocalName(root, 'moveTo'); // Phase D — Remove property change records let propertyChangesResolved = 0; for (const localName of PR_CHANGE_LOCALS) { - propertyChangesResolved += removeAllByLocalName(body, localName); + propertyChangesResolved += removeAllByLocalName(root, localName); } // Phase E — Cleanup // Strip paragraph-level revision markers from w:pPr/w:rPr - for (const p of collectByLocalName(body, 'p')) { + for (const p of collectByLocalName(root, 'p')) { for (let i = 0; i < p.childNodes.length; i++) { const child = p.childNodes[i]!; if (!isW(child, 'pPr')) continue; @@ -244,7 +244,7 @@ export function acceptChanges(doc: Document): AcceptChangesResult { } // Strip w:rsidDel attributes on remaining elements - const allElements = body.getElementsByTagNameNS(W_NS, '*'); + const allElements = root.getElementsByTagNameNS(W_NS, '*'); for (let i = 0; i < allElements.length; i++) { const el = allElements[i]!; if (el.hasAttributeNS(W_NS, 'rsidDel')) { diff --git a/packages/docx-core/src/primitives/document.ts b/packages/docx-core/src/primitives/document.ts index b4c7bf1..72b61f2 100644 --- a/packages/docx-core/src/primitives/document.ts +++ b/packages/docx-core/src/primitives/document.ts @@ -40,6 +40,7 @@ import { simplifyRedlines } from './simplify_redlines.js'; import { preventDoubleElevation } from './prevent_double_elevation.js'; import { validateDocument, type ValidateDocumentResult } from './validate_document.js'; import { acceptChanges as acceptChangesImpl, type AcceptChangesResult } from './accept_changes.js'; +import { rejectChanges as rejectChangesImpl, type RejectChangesResult } from './reject_changes.js'; import { bootstrapCommentParts, addComment as addCommentImpl, @@ -69,6 +70,89 @@ export type NormalizationResult = { doubleElevationsFixed: number; }; +const REVISION_STORY_PART_PATHS = [ + 'word/footnotes.xml', + 'word/endnotes.xml', + 'word/comments.xml', +] as const; + +function emptyAcceptChangesResult(): AcceptChangesResult { + return { insertionsAccepted: 0, deletionsAccepted: 0, movesResolved: 0, propertyChangesResolved: 0 }; +} + +function hasAcceptedChanges(result: AcceptChangesResult): boolean { + return ( + result.insertionsAccepted > 0 || + result.deletionsAccepted > 0 || + result.movesResolved > 0 || + result.propertyChangesResolved > 0 + ); +} + +function addAcceptChangesResult(total: AcceptChangesResult, result: AcceptChangesResult): void { + total.insertionsAccepted += result.insertionsAccepted; + total.deletionsAccepted += result.deletionsAccepted; + total.movesResolved += result.movesResolved; + total.propertyChangesResolved += result.propertyChangesResolved; +} + +function emptyRejectChangesResult(): RejectChangesResult { + return { insertionsRemoved: 0, deletionsRestored: 0, movesReverted: 0, propertyChangesReverted: 0 }; +} + +function hasRejectedChanges(result: RejectChangesResult): boolean { + return ( + result.insertionsRemoved > 0 || + result.deletionsRestored > 0 || + result.movesReverted > 0 || + result.propertyChangesReverted > 0 + ); +} + +function addRejectChangesResult(total: RejectChangesResult, result: RejectChangesResult): void { + total.insertionsRemoved += result.insertionsRemoved; + total.deletionsRestored += result.deletionsRestored; + total.movesReverted += result.movesReverted; + total.propertyChangesReverted += result.propertyChangesReverted; +} + +function parseWId(el: Element): number | null { + const idStr = el.getAttributeNS(OOXML.W_NS, 'id') ?? el.getAttribute('w:id'); + if (!idStr) return null; + const n = parseInt(idStr, 10); + return Number.isNaN(n) ? null : n; +} + +function collectLiveFootnoteRefIds(doc: Document): Set { + const ids = new Set(); + const refs = doc.getElementsByTagNameNS(OOXML.W_NS, W.footnoteReference); + for (let i = 0; i < refs.length; i++) { + const id = parseWId(refs.item(i) as Element); + if (id !== null) ids.add(id); + } + return ids; +} + +// Side-effect of accept/reject on document.xml: a body w:footnoteReference that +// lived inside a removed w:del (accept) or w:ins (reject) is gone afterwards. +// The corresponding in footnotes.xml is then unreachable — +// remove it so the side part matches the post-sweep body. Reserved separator / +// continuationSeparator entries are preserved unconditionally. +function pruneOrphanedFootnotes(footnotesDoc: Document, liveRefIds: Set): number { + const entries = Array.from(footnotesDoc.getElementsByTagNameNS(OOXML.W_NS, W.footnote)); + let pruned = 0; + for (const fn of entries) { + const typ = fn.getAttributeNS(OOXML.W_NS, 'type') ?? fn.getAttribute('w:type'); + if (typ === W.separator || typ === W.continuationSeparator) continue; + const id = parseWId(fn); + if (id === null) continue; + if (liveRefIds.has(id)) continue; + fn.parentNode?.removeChild(fn); + pruned++; + } + return pruned; +} + export type ParagraphRef = { id: string; // _bk_### text: string; @@ -394,21 +478,91 @@ export class DocxDocument { } /** - * Accept all tracked changes in the document body, producing a clean - * document with no revision markup. + * Accept all tracked changes in document.xml plus supported revisionable + * side-story parts, producing clean XML with no revision markup. */ - acceptChanges(): AcceptChangesResult { - const result = acceptChangesImpl(this.documentXml); - if ( - result.insertionsAccepted > 0 || - result.deletionsAccepted > 0 || - result.movesResolved > 0 || - result.propertyChangesResolved > 0 - ) { + async acceptChanges(): Promise { + const total = emptyAcceptChangesResult(); + const bodyResult = acceptChangesImpl(this.documentXml); + addAcceptChangesResult(total, bodyResult); + + // After accepting, footnotes whose body reference lived inside a removed + // w:del are orphaned. Only worth checking when the body sweep removed + // deletions (the only operation that can drop a footnoteReference). + const liveFootnoteRefIds = bodyResult.deletionsAccepted > 0 + ? collectLiveFootnoteRefIds(this.documentXml) + : null; + + for (const partPath of REVISION_STORY_PART_PATHS) { + const xml = await this.zip.readTextOrNull(partPath); + if (!xml) continue; + + const partDoc = parseXml(xml); + const partResult = acceptChangesImpl(partDoc); + addAcceptChangesResult(total, partResult); + + let footnotesPruned = 0; + if (partPath === 'word/footnotes.xml' && liveFootnoteRefIds) { + footnotesPruned = pruneOrphanedFootnotes(partDoc, liveFootnoteRefIds); + } + + if (hasAcceptedChanges(partResult) || footnotesPruned > 0) { + this.zip.writeText(partPath, serializeXml(partDoc)); + if (partPath === 'word/footnotes.xml') { + this.footnotesXml = partDoc; + } + } + } + + if (hasAcceptedChanges(total)) { this.dirty = true; this.documentViewCache = null; } - return result; + return total; + } + + /** + * Reject all tracked changes in document.xml plus supported revisionable + * side-story parts, restoring their pre-edit state where possible. + */ + async rejectChanges(): Promise { + const total = emptyRejectChangesResult(); + const bodyResult = rejectChangesImpl(this.documentXml); + addRejectChangesResult(total, bodyResult); + + // After rejecting, footnotes whose body reference lived inside a removed + // w:ins are orphaned. Only worth checking when the body sweep removed + // insertions (the only operation that can drop a footnoteReference). + const liveFootnoteRefIds = bodyResult.insertionsRemoved > 0 + ? collectLiveFootnoteRefIds(this.documentXml) + : null; + + for (const partPath of REVISION_STORY_PART_PATHS) { + const xml = await this.zip.readTextOrNull(partPath); + if (!xml) continue; + + const partDoc = parseXml(xml); + const partResult = rejectChangesImpl(partDoc); + addRejectChangesResult(total, partResult); + + let footnotesPruned = 0; + if (partPath === 'word/footnotes.xml' && liveFootnoteRefIds) { + footnotesPruned = pruneOrphanedFootnotes(partDoc, liveFootnoteRefIds); + } + + if (hasRejectedChanges(partResult) || footnotesPruned > 0) { + this.zip.writeText(partPath, serializeXml(partDoc)); + if (partPath === 'word/footnotes.xml') { + this.footnotesXml = partDoc; + } + } + } + + if (hasRejectedChanges(total)) { + this.dirty = true; + this.documentViewCache = null; + } + return total; } removeJuniorBookmarks(): number { diff --git a/packages/docx-core/src/primitives/reject_changes.ts b/packages/docx-core/src/primitives/reject_changes.ts index 7d172db..cd21e75 100644 --- a/packages/docx-core/src/primitives/reject_changes.ts +++ b/packages/docx-core/src/primitives/reject_changes.ts @@ -243,20 +243,20 @@ function relocateBookmarks(p: Element, paragraphsToRemove: Set): void { // ── Public API ────────────────────────────────────────────────────── /** - * Reject all tracked changes in the document body, restoring the - * document to its pre-edit state. + * Reject all tracked changes in the document body or story root, restoring + * the document to its pre-edit state. * * Mutates the Document in place (same convention as acceptChanges). */ export function rejectChanges(doc: Document): RejectChangesResult { - const body = doc.getElementsByTagNameNS(W_NS, 'body').item(0); - if (!body) { + const root = doc.getElementsByTagNameNS(W_NS, 'body').item(0) ?? doc.documentElement; + if (!root) { return { insertionsRemoved: 0, deletionsRestored: 0, movesReverted: 0, propertyChangesReverted: 0 }; } // Phase A — Identify paragraphs to remove (entirely inserted paragraphs) const paragraphsToRemove = new Set(); - const allParagraphs = collectByLocalName(body, 'p'); + const allParagraphs = collectByLocalName(root, 'p'); for (const p of allParagraphs) { // Paragraph-level insertion marker: w:p > w:pPr > w:rPr > w:ins @@ -276,18 +276,18 @@ export function rejectChanges(doc: Document): RejectChangesResult { } // Phase C — Remove insertions and move destinations - const insertionsRemoved = removeAllByLocalName(body, 'ins'); - const moveToRemoved = removeAllByLocalName(body, 'moveTo'); - removeAllByLocalName(body, 'moveToRangeStart'); - removeAllByLocalName(body, 'moveToRangeEnd'); - removeAllByLocalName(body, 'moveFromRangeStart'); - removeAllByLocalName(body, 'moveFromRangeEnd'); + const insertionsRemoved = removeAllByLocalName(root, 'ins'); + const moveToRemoved = removeAllByLocalName(root, 'moveTo'); + removeAllByLocalName(root, 'moveToRangeStart'); + removeAllByLocalName(root, 'moveToRangeEnd'); + removeAllByLocalName(root, 'moveFromRangeStart'); + removeAllByLocalName(root, 'moveFromRangeEnd'); // Phase D — Unwrap deletions and convert w:delText → w:t - const deletionsRestored = unwrapAllByLocalName(body, 'del'); + const deletionsRestored = unwrapAllByLocalName(root, 'del'); // Rename all w:delText elements to w:t so getParagraphText() sees them - const delTexts = collectByLocalName(body, 'delText'); + const delTexts = collectByLocalName(root, 'delText'); for (const dt of delTexts) { const parent = dt.parentNode; if (!parent) continue; @@ -306,12 +306,12 @@ export function rejectChanges(doc: Document): RejectChangesResult { } // Phase E — Unwrap move sources (keep content at original position) - const moveFromUnwrapped = unwrapAllByLocalName(body, 'moveFrom'); + const moveFromUnwrapped = unwrapAllByLocalName(root, 'moveFrom'); // Phase F — Restore original properties from *PrChange records let propertyChangesReverted = 0; for (const localName of PR_CHANGE_LOCALS) { - const changes = collectByLocalName(body, localName); + const changes = collectByLocalName(root, localName); // Sort deepest-first changes.sort((a, b) => getDepth(b) - getDepth(a)); for (const change of changes) { @@ -350,7 +350,7 @@ export function rejectChanges(doc: Document): RejectChangesResult { // Phase G — Cleanup // Strip paragraph-level revision markers from w:pPr/w:rPr - for (const p of collectByLocalName(body, 'p')) { + for (const p of collectByLocalName(root, 'p')) { for (let i = 0; i < p.childNodes.length; i++) { const child = p.childNodes[i]!; if (!isW(child, 'pPr')) continue; @@ -379,7 +379,7 @@ export function rejectChanges(doc: Document): RejectChangesResult { } // Strip w:rsidDel attributes on remaining elements - const allElements = body.getElementsByTagNameNS(W_NS, '*'); + const allElements = root.getElementsByTagNameNS(W_NS, '*'); for (let i = 0; i < allElements.length; i++) { const el = allElements[i]!; if (el.hasAttributeNS(W_NS, 'rsidDel')) { diff --git a/packages/docx-core/test-primitives/document.test.ts b/packages/docx-core/test-primitives/document.test.ts index 913e80a..ce1e43d 100644 --- a/packages/docx-core/test-primitives/document.test.ts +++ b/packages/docx-core/test-primitives/document.test.ts @@ -3,6 +3,7 @@ import { testAllure, type AllureBddContext } from './helpers/allure-test.js'; import JSZip from 'jszip'; import { DocxDocument } from '../src/primitives/document.js'; import { DocxZip } from '../src/primitives/zip.js'; +import { createRevisionContext, createRevisionIdState } from '../src/primitives/track-changes-emitter.js'; const test = testAllure.epic('DOCX Primitives').withLabels({ feature: 'Document' }); @@ -33,6 +34,11 @@ async function getDocumentXmlFromBuffer(buffer: Buffer): Promise { return zip.readText('word/document.xml'); } +async function getPartXmlFromBuffer(buffer: Buffer, partPath: string): Promise { + const zip = await DocxZip.load(buffer); + return zip.readText(partPath); +} + describe('DocxDocument', () => { test('reads paragraphs with offset/limit/nodeIds and supports negative offsets', async ({ given, when, then, and }: AllureBddContext) => { let doc!: DocxDocument; @@ -269,8 +275,8 @@ describe('DocxDocument', () => { expect(merge.runsMerged).toBeGreaterThanOrEqual(1); }); - await when('acceptChanges is called', () => { - const accepted = doc.acceptChanges(); + await when('acceptChanges is called', async () => { + const accepted = await doc.acceptChanges(); expect(accepted.insertionsAccepted).toBeGreaterThanOrEqual(1); }); @@ -279,4 +285,262 @@ describe('DocxDocument', () => { expect(text).toContain('Hello New'); }); }); + + test('acceptChanges cleans tracked insertions from document.xml and footnotes.xml', async ({ + given, + when, + then, + }: AllureBddContext) => { + let doc!: DocxDocument; + let result!: Awaited>; + let documentXml!: string; + let footnotesXml!: string; + + await given('a document with an AI-authored tracked footnote insertion', async () => { + const buffer = await makeDocxBuffer('Hello world'); + doc = await DocxDocument.load(buffer); + doc.insertParagraphBookmarks('mcp_accept_footnote'); + const paragraphId = doc.readParagraphs().paragraphs[0]!.id; + await doc.addFootnote( + { paragraphId, text: 'Tracked footnote' }, + createRevisionContext({ + author: 'SafeDocX AI', + date: '2026-05-06T14:15:16Z', + idState: createRevisionIdState(), + }), + ); + }); + + await when('acceptChanges is called', async () => { + result = await doc.acceptChanges(); + const { buffer } = await doc.toBuffer({ cleanBookmarks: false }); + documentXml = await getPartXmlFromBuffer(buffer, 'word/document.xml'); + footnotesXml = await getPartXmlFromBuffer(buffer, 'word/footnotes.xml'); + }); + + await then('both the body story and footnote story are clean', () => { + expect(result.insertionsAccepted).toBeGreaterThanOrEqual(2); + expect(documentXml).not.toContain(' { + let doc!: DocxDocument; + let result!: Awaited>; + let footnotesXml!: string; + + await given('a document with an existing footnote updated under tracked changes', async () => { + const buffer = await makeDocxBuffer('Hello world'); + doc = await DocxDocument.load(buffer); + doc.insertParagraphBookmarks('mcp_reject_footnote'); + const paragraphId = doc.readParagraphs().paragraphs[0]!.id; + const added = await doc.addFootnote({ paragraphId, text: 'Original footnote' }); + await doc.updateFootnoteText( + { noteId: added.noteId, newText: 'Replacement footnote' }, + createRevisionContext({ + author: 'SafeDocX AI', + date: '2026-05-06T14:15:16Z', + idState: createRevisionIdState(), + }), + ); + }); + + await when('rejectChanges is called', async () => { + result = await doc.rejectChanges(); + const { buffer } = await doc.toBuffer({ cleanBookmarks: false }); + footnotesXml = await getPartXmlFromBuffer(buffer, 'word/footnotes.xml'); + }); + + await then('the footnote story is clean and contains the original note text', () => { + expect(result.insertionsRemoved).toBeGreaterThanOrEqual(1); + expect(result.deletionsRestored).toBeGreaterThanOrEqual(1); + expect(footnotesXml).not.toContain(' { + let doc!: DocxDocument; + let documentXml!: string; + let footnotesXml!: string; + let noteId!: number; + + await given('a document with a tracked-inserted footnote', async () => { + const buffer = await makeDocxBuffer('Hello world'); + doc = await DocxDocument.load(buffer); + doc.insertParagraphBookmarks('mcp_reject_added_footnote'); + const paragraphId = doc.readParagraphs().paragraphs[0]!.id; + const added = await doc.addFootnote( + { paragraphId, text: 'Tracked footnote' }, + createRevisionContext({ + author: 'SafeDocX AI', + date: '2026-05-06T14:15:16Z', + idState: createRevisionIdState(), + }), + ); + noteId = added.noteId; + }); + + await when('rejectChanges is called', async () => { + await doc.rejectChanges(); + const { buffer } = await doc.toBuffer({ cleanBookmarks: false }); + documentXml = await getPartXmlFromBuffer(buffer, 'word/document.xml'); + footnotesXml = await getPartXmlFromBuffer(buffer, 'word/footnotes.xml'); + }); + + await then('the body reference and the orphan footnote entry are both gone', () => { + expect(documentXml).not.toContain(' { + let doc!: DocxDocument; + let documentXml!: string; + let footnotesXml!: string; + let noteId!: number; + + await given('a document with an existing footnote marked for deletion under tracked changes', async () => { + const buffer = await makeDocxBuffer('Hello world'); + doc = await DocxDocument.load(buffer); + doc.insertParagraphBookmarks('mcp_accept_deleted_footnote'); + const paragraphId = doc.readParagraphs().paragraphs[0]!.id; + const added = await doc.addFootnote({ paragraphId, text: 'Doomed footnote' }); + noteId = added.noteId; + await doc.deleteFootnote( + { noteId }, + createRevisionContext({ + author: 'SafeDocX AI', + date: '2026-05-06T14:15:16Z', + idState: createRevisionIdState(), + }), + ); + }); + + await when('acceptChanges is called', async () => { + await doc.acceptChanges(); + const { buffer } = await doc.toBuffer({ cleanBookmarks: false }); + documentXml = await getPartXmlFromBuffer(buffer, 'word/document.xml'); + footnotesXml = await getPartXmlFromBuffer(buffer, 'word/footnotes.xml'); + }); + + await then('the body reference and the now-orphaned footnote entry are both gone', () => { + expect(documentXml).not.toContain(' { + let doc!: DocxDocument; + let documentXml!: string; + let footnotesXml!: string; + let noteId!: number; + + await given('a document with an existing footnote marked for deletion under tracked changes', async () => { + const buffer = await makeDocxBuffer('Hello world'); + doc = await DocxDocument.load(buffer); + doc.insertParagraphBookmarks('mcp_reject_deleted_footnote'); + const paragraphId = doc.readParagraphs().paragraphs[0]!.id; + const added = await doc.addFootnote({ paragraphId, text: 'Surviving footnote' }); + noteId = added.noteId; + await doc.deleteFootnote( + { noteId }, + createRevisionContext({ + author: 'SafeDocX AI', + date: '2026-05-06T14:15:16Z', + idState: createRevisionIdState(), + }), + ); + }); + + await when('rejectChanges is called', async () => { + await doc.rejectChanges(); + const { buffer } = await doc.toBuffer({ cleanBookmarks: false }); + documentXml = await getPartXmlFromBuffer(buffer, 'word/document.xml'); + footnotesXml = await getPartXmlFromBuffer(buffer, 'word/footnotes.xml'); + }); + + await then('the body reference and the original footnote text are restored', () => { + expect(documentXml).toContain(` { + let doc!: DocxDocument; + let footnotesXml!: string; + let noteId!: number; + + await given('a document with a footnote whose text has been replaced under tracked changes', async () => { + const buffer = await makeDocxBuffer('Hello world'); + doc = await DocxDocument.load(buffer); + doc.insertParagraphBookmarks('mcp_accept_updated_footnote'); + const paragraphId = doc.readParagraphs().paragraphs[0]!.id; + const added = await doc.addFootnote({ paragraphId, text: 'Original footnote' }); + noteId = added.noteId; + await doc.updateFootnoteText( + { noteId, newText: 'Replacement footnote' }, + createRevisionContext({ + author: 'SafeDocX AI', + date: '2026-05-06T14:15:16Z', + idState: createRevisionIdState(), + }), + ); + }); + + await when('acceptChanges is called', async () => { + await doc.acceptChanges(); + const { buffer } = await doc.toBuffer({ cleanBookmarks: false }); + footnotesXml = await getPartXmlFromBuffer(buffer, 'word/footnotes.xml'); + }); + + await then('the replacement text wins and no revision markup remains', () => { + expect(footnotesXml).toContain(`w:id="${noteId}"`); + expect(footnotesXml).toContain('Replacement footnote'); + expect(footnotesXml).not.toContain('Original footnote'); + expect(footnotesXml).not.toContain('