@adeu/core 1.28.0 → 1.29.0
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 +135 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +135 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.test.ts +8 -1
- package/src/diff.ts +6 -0
- package/src/engine.bugs.test.ts +49 -0
- package/src/engine.tables.test.ts +31 -0
- package/src/engine.ts +122 -16
- package/src/ingest.ts +19 -2
- package/src/mapper.ts +20 -2
- package/src/repro_agy_bug.test.ts +108 -0
- package/src/sanitize/core.ts +1 -1
- package/src/sanitize/sanitize.test.ts +1 -0
package/package.json
CHANGED
package/src/diff.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { trim_common_context, generate_edits_from_text, create_word_patch_diff } from './diff.js';
|
|
2
|
+
import { trim_common_context, generate_edits_from_text, create_word_patch_diff, generate_edits_via_paragraph_alignment } from './diff.js';
|
|
3
3
|
|
|
4
4
|
describe('Diff Logic & Context Trimming', () => {
|
|
5
5
|
it('handles basic prefix and suffix', () => {
|
|
@@ -71,4 +71,11 @@ describe('Diff Logic & Context Trimming', () => {
|
|
|
71
71
|
expect(diff).toContain("+ Corporation");
|
|
72
72
|
expect(diff).toContain(" This agreement is made between the"); // Within 40-char context window so no truncation
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
it('handles trailing newline and whitespace safely in paragraph alignment', () => {
|
|
76
|
+
const original = "This is a single paragraph of the draft.";
|
|
77
|
+
const modified = "This is a single paragraph of the draft.\n";
|
|
78
|
+
const edits = generate_edits_via_paragraph_alignment(original, modified);
|
|
79
|
+
expect(edits.length).toBe(0);
|
|
80
|
+
});
|
|
74
81
|
});
|
package/src/diff.ts
CHANGED
|
@@ -544,6 +544,12 @@ export function generate_edits_via_paragraph_alignment(
|
|
|
544
544
|
original_text: string,
|
|
545
545
|
modified_text: string,
|
|
546
546
|
): ModifyText[] {
|
|
547
|
+
// Normalize trailing whitespace/newlines in modified_text to match original_text
|
|
548
|
+
const orig_stripped = original_text.trimEnd();
|
|
549
|
+
const orig_ws = original_text.slice(orig_stripped.length);
|
|
550
|
+
const mod_stripped = modified_text.trimEnd();
|
|
551
|
+
modified_text = mod_stripped + orig_ws;
|
|
552
|
+
|
|
547
553
|
const orig_paragraphs = original_text.split("\n\n");
|
|
548
554
|
const mod_paragraphs = modified_text.split("\n\n");
|
|
549
555
|
|
package/src/engine.bugs.test.ts
CHANGED
|
@@ -568,4 +568,53 @@ describe("Resolved Bugs Core Engine Verification", () => {
|
|
|
568
568
|
expect(caught.name).toBe("BatchValidationError");
|
|
569
569
|
expect(caught.message).toContain("Invalid change format");
|
|
570
570
|
});
|
|
571
|
+
|
|
572
|
+
it("BUG-REPRO: accept_all_revisions returns counts of accepted changes and removed comments", async () => {
|
|
573
|
+
const doc = await createTestDocument();
|
|
574
|
+
addParagraph(doc, "This is a test document.");
|
|
575
|
+
const engine = new RedlineEngine(doc);
|
|
576
|
+
|
|
577
|
+
engine.process_batch([
|
|
578
|
+
{
|
|
579
|
+
type: "modify",
|
|
580
|
+
target_text: "document",
|
|
581
|
+
new_text: "dossier",
|
|
582
|
+
comment: "Review note to be stripped",
|
|
583
|
+
},
|
|
584
|
+
]);
|
|
585
|
+
|
|
586
|
+
const stats = engine.accept_all_revisions() as any;
|
|
587
|
+
|
|
588
|
+
expect(stats).toBeDefined();
|
|
589
|
+
expect(stats.accepted_insertions).toBe(1);
|
|
590
|
+
expect(stats.accepted_deletions).toBe(1);
|
|
591
|
+
expect(stats.accepted_formatting).toBe(0);
|
|
592
|
+
// Counted from comment bodies this call actually deleted, not from the
|
|
593
|
+
// document's comment total — the two coincide here, but see below.
|
|
594
|
+
expect(stats.removed_comments).toBe(1);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("BUG-REPRO: removed_comments does not count comments it deliberately keeps", async () => {
|
|
598
|
+
const doc = await createTestDocument();
|
|
599
|
+
addParagraph(doc, "This is a test document.");
|
|
600
|
+
const engine = new RedlineEngine(doc);
|
|
601
|
+
|
|
602
|
+
engine.process_batch([
|
|
603
|
+
{
|
|
604
|
+
type: "modify",
|
|
605
|
+
target_text: "document",
|
|
606
|
+
new_text: "dossier",
|
|
607
|
+
comment: "Review note",
|
|
608
|
+
},
|
|
609
|
+
]);
|
|
610
|
+
|
|
611
|
+
// A different reviewer now opens the redlined document. The existing
|
|
612
|
+
// comment is foreign to them, so it keeps its BODY by design (only the
|
|
613
|
+
// anchor is detached) and must not be reported as removed.
|
|
614
|
+
const other = new RedlineEngine(doc);
|
|
615
|
+
other.author = "Someone Else";
|
|
616
|
+
const stats = other.accept_all_revisions() as any;
|
|
617
|
+
|
|
618
|
+
expect(stats.removed_comments).toBe(0);
|
|
619
|
+
});
|
|
571
620
|
});
|
|
@@ -265,4 +265,35 @@ describe("Table Interop & Engine (Node.js Port)", () => {
|
|
|
265
265
|
expect(errors[0]).toContain('Did you mean the literal "( x )"');
|
|
266
266
|
expect(errors[0]).toContain("drop the ^/$ anchors");
|
|
267
267
|
});
|
|
268
|
+
|
|
269
|
+
it("compiled report includes type for structural table operations", async () => {
|
|
270
|
+
const doc = await createTestDocument();
|
|
271
|
+
const tbl = addTable(doc, 2, 2);
|
|
272
|
+
setCellText(tbl, 0, 0, "Row1 Col1");
|
|
273
|
+
setCellText(tbl, 0, 1, "Row1 Col2");
|
|
274
|
+
setCellText(tbl, 1, 0, "Row2 Col1");
|
|
275
|
+
setCellText(tbl, 1, 1, "Row2 Col2");
|
|
276
|
+
|
|
277
|
+
const buf = await doc.save();
|
|
278
|
+
const midDoc = await DocumentObject.load(buf);
|
|
279
|
+
const engine = new RedlineEngine(midDoc);
|
|
280
|
+
|
|
281
|
+
const stats = engine.process_batch([
|
|
282
|
+
{
|
|
283
|
+
type: "insert_row",
|
|
284
|
+
target_text: "Row1 Col1",
|
|
285
|
+
position: "below",
|
|
286
|
+
cells: ["NewRow Col1", "NewRow Col2"],
|
|
287
|
+
} as InsertTableRow,
|
|
288
|
+
{
|
|
289
|
+
type: "delete_row",
|
|
290
|
+
target_text: "Row2 Col1",
|
|
291
|
+
} as DeleteTableRow,
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
expect(stats.edits_applied).toBe(2);
|
|
295
|
+
expect(stats.edits).toHaveLength(2);
|
|
296
|
+
expect(stats.edits[0].type).toBe("insert_row");
|
|
297
|
+
expect(stats.edits[1].type).toBe("delete_row");
|
|
298
|
+
});
|
|
268
299
|
});
|
package/src/engine.ts
CHANGED
|
@@ -905,10 +905,30 @@ export class RedlineEngine {
|
|
|
905
905
|
}
|
|
906
906
|
}
|
|
907
907
|
|
|
908
|
+
// Pre-count revisions before mutating. Unit is REVISION ELEMENTS, matching
|
|
909
|
+
// the Python engine and sanitize's count_tracked_changes so no two surfaces
|
|
910
|
+
// report different totals for one document.
|
|
911
|
+
let accepted_insertions = 0;
|
|
912
|
+
let accepted_deletions = 0;
|
|
913
|
+
let accepted_formatting = 0;
|
|
914
|
+
for (const root_element of parts_to_process) {
|
|
915
|
+
accepted_insertions += findAllDescendants(root_element, "w:ins").length;
|
|
916
|
+
accepted_deletions += findAllDescendants(root_element, "w:del").length;
|
|
917
|
+
for (const tag of ["w:rPrChange", "w:pPrChange", "w:sectPrChange"]) {
|
|
918
|
+
accepted_formatting += findAllDescendants(root_element, tag).length;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Counted as it happens below, not pre-read from the comments part: this
|
|
923
|
+
// method only deletes the bodies of OUR OWN comments wrapping a resolved
|
|
924
|
+
// revision (foreign ones keep their body by design), so the document's
|
|
925
|
+
// comment total would claim removals that never happened.
|
|
926
|
+
let removed_comments = 0;
|
|
927
|
+
|
|
908
928
|
for (const root_element of parts_to_process) {
|
|
909
929
|
const insNodes = findAllDescendants(root_element, "w:ins");
|
|
910
930
|
for (const ins of insNodes) {
|
|
911
|
-
this._clean_wrapping_comments(ins);
|
|
931
|
+
removed_comments += this._clean_wrapping_comments(ins);
|
|
912
932
|
const parent = ins.parentNode as Element | null;
|
|
913
933
|
if (!parent) continue;
|
|
914
934
|
|
|
@@ -956,8 +976,8 @@ export class RedlineEngine {
|
|
|
956
976
|
if (has_content) {
|
|
957
977
|
rPr.removeChild(delMark);
|
|
958
978
|
} else {
|
|
959
|
-
this._clean_wrapping_comments(p);
|
|
960
|
-
this._delete_comments_in_element(p);
|
|
979
|
+
removed_comments += this._clean_wrapping_comments(p);
|
|
980
|
+
removed_comments += this._delete_comments_in_element(p);
|
|
961
981
|
if (p.parentNode) {
|
|
962
982
|
p.parentNode.removeChild(p);
|
|
963
983
|
}
|
|
@@ -968,8 +988,8 @@ export class RedlineEngine {
|
|
|
968
988
|
|
|
969
989
|
const delNodes = findAllDescendants(root_element, "w:del");
|
|
970
990
|
for (const d of delNodes) {
|
|
971
|
-
this._clean_wrapping_comments(d);
|
|
972
|
-
this._delete_comments_in_element(d);
|
|
991
|
+
removed_comments += this._clean_wrapping_comments(d);
|
|
992
|
+
removed_comments += this._delete_comments_in_element(d);
|
|
973
993
|
const parent = d.parentNode as Element | null;
|
|
974
994
|
if (parent) {
|
|
975
995
|
if (parent.tagName === "w:trPr") {
|
|
@@ -1089,6 +1109,13 @@ export class RedlineEngine {
|
|
|
1089
1109
|
}
|
|
1090
1110
|
}
|
|
1091
1111
|
}
|
|
1112
|
+
|
|
1113
|
+
return {
|
|
1114
|
+
accepted_insertions,
|
|
1115
|
+
accepted_deletions,
|
|
1116
|
+
accepted_formatting,
|
|
1117
|
+
removed_comments,
|
|
1118
|
+
};
|
|
1092
1119
|
}
|
|
1093
1120
|
|
|
1094
1121
|
/**
|
|
@@ -1985,7 +2012,10 @@ export class RedlineEngine {
|
|
|
1985
2012
|
}
|
|
1986
2013
|
return null;
|
|
1987
2014
|
}
|
|
1988
|
-
|
|
2015
|
+
/** Returns how many comment BODIES were actually deleted (see below: only
|
|
2016
|
+
* our own are; foreign ones keep their body and lose only the anchor). */
|
|
2017
|
+
private _clean_wrapping_comments(element: Element): number {
|
|
2018
|
+
let deleted = 0;
|
|
1989
2019
|
let first_node: Element = element;
|
|
1990
2020
|
while (true) {
|
|
1991
2021
|
const prev = getPreviousElement(first_node);
|
|
@@ -2071,6 +2101,7 @@ export class RedlineEngine {
|
|
|
2071
2101
|
const is_own = author !== null && author === this.author;
|
|
2072
2102
|
if (is_own) {
|
|
2073
2103
|
this.comments_manager.deleteComment(c_id);
|
|
2104
|
+
deleted++;
|
|
2074
2105
|
}
|
|
2075
2106
|
if (s.parentNode) s.parentNode.removeChild(s);
|
|
2076
2107
|
for (const e of ends_to_remove) {
|
|
@@ -2088,14 +2119,18 @@ export class RedlineEngine {
|
|
|
2088
2119
|
}
|
|
2089
2120
|
}
|
|
2090
2121
|
}
|
|
2122
|
+
return deleted;
|
|
2091
2123
|
}
|
|
2092
2124
|
|
|
2093
|
-
|
|
2125
|
+
/** Returns how many comment bodies were deleted. */
|
|
2126
|
+
private _delete_comments_in_element(element: Element): number {
|
|
2127
|
+
let deleted = 0;
|
|
2094
2128
|
const refs = findAllDescendants(element, "w:commentReference");
|
|
2095
2129
|
for (const ref of refs) {
|
|
2096
2130
|
const c_id = ref.getAttribute("w:id");
|
|
2097
2131
|
if (c_id) {
|
|
2098
2132
|
this.comments_manager.deleteComment(c_id);
|
|
2133
|
+
deleted++;
|
|
2099
2134
|
for (const tag of ["w:commentRangeStart", "w:commentRangeEnd"]) {
|
|
2100
2135
|
const nodes = findAllDescendants(this.doc.element, tag);
|
|
2101
2136
|
for (const node of nodes) {
|
|
@@ -2106,6 +2141,7 @@ export class RedlineEngine {
|
|
|
2106
2141
|
}
|
|
2107
2142
|
}
|
|
2108
2143
|
}
|
|
2144
|
+
return deleted;
|
|
2109
2145
|
}
|
|
2110
2146
|
|
|
2111
2147
|
public validate_edits(edits: any[], index_offset: number = 0): string[] {
|
|
@@ -2893,6 +2929,7 @@ export class RedlineEngine {
|
|
|
2893
2929
|
);
|
|
2894
2930
|
reports_by_input[i] = {
|
|
2895
2931
|
status: "failed",
|
|
2932
|
+
type: (edit as any).type || "modify",
|
|
2896
2933
|
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2897
2934
|
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2898
2935
|
warning: warning,
|
|
@@ -2908,6 +2945,7 @@ export class RedlineEngine {
|
|
|
2908
2945
|
const previews = this._build_edit_context_previews(edit);
|
|
2909
2946
|
reports_by_input[i] = {
|
|
2910
2947
|
status: "applied",
|
|
2948
|
+
type: (edit as any).type || "modify",
|
|
2911
2949
|
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2912
2950
|
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2913
2951
|
warning: null,
|
|
@@ -2932,6 +2970,7 @@ export class RedlineEngine {
|
|
|
2932
2970
|
);
|
|
2933
2971
|
reports_by_input[i] = {
|
|
2934
2972
|
status: "failed",
|
|
2973
|
+
type: (edit as any).type || "modify",
|
|
2935
2974
|
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
2936
2975
|
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
2937
2976
|
warning: warning,
|
|
@@ -2954,6 +2993,7 @@ export class RedlineEngine {
|
|
|
2954
2993
|
if (report.status === "applied") {
|
|
2955
2994
|
reports_by_input[i] = {
|
|
2956
2995
|
status: "failed",
|
|
2996
|
+
type: report.type || "modify",
|
|
2957
2997
|
target_text: report.target_text,
|
|
2958
2998
|
new_text: report.new_text,
|
|
2959
2999
|
warning: null,
|
|
@@ -3030,6 +3070,7 @@ export class RedlineEngine {
|
|
|
3030
3070
|
}
|
|
3031
3071
|
edits_reports.push({
|
|
3032
3072
|
status: success ? "applied" : "failed",
|
|
3073
|
+
type: (edit as any).type || "modify",
|
|
3033
3074
|
target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
|
|
3034
3075
|
new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
|
|
3035
3076
|
warning: warning,
|
|
@@ -3122,13 +3163,64 @@ export class RedlineEngine {
|
|
|
3122
3163
|
}
|
|
3123
3164
|
|
|
3124
3165
|
if (matches.length > 0) {
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
//
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3166
|
+
const match_mode = edit.match_mode || "strict";
|
|
3167
|
+
|
|
3168
|
+
// We need to resolve matches to unique w:tr elements to deduplicate them.
|
|
3169
|
+
const unique_matches: [number, number][] = [];
|
|
3170
|
+
const seen_trs = new Set<any>();
|
|
3171
|
+
|
|
3172
|
+
for (const match of matches) {
|
|
3173
|
+
const start_idx = match[0];
|
|
3174
|
+
const [anchor_run, anchor_para] = resolved_mapper.get_insertion_anchor(start_idx, false);
|
|
3175
|
+
let target_element: Element | null = null;
|
|
3176
|
+
if (anchor_run) target_element = anchor_run._element;
|
|
3177
|
+
else if (anchor_para) target_element = anchor_para._element;
|
|
3178
|
+
|
|
3179
|
+
let tr: Element | null = target_element;
|
|
3180
|
+
while (tr && tr.tagName !== "w:tr") tr = tr.parentNode as Element;
|
|
3181
|
+
|
|
3182
|
+
if (tr) {
|
|
3183
|
+
if (!seen_trs.has(tr)) {
|
|
3184
|
+
seen_trs.add(tr);
|
|
3185
|
+
unique_matches.push(match);
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
if (unique_matches.length > 0) {
|
|
3191
|
+
let matches_to_apply = unique_matches;
|
|
3192
|
+
if (match_mode === "strict" || match_mode === "first") {
|
|
3193
|
+
matches_to_apply = unique_matches.slice(0, 1);
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
if (match_mode === "all" || matches_to_apply.length > 1) {
|
|
3197
|
+
// Create sub-edits for each match so that they are processed as independent operations,
|
|
3198
|
+
// and the occurrences_modified and applied_status are tracked correctly on the parent.
|
|
3199
|
+
for (const m of matches_to_apply) {
|
|
3200
|
+
const sub_edit = {
|
|
3201
|
+
...edit,
|
|
3202
|
+
_resolved_start_idx: m[0],
|
|
3203
|
+
_active_mapper_ref: resolved_mapper,
|
|
3204
|
+
_parent_edit_ref: edit,
|
|
3205
|
+
};
|
|
3206
|
+
resolved_edits.push([sub_edit, null]);
|
|
3207
|
+
}
|
|
3208
|
+
} else {
|
|
3209
|
+
// Single match case for non-"all" modes
|
|
3210
|
+
edit._resolved_start_idx = matches_to_apply[0][0];
|
|
3211
|
+
edit._active_mapper_ref = resolved_mapper;
|
|
3212
|
+
resolved_edits.push([edit, null]);
|
|
3213
|
+
}
|
|
3214
|
+
} else {
|
|
3215
|
+
skipped++;
|
|
3216
|
+
edit._applied_status = false;
|
|
3217
|
+
const target_snippet = (edit.target_text || "")
|
|
3218
|
+
.trim()
|
|
3219
|
+
.substring(0, 40);
|
|
3220
|
+
const msg = `- Failed to locate row target: '${target_snippet}...'`;
|
|
3221
|
+
this.skipped_details.push(msg);
|
|
3222
|
+
edit._error_msg = msg;
|
|
3223
|
+
}
|
|
3132
3224
|
} else {
|
|
3133
3225
|
skipped++;
|
|
3134
3226
|
edit._applied_status = false;
|
|
@@ -3498,8 +3590,22 @@ export class RedlineEngine {
|
|
|
3498
3590
|
let already_resolved = 0;
|
|
3499
3591
|
const resolved_history = new Map<string, string>(); // id -> resolving action type
|
|
3500
3592
|
|
|
3501
|
-
|
|
3502
|
-
|
|
3593
|
+
// Sort actions internally: non-destructive metadata operations (ReplyComment) first,
|
|
3594
|
+
// followed by destructive structural operations (AcceptChange, RejectChange).
|
|
3595
|
+
// Stable sort preserves the original relative ordering, and we preserve `pos`
|
|
3596
|
+
// so diagnostic messages refer to the original array indexes.
|
|
3597
|
+
const sortedActions = actions
|
|
3598
|
+
.map((action, pos) => ({ action, pos }))
|
|
3599
|
+
.sort((a, b) => {
|
|
3600
|
+
const aPri = a.action.type === "reply" ? 0 : 1;
|
|
3601
|
+
const bPri = b.action.type === "reply" ? 0 : 1;
|
|
3602
|
+
if (aPri !== bPri) {
|
|
3603
|
+
return aPri - bPri;
|
|
3604
|
+
}
|
|
3605
|
+
return a.pos - b.pos;
|
|
3606
|
+
});
|
|
3607
|
+
|
|
3608
|
+
for (const { action, pos } of sortedActions) {
|
|
3503
3609
|
const type = action.type;
|
|
3504
3610
|
if (type === "reply") {
|
|
3505
3611
|
const cid = action.target_id.replace("Com:", "");
|
package/src/ingest.ts
CHANGED
|
@@ -254,10 +254,27 @@ export function extract_table(
|
|
|
254
254
|
// via the mapper). We key on the cell's first paragraph w14:paraId, which
|
|
255
255
|
// Word assigns and keeps stable across reads.
|
|
256
256
|
if (!cleanView) {
|
|
257
|
-
|
|
257
|
+
let firstP = cell._element.getElementsByTagName("w:p")[0] as
|
|
258
258
|
| Element
|
|
259
259
|
| undefined;
|
|
260
|
-
|
|
260
|
+
let paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
|
|
261
|
+
if (!paraId && (!cell_content || cell_content.trim() === "")) {
|
|
262
|
+
if (!firstP) {
|
|
263
|
+
const xmlDoc = cell._element.ownerDocument!;
|
|
264
|
+
firstP = xmlDoc.createElement("w:p");
|
|
265
|
+
cell._element.appendChild(firstP);
|
|
266
|
+
}
|
|
267
|
+
const allPs = Array.from(cell._element.ownerDocument!.getElementsByTagName("w:p"));
|
|
268
|
+
const index = allPs.indexOf(firstP);
|
|
269
|
+
let hash = 2166136261;
|
|
270
|
+
const str = `fallback-paraId-${index}`;
|
|
271
|
+
for (let i = 0; i < str.length; i++) {
|
|
272
|
+
hash ^= str.charCodeAt(i);
|
|
273
|
+
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
274
|
+
}
|
|
275
|
+
paraId = (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
|
|
276
|
+
firstP.setAttribute("w14:paraId", paraId);
|
|
277
|
+
}
|
|
261
278
|
if (paraId) {
|
|
262
279
|
const space_pad = cell_content ? " " : "";
|
|
263
280
|
const anchor = `${space_pad}{#cell:${paraId}}`;
|
package/src/mapper.ts
CHANGED
|
@@ -348,10 +348,28 @@ export class DocumentMapper {
|
|
|
348
348
|
// can resolve "write into this cell" even when the cell is empty
|
|
349
349
|
// (pPr-only paragraph with no run).
|
|
350
350
|
if (!this.clean_view && !this.original_view) {
|
|
351
|
-
|
|
351
|
+
let firstP = cell._element.getElementsByTagName("w:p")[0] as
|
|
352
352
|
| Element
|
|
353
353
|
| undefined;
|
|
354
|
-
|
|
354
|
+
let paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
|
|
355
|
+
const is_empty = current === cell_start;
|
|
356
|
+
if (!paraId && is_empty) {
|
|
357
|
+
if (!firstP) {
|
|
358
|
+
const xmlDoc = cell._element.ownerDocument!;
|
|
359
|
+
firstP = xmlDoc.createElement("w:p");
|
|
360
|
+
cell._element.appendChild(firstP);
|
|
361
|
+
}
|
|
362
|
+
const allPs = Array.from(cell._element.ownerDocument!.getElementsByTagName("w:p"));
|
|
363
|
+
const index = allPs.indexOf(firstP);
|
|
364
|
+
let hash = 2166136261;
|
|
365
|
+
const str = `fallback-paraId-${index}`;
|
|
366
|
+
for (let i = 0; i < str.length; i++) {
|
|
367
|
+
hash ^= str.charCodeAt(i);
|
|
368
|
+
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
369
|
+
}
|
|
370
|
+
paraId = (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
|
|
371
|
+
firstP.setAttribute("w14:paraId", paraId);
|
|
372
|
+
}
|
|
355
373
|
if (paraId && firstP) {
|
|
356
374
|
// Zero-width span bound to the empty cell paragraph: gives
|
|
357
375
|
// get_insertion_anchor a paragraph to land on. Placed at the anchor
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createTestDocument, addTable, setCellText } from "./test-utils.js";
|
|
3
|
+
import { extractTextFromBuffer } from "./ingest.js";
|
|
4
|
+
import { RedlineEngine } from "./engine.js";
|
|
5
|
+
|
|
6
|
+
describe("QA Regression Test - Finding F1: Missing cell anchors for empty table cells", () => {
|
|
7
|
+
it("should generate and render stable cell anchors for empty table cells", async () => {
|
|
8
|
+
// 1. Build a document containing a table with empty/blank cells.
|
|
9
|
+
// In this document, the cells are constructed programmatically without any pre-existing w14:paraId.
|
|
10
|
+
const doc = await createTestDocument();
|
|
11
|
+
const tbl = addTable(doc, 1, 2);
|
|
12
|
+
setCellText(tbl, 0, 0, "Hello");
|
|
13
|
+
// Cell (0,1) is left completely empty, with no pre-existing w14:paraId attribute on its w:p element.
|
|
14
|
+
|
|
15
|
+
const buf = await doc.save();
|
|
16
|
+
|
|
17
|
+
// 2. Ingest/extract text from the document.
|
|
18
|
+
const text = await extractTextFromBuffer(buf, false);
|
|
19
|
+
|
|
20
|
+
// 3. Assert correct behavior.
|
|
21
|
+
// The empty cell must still render a trailing {#cell:<id>} anchor so that it can be targeted for edits.
|
|
22
|
+
// We expect the output to be formatted like: Hello | {#cell:<id>}
|
|
23
|
+
const cellAnchorRegex = /Hello \| \{#cell:[0-9a-fA-F]{8}\}/;
|
|
24
|
+
expect(text).toSatisfy((val: string) => {
|
|
25
|
+
return cellAnchorRegex.test(val);
|
|
26
|
+
}, `Expected extracted text to contain a cell anchor for the empty cell, but got:\n"${text}"`);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("QA Regression Test - Top Bug: match_mode='all' fails to affect multiple matches for table row edits", () => {
|
|
31
|
+
it("should delete all rows containing target_text when match_mode is all", async () => {
|
|
32
|
+
const doc = await createTestDocument();
|
|
33
|
+
const tbl = addTable(doc, 3, 3);
|
|
34
|
+
setCellText(tbl, 0, 0, "ID");
|
|
35
|
+
setCellText(tbl, 0, 1, "Name");
|
|
36
|
+
setCellText(tbl, 0, 2, "Notes");
|
|
37
|
+
|
|
38
|
+
setCellText(tbl, 1, 0, "1");
|
|
39
|
+
setCellText(tbl, 1, 1, "Alice");
|
|
40
|
+
setCellText(tbl, 1, 2, "First record");
|
|
41
|
+
|
|
42
|
+
setCellText(tbl, 2, 0, "2");
|
|
43
|
+
setCellText(tbl, 2, 1, "");
|
|
44
|
+
setCellText(tbl, 2, 2, "Second record with empty name");
|
|
45
|
+
|
|
46
|
+
const buf = await doc.save();
|
|
47
|
+
const midDoc = await (doc.constructor as any).load(buf);
|
|
48
|
+
|
|
49
|
+
const engine = new RedlineEngine(midDoc);
|
|
50
|
+
const stats = engine.process_batch([
|
|
51
|
+
{
|
|
52
|
+
type: "delete_row",
|
|
53
|
+
target_text: "record",
|
|
54
|
+
match_mode: "all",
|
|
55
|
+
} as any,
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
expect(stats.edits_applied).toBe(1);
|
|
59
|
+
expect(stats.occurrences_modified).toBe(2);
|
|
60
|
+
|
|
61
|
+
engine.accept_all_revisions();
|
|
62
|
+
const finalBuf = await midDoc.save();
|
|
63
|
+
const clean_text = await extractTextFromBuffer(finalBuf, true);
|
|
64
|
+
|
|
65
|
+
expect(clean_text).not.toContain("First record");
|
|
66
|
+
expect(clean_text).not.toContain("Second record with empty name");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should insert rows adjacent to all rows containing target_text when match_mode is all", async () => {
|
|
70
|
+
const doc = await createTestDocument();
|
|
71
|
+
const tbl = addTable(doc, 3, 3);
|
|
72
|
+
setCellText(tbl, 0, 0, "ID");
|
|
73
|
+
setCellText(tbl, 0, 1, "Name");
|
|
74
|
+
setCellText(tbl, 0, 2, "Notes");
|
|
75
|
+
|
|
76
|
+
setCellText(tbl, 1, 0, "1");
|
|
77
|
+
setCellText(tbl, 1, 1, "Alice");
|
|
78
|
+
setCellText(tbl, 1, 2, "First record");
|
|
79
|
+
|
|
80
|
+
setCellText(tbl, 2, 0, "2");
|
|
81
|
+
setCellText(tbl, 2, 1, "");
|
|
82
|
+
setCellText(tbl, 2, 2, "Second record with empty name");
|
|
83
|
+
|
|
84
|
+
const buf = await doc.save();
|
|
85
|
+
const midDoc = await (doc.constructor as any).load(buf);
|
|
86
|
+
|
|
87
|
+
const engine = new RedlineEngine(midDoc);
|
|
88
|
+
const stats = engine.process_batch([
|
|
89
|
+
{
|
|
90
|
+
type: "insert_row",
|
|
91
|
+
target_text: "record",
|
|
92
|
+
match_mode: "all",
|
|
93
|
+
position: "below",
|
|
94
|
+
cells: ["NEW_ID", "NEW_NAME", "NEW_NOTES"],
|
|
95
|
+
} as any,
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
expect(stats.edits_applied).toBe(1);
|
|
99
|
+
expect(stats.occurrences_modified).toBe(2);
|
|
100
|
+
|
|
101
|
+
engine.accept_all_revisions();
|
|
102
|
+
const finalBuf = await midDoc.save();
|
|
103
|
+
const clean_text = await extractTextFromBuffer(finalBuf, true);
|
|
104
|
+
|
|
105
|
+
const occurrences = (clean_text.match(/NEW_ID \| NEW_NAME \| NEW_NOTES/g) || []).length;
|
|
106
|
+
expect(occurrences).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/sanitize/core.ts
CHANGED
|
@@ -29,7 +29,7 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
|
|
|
29
29
|
|
|
30
30
|
if (total > 0 && !options.accept_all) {
|
|
31
31
|
report.status = 'blocked';
|
|
32
|
-
report.blocked_reason = `Document contains ${total} unresolved tracked changes (${counts[0]} insertions, ${counts[1]} deletions, ${counts[2]} formatting). Review in Word first, or set
|
|
32
|
+
report.blocked_reason = `Document contains ${total} unresolved tracked changes (${counts[0]} insertions, ${counts[1]} deletions, ${counts[2]} formatting). Review in Word first, set accept_all=true, or set sanitize_mode='keep-markup'.`;
|
|
33
33
|
return { reportText: report.render() };
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -220,6 +220,7 @@ describe("Finalize Document (Core)", () => {
|
|
|
220
220
|
|
|
221
221
|
expect(res.reportText).toContain("BLOCKED:");
|
|
222
222
|
expect(res.reportText).toContain("unresolved tracked changes");
|
|
223
|
+
expect(res.reportText).toContain("sanitize_mode='keep-markup'");
|
|
223
224
|
});
|
|
224
225
|
|
|
225
226
|
describe("Resolved Bugs Sanitize Parity Verification", () => {
|