@adeu/core 1.18.1 → 1.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.18.1",
3
+ "version": "1.18.2",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -0,0 +1,174 @@
1
+ // FILE: node/packages/core/src/engine.comment-preservation.test.ts
2
+ import { describe, it, expect } from "vitest";
3
+ import { zipSync, strToU8 } from "fflate";
4
+ import { DocumentObject } from "./docx/bridge.js";
5
+ import { extract_comments_data } from "./comments.js";
6
+ import { RedlineEngine } from "./engine.js";
7
+
8
+ /**
9
+ * Regression test for author-aware comment preservation on accept/reject.
10
+ *
11
+ * Bug: accepting a tracked change whose run is wrapped by a comment caused
12
+ * `_clean_wrapping_comments` to delete that comment unconditionally — even when
13
+ * it belonged to another author. In the playbook-commenting scenario this
14
+ * silently erased the counterparty's ("Supplier's Counsel") annotation the
15
+ * instant their insertion (Chg:2) was accepted, destroying provenance and
16
+ * failing the "keep the counterparty comment" requirement.
17
+ *
18
+ * Fix: a wrapping comment authored by someone else has its range markers
19
+ * detached (so the accept proceeds with no orphaned anchor) but its BODY is
20
+ * kept in the comments part. Own-authored wrapping comments are still deleted.
21
+ *
22
+ * The triggering document is built in-memory as a minimal valid .docx so the
23
+ * test doesn't depend on the contents of any golden fixture: a paragraph with a
24
+ * foreign <w:ins id="2"> whose run is wrapped by comment id="1" from that same
25
+ * foreign author.
26
+ */
27
+
28
+ const WORD_XMLNS =
29
+ 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' +
30
+ 'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" ' +
31
+ 'xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"';
32
+
33
+ function xmlDecl(body: string): string {
34
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + body;
35
+ }
36
+
37
+ /**
38
+ * Build a minimal, valid DOCX buffer containing a wrapped foreign insertion:
39
+ *
40
+ * commentRangeStart(1) → <w:ins id=2 author=insAuthor>run("INSERTED")</w:ins>
41
+ * → commentRangeEnd(1) → <w:r><w:commentReference id=1/></w:r>
42
+ *
43
+ * plus a comments part with one comment id=1 authored by commentAuthor, body
44
+ * "robust protection". Includes [Content_Types].xml (with the comments
45
+ * Override) and word/_rels/document.xml.rels so `load()` classifies the parts.
46
+ */
47
+ async function buildWrappedInsertionDoc(
48
+ insAuthor: string,
49
+ commentAuthor: string,
50
+ ): Promise<DocumentObject> {
51
+ const contentTypes = xmlDecl(
52
+ `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
53
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
54
+ <Default Extension="xml" ContentType="application/xml"/>
55
+ <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
56
+ <Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>
57
+ </Types>`,
58
+ );
59
+
60
+ const rootRels = xmlDecl(
61
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
62
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
63
+ </Relationships>`,
64
+ );
65
+
66
+ const documentRels = xmlDecl(
67
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
68
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>
69
+ </Relationships>`,
70
+ );
71
+
72
+ const documentXml = xmlDecl(
73
+ `<w:document ${WORD_XMLNS}>
74
+ <w:body>
75
+ <w:p w14:paraId="00000001">
76
+ <w:r><w:t xml:space="preserve">Prefix text. </w:t></w:r>
77
+ <w:commentRangeStart w:id="1"/>
78
+ <w:ins w:id="2" w:author="${insAuthor}" w:date="2026-01-01T00:00:00Z"><w:r><w:t>INSERTED</w:t></w:r></w:ins>
79
+ <w:commentRangeEnd w:id="1"/>
80
+ <w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
81
+ <w:r><w:t xml:space="preserve"> suffix text.</w:t></w:r>
82
+ </w:p>
83
+ </w:body>
84
+ </w:document>`,
85
+ );
86
+
87
+ const commentsXml = xmlDecl(
88
+ `<w:comments ${WORD_XMLNS}>
89
+ <w:comment w:id="1" w:author="${commentAuthor}" w:date="2026-01-01T00:00:00Z" w:initials="SC"><w:p><w:r><w:t>robust protection</w:t></w:r></w:p></w:comment>
90
+ </w:comments>`,
91
+ );
92
+
93
+ const zip: Record<string, Uint8Array> = {
94
+ "[Content_Types].xml": strToU8(contentTypes),
95
+ "_rels/.rels": strToU8(rootRels),
96
+ "word/document.xml": strToU8(documentXml),
97
+ "word/comments.xml": strToU8(commentsXml),
98
+ "word/_rels/document.xml.rels": strToU8(documentRels),
99
+ };
100
+
101
+ const buf = Buffer.from(zipSync(zip));
102
+ return DocumentObject.load(buf);
103
+ }
104
+
105
+ describe("author-aware wrapping-comment preservation", () => {
106
+ it("keeps a foreign author's wrapping comment when their change is accepted", async () => {
107
+ const doc = await buildWrappedInsertionDoc(
108
+ "Supplier's Counsel",
109
+ "Supplier's Counsel",
110
+ );
111
+
112
+ // Sanity: comment present before we touch anything.
113
+ const before = extract_comments_data(doc.pkg);
114
+ expect(Object.keys(before).length).toBe(1);
115
+ expect(before["1"].text).toContain("robust protection");
116
+
117
+ // We ("Authority Counsel") accept the counterparty's insertion Chg:2.
118
+ const engine = new RedlineEngine(doc, "Authority Counsel");
119
+ engine.process_batch(
120
+ [{ type: "accept", target_id: "Chg:2" } as any],
121
+ false,
122
+ );
123
+
124
+ // The counterparty's comment body must survive the accept.
125
+ const after = extract_comments_data(doc.pkg);
126
+ expect(after["1"]?.text).toContain("robust protection");
127
+
128
+ // The accept must actually have taken effect: the <w:ins> is unwrapped.
129
+ const savedBuf = await doc.save();
130
+ const reloaded = await DocumentObject.load(savedBuf);
131
+ expect(reloaded.element.getElementsByTagName("w:ins").length).toBe(0);
132
+
133
+ // Comment survives the roundtrip too.
134
+ const afterRoundtrip = extract_comments_data(reloaded.pkg);
135
+ expect(afterRoundtrip["1"]?.text).toContain("robust protection");
136
+ });
137
+
138
+ it("keeps a foreign author's wrapping comment when their change is rejected", async () => {
139
+ const doc = await buildWrappedInsertionDoc(
140
+ "Supplier's Counsel",
141
+ "Supplier's Counsel",
142
+ );
143
+
144
+ const engine = new RedlineEngine(doc, "Authority Counsel");
145
+ engine.process_batch(
146
+ [{ type: "reject", target_id: "Chg:2" } as any],
147
+ false,
148
+ );
149
+
150
+ // Rejecting removes the inserted TEXT, but the wrapping annotation (a
151
+ // separate comment) is preserved rather than collaterally deleted.
152
+ const after = extract_comments_data(doc.pkg);
153
+ expect(after["1"]?.text).toContain("robust protection");
154
+ });
155
+
156
+ it("still deletes our OWN wrapping comment when we accept our own change", async () => {
157
+ // Control: when the wrapping comment is ours, cleanup is unchanged — it is
158
+ // removed, matching prior behavior (keeping our own now-resolved note is not
159
+ // required).
160
+ const doc = await buildWrappedInsertionDoc(
161
+ "Authority Counsel",
162
+ "Authority Counsel",
163
+ );
164
+
165
+ const engine = new RedlineEngine(doc, "Authority Counsel");
166
+ engine.process_batch(
167
+ [{ type: "accept", target_id: "Chg:2" } as any],
168
+ false,
169
+ );
170
+
171
+ const after = extract_comments_data(doc.pkg);
172
+ expect(after["1"]).toBeUndefined();
173
+ });
174
+ });
package/src/engine.ts CHANGED
@@ -65,7 +65,6 @@ function insertAtIndex(parent: Element, index: number, child: Node) {
65
65
  }
