@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/dist/index.cjs +45 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +45 -16
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.comment-preservation.test.ts +174 -0
- package/src/engine.ts +76 -23
package/package.json
CHANGED
|
@@ -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(
|
|
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(
|
|
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] =
|
|
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
|
-
|
|
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(
|
|
1587
|
-
start,
|
|
1588
|
-
|
|
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 (
|
|
1825
|
-
|
|
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.
|
|
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(
|
|
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
|
|
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(
|
|
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!);
|