From ebb523abf29e1890c5f981e8f731dae86180463f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 00:59:59 +0000 Subject: [PATCH] fix(comments): mirror add/reply/delete into Word's comment sidecars A Word-saved package carries commentsExtended.xml, commentsIds.xml and commentsExtensible.xml with one entry per comment. `comments add` wrote none of them and `comments delete` never pruned commentsIds/Extensible, so Word offered to repair the file and renumbered every comment. CommentsView now loads and writes commentsIds/commentsExtensible beside commentsExtended, registers a commentEx + commentId (fresh durableId) + commentExtensible for every comment it appends (add, reply, audit), and removes all three on delete. Absent sidecars stay absent. `raw part replace/edit` now rejects the two newly view-owned parts. Fixes #33 Triage-Routine: true Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LkctEMYpX1DxpKLGCMzZxp --- REFERENCE.md | 2 +- src/core/ast/document/comments.tsx | 158 ++++++++++++++++++++++++++--- src/core/comments/index.tsx | 19 ++-- src/core/raw/parts.ts | 4 + tests/cli/comments.test.ts | 120 ++++++++++++++++++++++ 5 files changed, 277 insertions(+), 26 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index e65ffa8..9c9ac9f 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -507,7 +507,7 @@ docx track-changes apply doc.docx --accept rev0 --reject rev1 # finalize: acce **JSX for emitters.** Constructing OOXML fragments imperatively (`` → `` → ``) is verbose, so fresh XML is authored in JSX with a custom factory: `` becomes the right `XmlNode` tree. -**Span-aware comments & hyperlinks.** `comments add --at p3:5-20` (and `hyperlinks add`) find the runs containing offsets 5 and 20, split them at the boundaries (preserving `` on both halves), and insert markers between the slices. Comments authored by older tools that lack `w14:paraId` (required by `commentsExtended.xml`) get a fresh paraId injected automatically on resolve/reply. +**Span-aware comments & hyperlinks.** `comments add --at p3:5-20` (and `hyperlinks add`) find the runs containing offsets 5 and 20, split them at the boundaries (preserving `` on both halves), and insert markers between the slices. Comments authored by older tools that lack `w14:paraId` (required by `commentsExtended.xml`) get a fresh paraId injected automatically on resolve/reply. When a Word-saved file carries Word's comment sidecars (`commentsExtended.xml`, `commentsIds.xml`, `commentsExtensible.xml`), every `comments add`/`reply`/`delete` adds or removes the matching entry in each one, so Word never offers to repair the file. A file without them stays without them. **Tracked changes.** With `` set, `insert`/`edit`/`delete`/`replace` emit native ``/`` (attributed via `--author`, `$DOCX_AUTHOR`, or `Reviewer`); pass `--track` to one of those commands (or the `tables` verbs / `images delete`) to track just that invocation even when the doc toggle is off. `edit --at pN --text` runs a word-level diff so unchanged words keep their formatting and only changed words are wrapped — the same shape Word produces mid-tracking. `accept`/`reject` handle run-level ins/del/moveFrom/moveTo, `sectPrChange`, paragraph-mark ins/del, and the table-structural revisions (rowIns/rowDel, cellIns/cellDel, tblGridChange, tcPrChange). OOXML has no tracked-change construct for hyperlink edits or image swaps, so under tracking those emit a `[docx-cli]` audit comment instead of a fake revision (image _deletion_ is honest removal — it wraps a real ``). diff --git a/src/core/ast/document/comments.tsx b/src/core/ast/document/comments.tsx index f4d94f1..5dd8585 100644 --- a/src/core/ast/document/comments.tsx +++ b/src/core/ast/document/comments.tsx @@ -9,6 +9,13 @@ import type { RelationshipsView } from "./relationships"; const COMMENTS_PART_NAME = "word/comments.xml"; const COMMENTS_EXT_PART_NAME = "word/commentsExtended.xml"; +// Word 2016+/2018+ sidecars keyed to each comment's `w14:paraId` +// (`commentsIds`) and to the `durableId` it mints there (`commentsExtensible`). +// We never create them — a package without them is consistent — but when a +// Word-saved input carries them, every comment we add or delete must be +// mirrored, or Word reports unreadable content and renumbers every comment. +const COMMENTS_IDS_PART_NAME = "word/commentsIds.xml"; +const COMMENTS_EXTENSIBLE_PART_NAME = "word/commentsExtensible.xml"; const COMMENTS_RELATIONSHIP_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"; const COMMENTS_EXT_RELATIONSHIP_TYPE = @@ -25,11 +32,19 @@ const NS_W15 = "http://schemas.microsoft.com/office/word/2012/wordml"; export class CommentsView { tree: XmlNode[]; extendedTree?: XmlNode[]; + idsTree?: XmlNode[]; + extensibleTree?: XmlNode[]; commentReferences: Map = new Map(); - constructor(tree: XmlNode[] = [], extendedTree?: XmlNode[]) { + constructor( + tree: XmlNode[] = [], + extendedTree?: XmlNode[], + durableSidecars: { idsTree?: XmlNode[]; extensibleTree?: XmlNode[] } = {}, + ) { this.tree = tree; this.extendedTree = extendedTree; + this.idsTree = durableSidecars.idsTree; + this.extensibleTree = durableSidecars.extensibleTree; // `w14:paraId`/`w14:textId` (Word's threading keys, written on every comment // body) are valid only when `w14` rides the root's `mc:Ignorable`. A part // that came WITH the document — from Word, which often omits it here, or an @@ -41,13 +56,17 @@ export class CommentsView { if (root) declareW14(root); } - /** Load this view (and its extended sidecar) from a package; returns - * undefined if the base comments part is absent. */ + /** Load this view (and its extended/ids/extensible sidecars) from a + * package; returns undefined if the base comments part is absent. */ static async fromPackage(pkg: Pkg): Promise { const tree = await pkg.readPart(COMMENTS_PART_NAME); if (!tree) return undefined; - const extendedTree = await pkg.readPart(COMMENTS_EXT_PART_NAME); - return new CommentsView(tree, extendedTree); + const [extendedTree, idsTree, extensibleTree] = await Promise.all([ + pkg.readPart(COMMENTS_EXT_PART_NAME), + pkg.readPart(COMMENTS_IDS_PART_NAME), + pkg.readPart(COMMENTS_EXTENSIBLE_PART_NAME), + ]); + return new CommentsView(tree, extendedTree, { idsTree, extensibleTree }); } /** Parse a view from raw XML; returns undefined if the base input is @@ -64,15 +83,18 @@ export class CommentsView { ); } - /** Serialize this view's tree (and the extended sidecar if present) into - * the package's `word/comments.xml` and `word/commentsExtended.xml`. */ + /** Serialize this view's tree (and each sidecar that is present) into + * the package's `word/comments.xml`, `commentsExtended.xml`, + * `commentsIds.xml` and `commentsExtensible.xml`. */ writeTo(pkg: Pkg): void { pkg.writeText(COMMENTS_PART_NAME, XmlNode.serialize(this.tree)); - if (this.extendedTree) { - pkg.writeText( - COMMENTS_EXT_PART_NAME, - XmlNode.serialize(this.extendedTree), - ); + const sidecars: [string, XmlNode[] | undefined][] = [ + [COMMENTS_EXT_PART_NAME, this.extendedTree], + [COMMENTS_IDS_PART_NAME, this.idsTree], + [COMMENTS_EXTENSIBLE_PART_NAME, this.extensibleTree], + ]; + for (const [partName, sidecarTree] of sidecars) { + if (sidecarTree) pkg.writeText(partName, XmlNode.serialize(sidecarTree)); } } @@ -288,6 +310,88 @@ export class CommentsView { root.children.splice(insertIndex, 0, comment); } + /** Register a freshly appended comment, keyed by its (last) paragraph's + * `paraId`, in every sidecar the package ALREADY carries — the shape Word + * writes: a `` (skipped for a `threaded` reply, + * whose lens places its own entry with `paraIdParent`), a + * `` with a freshly minted `durableId`, and a + * `` carrying that `durableId` + `dateUtc`. An + * absent sidecar stays absent. */ + registerSidecarEntries( + paraId: string, + date: string, + options: { threaded: boolean }, + ): void { + const extendedRoot = sidecarRoot(this.extendedTree, "w15:commentsEx"); + if (extendedRoot && !options.threaded) { + extendedRoot.children.push( + new XmlNode("w15:commentEx", { + "w15:paraId": paraId, + "w15:done": "0", + }), + ); + } + const idsRoot = sidecarRoot(this.idsTree, "w16cid:commentsIds"); + if (!idsRoot) return; + const durableId = mintDurableId(idsRoot); + idsRoot.children.push( + new XmlNode("w16cid:commentId", { + "w16cid:paraId": paraId, + "w16cid:durableId": durableId, + }), + ); + const extensibleRoot = sidecarRoot( + this.extensibleTree, + "w16cex:commentsExtensible", + ); + if (!extensibleRoot) return; + const dateUtc = toDateUtc(date); + extensibleRoot.children.push( + new XmlNode("w16cex:commentExtensible", { + "w16cex:durableId": durableId, + ...(dateUtc ? { "w16cex:dateUtc": dateUtc } : {}), + }), + ); + } + + /** Drop every sidecar entry keyed to a deleted comment's `paraId`: its + * ``, its ``, and the + * `` sharing that entry's `durableId`. */ + removeSidecarEntries(paraId: string): void { + const extendedRoot = sidecarRoot(this.extendedTree, "w15:commentsEx"); + if (extendedRoot) { + extendedRoot.children = extendedRoot.children.filter( + (child) => + !( + child.tag === "w15:commentEx" && + child.getAttribute("w15:paraId") === paraId + ), + ); + } + const idsRoot = sidecarRoot(this.idsTree, "w16cid:commentsIds"); + if (!idsRoot) return; + const removedDurableIds = new Set(); + idsRoot.children = idsRoot.children.filter((child) => { + if (child.tag !== "w16cid:commentId") return true; + if (child.getAttribute("w16cid:paraId") !== paraId) return true; + const durableId = child.getAttribute("w16cid:durableId"); + if (durableId) removedDurableIds.add(durableId); + return false; + }); + const extensibleRoot = sidecarRoot( + this.extensibleTree, + "w16cex:commentsExtensible", + ); + if (!extensibleRoot) return; + extensibleRoot.children = extensibleRoot.children.filter( + (child) => + !( + child.tag === "w16cex:commentExtensible" && + removedDurableIds.has(child.getAttribute("w16cex:durableId") ?? "") + ), + ); + } + /** Parse this part (plus the extended sidecar) into the AST `Comment[]` the * reader exposes on `Body.comments`, and populate `commentReferences` for * mutators. `anchors` are the span ranges the body walk computed from @@ -421,6 +525,36 @@ function lastParagraph(comment: XmlNode | undefined): XmlNode | undefined { return comment?.findChildren("w:p").at(-1); } +function sidecarRoot( + tree: XmlNode[] | undefined, + rootTag: string, +): XmlNode | undefined { + return tree ? XmlNode.findRoot(tree, rootTag) : undefined; +} + +/** Mint a `w16cid:durableId` unique within the part. MS-DOCX bounds it to + * (0, 0x7FFFFFFF) — the paraId range minus its top value — so reuse the + * paraId minter (and its `DOCX_CLI_PARA_ID_SEED` determinism seam). */ +function mintDurableId(idsRoot: XmlNode): string { + const taken = new Set( + idsRoot.children.map((child) => + child.getAttribute("w16cid:durableId")?.toUpperCase(), + ), + ); + for (;;) { + const candidate = generateParaId(); + if (candidate !== "7FFFFFFF" && !taken.has(candidate)) return candidate; + } +} + +/** Word's `w16cex:dateUtc` is a whole-second UTC `xsd:dateTime`. Undefined + * (attribute omitted — it's optional) when `date` doesn't parse. */ +function toDateUtc(date: string): string | undefined { + const parsed = new Date(date); + if (Number.isNaN(parsed.getTime())) return undefined; + return parsed.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + /** Mint a fresh `w14:paraId` value. Word writes 8-char uppercase hex; we * match. Per MS-DOCX, a paraId MUST be greater than 0x00000000 and less * than 0x80000000 — Word silently re-keys out-of-range ids on open, which diff --git a/src/core/comments/index.tsx b/src/core/comments/index.tsx index 07f1dd8..0e86890 100644 --- a/src/core/comments/index.tsx +++ b/src/core/comments/index.tsx @@ -285,7 +285,8 @@ export class Comments { } /** Remove every id in `ids`: splice the `` body, prune any - * `` entry keyed to its paraId, and strip + * sidecar entry keyed to its paraId (`commentsExtended`/`commentsIds`/ + * `commentsExtensible`), and strip * `` / `` / * `` markers from the body. Deleting a thread parent * cascades through its replies so none is left with dangling body markers @@ -325,18 +326,7 @@ export class Comments { const index = root.children.indexOf(node); if (index !== -1) root.children.splice(index, 1); - if (paraId && view.extendedTree) { - const extRoot = XmlNode.findRoot(view.extendedTree, "w15:commentsEx"); - if (extRoot) { - extRoot.children = extRoot.children.filter( - (child) => - !( - child.tag === "w15:commentEx" && - child.getAttribute("w15:paraId") === paraId - ), - ); - } - } + if (paraId) view.removeSidecarEntries(paraId); removeCommentMarkers(this.document.documentTree, numericId); } @@ -392,6 +382,9 @@ export class Comments { }} /> ); + commentsView.registerSidecarEntries(paraId, date, { + threaded: Boolean(options?.threadParentNumericId), + }); if (options?.threadParentNumericId) { commentsView.insertReplyAfter(options.threadParentNumericId, comment); return; diff --git a/src/core/raw/parts.ts b/src/core/raw/parts.ts index cc97f1c..1a3dcb1 100644 --- a/src/core/raw/parts.ts +++ b/src/core/raw/parts.ts @@ -225,6 +225,10 @@ function partReplaceRoute(name: string): PartReplaceRoute | { hint: string } { "Comment ids pair with body range marks — use docx comments add/reply/resolve.", "word/commentsExtended.xml": "Threading pairs with comments.xml — use docx comments add/reply/resolve.", + "word/commentsIds.xml": + "Durable ids pair with comments.xml — use docx comments add/reply/delete.", + "word/commentsExtensible.xml": + "Durable ids pair with comments.xml — use docx comments add/reply/delete.", "docProps/core.xml": "Core properties are managed by docx-cli.", "[Content_Types].xml": "Content types are registered automatically when parts are added.", diff --git a/tests/cli/comments.test.ts b/tests/cli/comments.test.ts index 346ba51..1bdb5a3 100644 --- a/tests/cli/comments.test.ts +++ b/tests/cli/comments.test.ts @@ -1391,3 +1391,123 @@ describe("docx comments reply --batch", () => { expect(result.exitCode).toBe(2); }); }); + +describe("docx comments — Word's durable-id sidecars (issue #33)", () => { + // Word 365 writes three sidecars beside comments.xml, each with ONE entry per + // comment: commentsExtended (`w15:commentEx`, keyed by the comment's + // `w14:paraId`), commentsIds (`w16cid:commentId`, paraId → durableId) and + // commentsExtensible (`w16cex:commentExtensible`, keyed by durableId). A + // comment missing from them makes Word offer to repair the file — and the + // repair renumbers every comment. + const NS_MC = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + + async function wordShapedDocument(): Promise { + const workspace = tempWorkspace("comments-sidecars"); + const docPath = join(workspace, "word-shaped.docx"); + await runCli("create", docPath, "--text", "First clause. Second clause."); + await runCli("comments", "add", docPath, "--at", "p0", "--text", "Seed"); + const pkg = await Pkg.open(docPath); + const paraId = (await pkg.readText("word/comments.xml")).match( + /w14:paraId="([0-9A-F]{8})"/, + )?.[1]; + expect(paraId).toBeDefined(); + pkg.writeText( + "word/commentsExtended.xml", + ``, + ); + pkg.writeText( + "word/commentsIds.xml", + ``, + ); + pkg.writeText( + "word/commentsExtensible.xml", + ``, + ); + await pkg.save(); + return docPath; + } + + async function sidecarState(docPath: string) { + const all = (xml: string, pattern: RegExp) => + [...xml.matchAll(pattern)].map((match) => match[1]); + const comments = await readPart(docPath, "word/comments.xml"); + const ids = await readPart(docPath, "word/commentsIds.xml"); + return { + commentParaIds: all( + comments, + /]*>.*?w14:paraId="([0-9A-F]{8})"/gs, + ), + extendedParaIds: all( + await readPart(docPath, "word/commentsExtended.xml"), + /]*w15:paraId="([0-9A-F]{8})"/g, + ), + idsParaIds: all(ids, /w16cid:paraId="([0-9A-F]{8})"/g), + idsDurableIds: all(ids, /w16cid:durableId="([0-9A-F]{8})"/g), + extensibleDurableIds: all( + await readPart(docPath, "word/commentsExtensible.xml"), + /w16cex:durableId="([0-9A-F]{8})"/g, + ), + }; + } + + function expectConsistent(state: Awaited>) { + const sorted = (values: (string | undefined)[]) => [...values].sort(); + expect(sorted(state.extendedParaIds)).toEqual(sorted(state.commentParaIds)); + expect(sorted(state.idsParaIds)).toEqual(sorted(state.commentParaIds)); + expect(sorted(state.extensibleDurableIds)).toEqual( + sorted(state.idsDurableIds), + ); + expect(new Set(state.idsDurableIds).size).toBe(state.idsDurableIds.length); + } + + test("add, reply and delete keep every sidecar 1:1 with comments.xml", async () => { + const docPath = await wordShapedDocument(); + + await runCli( + "comments", + "add", + docPath, + "--anchor", + "First", + "--text", + "A", + ); + await runCli("comments", "add", docPath, "--at", "p0", "--text", "B"); + let state = await sidecarState(docPath); + expect(state.commentParaIds).toHaveLength(3); + expectConsistent(state); + expect(state.idsDurableIds).toContain("1A2B3C4D"); + + await runCli("comments", "reply", docPath, "--at", "c1", "--text", "R"); + state = await sidecarState(docPath); + expect(state.commentParaIds).toHaveLength(4); + expectConsistent(state); + + // Deleting the thread root cascades to its reply — both leave every part. + await runCli("comments", "delete", docPath, "--at", "c1"); + state = await sidecarState(docPath); + expect(state.commentParaIds).toHaveLength(2); + expectConsistent(state); + + await runCli("comments", "delete", docPath, "--at", "c0"); + state = await sidecarState(docPath); + expect(state.idsDurableIds).not.toContain("1A2B3C4D"); + expectConsistent(state); + expect((await runCli("validate", docPath)).exitCode).toBe(0); + }); + + test("a package without the sidecars stays without them", async () => { + const workspace = tempWorkspace("comments-no-sidecars"); + const docPath = join(workspace, "plain.docx"); + await runCli("create", docPath, "--text", "Clause."); + await runCli("comments", "add", docPath, "--at", "p0", "--text", "A"); + const pkg = await Pkg.open(docPath); + for (const part of [ + "word/commentsExtended.xml", + "word/commentsIds.xml", + "word/commentsExtensible.xml", + ]) { + expect(await pkg.readPart(part)).toBeUndefined(); + } + }); +});