66
66
  }
67
67
 
68
-
69
68
  function safeCloneEdit(val: any, seen: WeakMap<any, any> = new WeakMap()): any {
70
69
  if (val === null || typeof val !== "object") {
71
70
  return val;
@@ -127,7 +126,10 @@ function restoreSnapshot(doc: any, snapshot: any): void {
127
126
  }
128
127
  }
129
128
 
130
- function stripMatchingHeadingHashes(target: string, newText: string): [string, string] {
129
+ function stripMatchingHeadingHashes(
130
+ target: string,
131
+ newText: string,
132
+ ): [string, string] {
131
133
  if (!target || !newText) return [target, newText];
132
134
  const targetMatch = target.match(/^(#+)\s+/);
133
135
  const newMatch = newText.match(/^(#+)\s+/);
@@ -150,7 +152,10 @@ export class BatchValidationError extends Error {
150
152
  }
151
153
  }
152
154
 
153
- export function validate_edit_strings(edits: any[], index_offset: number = 0): string[] {
155
+ export function validate_edit_strings(
156
+ edits: any[],
157
+ index_offset: number = 0,
158
+ ): string[] {
154
159
  const errors: string[] = [];
155
160
 
156
161
  for (let i = 0; i < edits.length; i++) {
@@ -397,7 +402,8 @@ export class RedlineEngine {
397
402
  let target_text = edit.target_text || "";
398
403
  let new_text = edit.new_text || "";
399
404
 
400
- const [clean_target, target_style] = this._parse_markdown_style(target_text);
405
+ const [clean_target, target_style] =
406
+ this._parse_markdown_style(target_text);
401
407
  if (target_style && target_style.startsWith("Heading")) {
402
408
  const prefix_len = target_text.length - clean_target.length;
403
409
  start_idx += prefix_len;
@@ -1358,7 +1364,29 @@ export class RedlineEngine {
1358
1364
 
1359
1365
  insertAfter(ref_run, new_end);
1360
1366
  }
1361
-
1367
+ /**
1368
+ * Read a comment's author directly from the comments part. Used by
1369
+ * _clean_wrapping_comments to decide whether a wrapping comment belongs to
1370
+ * another author (and must be preserved when its anchored change is
1371
+ * accepted/rejected) or to us (safe to delete). Reads the part rather than
1372
+ * the mapper because the mapper's comments_map is not rebuilt between review
1373
+ * actions, so it can be stale mid-batch. Returns null if not found.
1374
+ */
1375
+ private _get_comment_author(c_id: string): string | null {
1376
+ const part = this.doc.pkg.parts.find(
1377
+ (p) =>
1378
+ p.contentType ===
1379
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
1380
+ );
1381
+ if (!part) return null;
1382
+ const comments = findAllDescendants(part._element, "w:comment");
1383
+ for (const c of comments) {
1384
+ if (c.getAttribute("w:id") === c_id) {
1385
+ return c.getAttribute("w:author");
1386
+ }
1387
+ }
1388
+ return null;
1389
+ }
1362
1390
  private _clean_wrapping_comments(element: Element) {
1363
1391
  let first_node: Element = element;
1364
1392
  while (true) {
@@ -1431,7 +1459,21 @@ export class RedlineEngine {
1431
1459
  for (const s of starts_to_remove) {
1432
1460
  const c_id = s.getAttribute("w:id");
1433
1461
  if (c_id && end_ids.has(c_id)) {
1434
- this.comments_manager.deleteComment(c_id);
1462
+ // Author-aware preservation. A comment that merely WRAPS the change
1463
+ // being accepted/rejected is a separate annotation — often the
1464
+ // counterparty's note explaining the very edit we are resolving.
1465
+ // Deleting it silently destroys their provenance (and, e.g., fails a
1466
+ // "keep the counterparty's comment" review requirement). So: always
1467
+ // detach the range markers (leaving no orphaned anchor, which lets the
1468
+ // accept/reject proceed cleanly), but only delete the comment BODY when
1469
+ // it is ours. Foreign-authored comment bodies are kept in the comments
1470
+ // part. When the author can't be read we default to preserving, since
1471
+ // deletion is the irreversible, higher-cost mistake.
1472
+ const author = this._get_comment_author(c_id);
1473
+ const is_own = author !== null && author === this.author;
1474
+ if (is_own) {
1475
+ this.comments_manager.deleteComment(c_id);
1476
+ }
1435
1477
  if (s.parentNode) s.parentNode.removeChild(s);
1436
1478
  for (const e of ends_to_remove) {
1437
1479
  let e_id: string | null = null;
@@ -1583,10 +1625,9 @@ export class RedlineEngine {
1583
1625
  if (edit.target_text.includes("|")) {
1584
1626
  matches = matches.slice(0, 1);
1585
1627
  } else {
1586
- const positions: [number, number][] = matches.map(([start, length]) => [
1587
- start,
1588
- start + length,
1589
- ]);
1628
+ const positions: [number, number][] = matches.map(
1629
+ ([start, length]) => [start, start + length],
1630
+ );
1590
1631
  errors.push(
1591
1632
  format_ambiguity_error(
1592
1633
  i + 1 + index_offset,
@@ -1821,8 +1862,17 @@ export class RedlineEngine {
1821
1862
  ): any {
1822
1863
  // Pre-process edits: strip identical leading heading hashes from target_text and new_text
1823
1864
  for (const c of changes) {
1824
- if (c && typeof c === "object" && (c as any).type === "modify" && (c as any).target_text && (c as any).new_text) {
1825
- const [strippedTarget, strippedNew] = stripMatchingHeadingHashes((c as any).target_text, (c as any).new_text);
1865
+ if (
1866
+ c &&
1867
+ typeof c === "object" &&
1868
+ (c as any).type === "modify" &&
1869
+ (c as any).target_text &&
1870
+ (c as any).new_text
1871
+ ) {
1872
+ const [strippedTarget, strippedNew] = stripMatchingHeadingHashes(
1873
+ (c as any).target_text,
1874
+ (c as any).new_text,
1875
+ );
1826
1876
  (c as any).target_text = strippedTarget;
1827
1877
  (c as any).new_text = strippedNew;
1828
1878
  }
@@ -1861,10 +1911,7 @@ export class RedlineEngine {
1861
1911
  actions.length > 0 ? this.validate_review_actions(actions) : [];
1862
1912
  const validate_edits_now = edits.length > 0 && action_errors.length > 0;
1863
1913
  const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
1864
- const all_errors = [
1865
- ...action_errors,
1866
- ...edit_errors,
1867
- ];
1914
+ const all_errors = [...action_errors, ...edit_errors];
1868
1915
  if (all_errors.length > 0) {
1869
1916
  throw new BatchValidationError(all_errors);
1870
1917
  }
@@ -1994,9 +2041,7 @@ export class RedlineEngine {
1994
2041
  throw err;
1995
2042
  }
1996
2043
 
1997
- applied_edits = edits.filter(
1998
- (e) => (e as any)._applied_status,
1999
- ).length;
2044
+ applied_edits = edits.filter((e) => (e as any)._applied_status).length;
2000
2045
  skipped_edits = edits.length - applied_edits;
2001
2046
 
2002
2047
  for (const edit of edits) {
@@ -2040,7 +2085,7 @@ export class RedlineEngine {
2040
2085
  skipped_details: this.skipped_details,
2041
2086
  edits: edits_reports,
2042
2087
  engine: "node",
2043
- version: "1.10.0",
2088
+ version: "1.18.2",
2044
2089
  };
2045
2090
  }
2046
2091
 
@@ -2585,7 +2630,10 @@ export class RedlineEngine {
2585
2630
  const t_seg = target_segs[k];
2586
2631
  const n_seg = new_segs[k];
2587
2632
  if (t_seg !== n_seg) {
2588
- const [seg_prefix, seg_suffix] = trim_common_context(t_seg, n_seg);
2633
+ const [seg_prefix, seg_suffix] = trim_common_context(
2634
+ t_seg,
2635
+ n_seg,
2636
+ );
2589
2637
  const seg_target = t_seg.substring(
2590
2638
  seg_prefix,
2591
2639
  t_seg.length - seg_suffix,
@@ -2938,7 +2986,8 @@ export class RedlineEngine {
2938
2986
  const is_inline_first = result.first_node.tagName === "w:ins";
2939
2987
  if (is_inline_first) {
2940
2988
  if (anchor_run) {
2941
- const anchor_parent = anchor_run._element.parentNode as Element | null;
2989
+ const anchor_parent = anchor_run._element
2990
+ .parentNode as Element | null;
2942
2991
  if (anchor_parent && anchor_parent.tagName === "w:ins") {
2943
2992
  // Inserting inside another author's pending <w:ins>: split it so our
2944
2993
  // new <w:ins> lands as a sibling right after the anchor run, never
@@ -3099,7 +3148,11 @@ export class RedlineEngine {
3099
3148
  // nested in their <w:ins> and splice our new <w:ins> in right after
3100
3149
  // it by splitting their <w:ins>, so we never nest <w:ins> in
3101
3150
  // <w:ins>.
3102
- this._insert_and_split_ins(del_parent, last_del!, result.first_node);
3151
+ this._insert_and_split_ins(
3152
+ del_parent,
3153
+ last_del!,
3154
+ result.first_node,
3155
+ );
3103
3156
  } else {
3104
3157
  // Inline: place the first <w:ins> immediately after last_del.
3105
3158
  insertAfter(result.first_node, last_del!);