@adeu/core 1.9.0 → 1.10.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 +615 -102
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -2
- package/dist/index.d.ts +15 -2
- package/dist/index.js +615 -102
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/comments.ts +33 -14
- package/src/consistency.test.ts +62 -4
- package/src/diff.ts +42 -6
- package/src/docx/dom.ts +2 -2
- package/src/engine.bugs.test.ts +38 -0
- package/src/engine.feedback.test.ts +144 -0
- package/src/engine.issue23.test.ts +511 -0
- package/src/engine.ts +513 -64
- package/src/sanitize/core.ts +1 -0
- package/src/sanitize/sanitize.test.ts +48 -6
- package/src/sanitize/transforms.ts +88 -1
package/src/engine.ts
CHANGED
|
@@ -247,6 +247,42 @@ export class RedlineEngine {
|
|
|
247
247
|
this.comments_manager = new CommentsManager(this.doc);
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
private _check_punctuation_warning(target_text: string): string | null {
|
|
251
|
+
if (!target_text) return null;
|
|
252
|
+
if (target_text.includes("_") || target_text.includes("-")) {
|
|
253
|
+
return `Warning: target_text '${target_text}' contains tokenization-splitting punctuation ('_' or '-'). This can trigger mid-word splits in the diff engine. Consider using a longer plain-prose anchor.`;
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private _build_edit_context_previews(edit: any): [string | null, string | null] {
|
|
259
|
+
if (edit.type !== "modify") return [null, null];
|
|
260
|
+
if (edit._resolved_proxy_edit) {
|
|
261
|
+
edit = edit._resolved_proxy_edit;
|
|
262
|
+
}
|
|
263
|
+
const start_idx = edit._resolved_start_idx;
|
|
264
|
+
if (start_idx === undefined || start_idx === null) return [null, null];
|
|
265
|
+
const target_text = edit.target_text || "";
|
|
266
|
+
const new_text = edit.new_text || "";
|
|
267
|
+
const length = target_text.length;
|
|
268
|
+
const active_mapper = edit._active_mapper_ref || this.mapper;
|
|
269
|
+
const full_text = active_mapper.full_text;
|
|
270
|
+
if (!full_text) return [null, null];
|
|
271
|
+
|
|
272
|
+
const before_start = Math.max(0, start_idx - 30);
|
|
273
|
+
const context_before = full_text.substring(before_start, start_idx);
|
|
274
|
+
const context_after = full_text.substring(start_idx + length, start_idx + length + 30);
|
|
275
|
+
|
|
276
|
+
const critic_markup = `${context_before}{--${target_text}--}{++${new_text}++}${context_after}`;
|
|
277
|
+
|
|
278
|
+
let clean_text = critic_markup;
|
|
279
|
+
clean_text = clean_text.replace(/\{>>.*?<<\}/gs, "");
|
|
280
|
+
clean_text = clean_text.replace(/\{--.*?--\}/gs, "");
|
|
281
|
+
clean_text = clean_text.replace(/\{\+\+(.*?)\+\+\}/gs, "$1");
|
|
282
|
+
|
|
283
|
+
return [critic_markup, clean_text];
|
|
284
|
+
}
|
|
285
|
+
|
|
250
286
|
private _scan_existing_ids(): number {
|
|
251
287
|
let maxId = 0;
|
|
252
288
|
for (const tag of ["w:ins", "w:del"]) {
|
|
@@ -351,35 +387,99 @@ export class RedlineEngine {
|
|
|
351
387
|
}
|
|
352
388
|
}
|
|
353
389
|
|
|
354
|
-
// Final pass:
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
390
|
+
// Final pass: completely eject all comments, anchors, and parts
|
|
391
|
+
for (const root_element of parts_to_process) {
|
|
392
|
+
for (const tag of ["w:commentRangeStart", "w:commentRangeEnd"]) {
|
|
393
|
+
for (const el of findAllDescendants(root_element, tag)) {
|
|
394
|
+
el.parentNode?.removeChild(el);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const refs = findAllDescendants(root_element, "w:commentReference");
|
|
399
|
+
for (const ref of refs) {
|
|
400
|
+
const parent = ref.parentNode as Element | null;
|
|
401
|
+
if (parent) {
|
|
402
|
+
if (parent.tagName === "w:r" || parent.tagName.endsWith(":r")) {
|
|
403
|
+
const nonRprChildren = Array.from(parent.childNodes).filter(
|
|
404
|
+
(c) => c.nodeType === 1 && (c as Element).tagName !== "w:rPr" && (c as Element).tagName !== "rPr"
|
|
405
|
+
);
|
|
406
|
+
if (nonRprChildren.length <= 1) {
|
|
407
|
+
parent.parentNode?.removeChild(parent);
|
|
408
|
+
} else {
|
|
409
|
+
parent.removeChild(ref);
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
parent.removeChild(ref);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
364
415
|
}
|
|
365
416
|
}
|
|
366
417
|
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
418
|
+
const pkg = this.doc.pkg;
|
|
419
|
+
const comment_partnames = new Set<string>();
|
|
420
|
+
for (const part of pkg.parts) {
|
|
421
|
+
if (part.partname.toLowerCase().includes("comments")) {
|
|
422
|
+
comment_partnames.add(part.partname);
|
|
423
|
+
const withSlash = part.partname.startsWith("/") ? part.partname : "/" + part.partname;
|
|
424
|
+
const withoutSlash = part.partname.startsWith("/") ? part.partname.substring(1) : part.partname;
|
|
425
|
+
comment_partnames.add(withSlash);
|
|
426
|
+
comment_partnames.add(withoutSlash);
|
|
376
427
|
}
|
|
377
428
|
}
|
|
378
429
|
|
|
379
|
-
|
|
380
|
-
|
|
430
|
+
if (comment_partnames.size > 0) {
|
|
431
|
+
// Sever relationships referencing comments
|
|
432
|
+
for (const part of pkg.parts) {
|
|
433
|
+
if (part.partname.endsWith(".rels")) {
|
|
434
|
+
const rels = findAllDescendants(part._element, "Relationship");
|
|
435
|
+
const toRemove: Element[] = [];
|
|
436
|
+
for (const rel of rels) {
|
|
437
|
+
const target = rel.getAttribute("Target") || "";
|
|
438
|
+
if (target.toLowerCase().includes("comments")) {
|
|
439
|
+
toRemove.push(rel);
|
|
440
|
+
|
|
441
|
+
const sourcePath = part.partname.replace("/_rels/", "/").replace(".rels", "");
|
|
442
|
+
const sourcePart = pkg.getPartByPath(sourcePath);
|
|
443
|
+
if (sourcePart) {
|
|
444
|
+
const relId = rel.getAttribute("Id");
|
|
445
|
+
if (relId) sourcePart.rels.delete(relId);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const relEl of toRemove) {
|
|
450
|
+
relEl.parentNode?.removeChild(relEl);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Remove overrides from [Content_Types].xml
|
|
456
|
+
const ctPart = pkg.getPartByPath("[Content_Types].xml");
|
|
457
|
+
if (ctPart) {
|
|
458
|
+
const overrides = findAllDescendants(ctPart._element, "Override");
|
|
459
|
+
const toRemove: Element[] = [];
|
|
460
|
+
for (const override of overrides) {
|
|
461
|
+
const partName = override.getAttribute("PartName") || "";
|
|
462
|
+
if (comment_partnames.has(partName) || partName.toLowerCase().includes("comments")) {
|
|
463
|
+
toRemove.push(override);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
for (const overrideEl of toRemove) {
|
|
467
|
+
overrideEl.parentNode?.removeChild(overrideEl);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Remove comment parts from pkg.parts
|
|
472
|
+
pkg.parts = pkg.parts.filter(p => !p.partname.toLowerCase().includes("comments"));
|
|
473
|
+
|
|
474
|
+
// Remove comment files from pkg.unzipped
|
|
475
|
+
for (const key of Object.keys(pkg.unzipped)) {
|
|
476
|
+
if (key.toLowerCase().includes("comments")) {
|
|
477
|
+
delete pkg.unzipped[key];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
381
480
|
}
|
|
382
481
|
}
|
|
482
|
+
|
|
383
483
|
private _getNextId(): string {
|
|
384
484
|
this.current_id++;
|
|
385
485
|
return this.current_id.toString();
|
|
@@ -505,7 +605,9 @@ export class RedlineEngine {
|
|
|
505
605
|
end_p.appendChild(range_end);
|
|
506
606
|
end_p.appendChild(ref_run);
|
|
507
607
|
}
|
|
508
|
-
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
509
611
|
* Inserts `text` as one or more tracked paragraphs anchored relative to
|
|
510
612
|
* either an existing run or a paragraph. Returns:
|
|
511
613
|
* { first_node, last_p, last_ins, used_block_mode }
|
|
@@ -734,8 +836,19 @@ export class RedlineEngine {
|
|
|
734
836
|
const anchor_rPr = findChild(anchor_run._element, "w:rPr");
|
|
735
837
|
if (anchor_rPr) {
|
|
736
838
|
const clone = anchor_rPr.cloneNode(true) as Element;
|
|
737
|
-
// Strip vanish / strike to avoid invisible inserts
|
|
738
|
-
|
|
839
|
+
// Strip vanish / strike to avoid invisible inserts, and emphasis
|
|
840
|
+
// (bold/italic) so inserted replacement text does not silently
|
|
841
|
+
// inherit the anchor run's character formatting (BUG-23-2). Explicit
|
|
842
|
+
// markdown emphasis is re-applied per-segment via _apply_run_props.
|
|
843
|
+
for (const tag of [
|
|
844
|
+
"w:vanish",
|
|
845
|
+
"w:strike",
|
|
846
|
+
"w:dstrike",
|
|
847
|
+
"w:i",
|
|
848
|
+
"w:iCs",
|
|
849
|
+
"w:b",
|
|
850
|
+
"w:bCs",
|
|
851
|
+
]) {
|
|
739
852
|
const found = findChild(clone, tag);
|
|
740
853
|
if (found) clone.removeChild(found);
|
|
741
854
|
}
|
|
@@ -750,6 +863,7 @@ export class RedlineEngine {
|
|
|
750
863
|
}
|
|
751
864
|
return ins;
|
|
752
865
|
}
|
|
866
|
+
|
|
753
867
|
private _parse_markdown_style(text: string): [string, string | null] {
|
|
754
868
|
const stripped_text = text.trimStart();
|
|
755
869
|
|
|
@@ -855,6 +969,7 @@ export class RedlineEngine {
|
|
|
855
969
|
}
|
|
856
970
|
}
|
|
857
971
|
}
|
|
972
|
+
|
|
858
973
|
/**
|
|
859
974
|
* Replaces (or creates) a paragraph's <w:pPr> with a single <w:pStyle> entry
|
|
860
975
|
* pointing at `style_name`. Strips any existing pPr to avoid layering a new
|
|
@@ -884,6 +999,7 @@ export class RedlineEngine {
|
|
|
884
999
|
// pPr is the first child of <w:p> per OOXML schema.
|
|
885
1000
|
p_element.insertBefore(pPr, p_element.firstChild);
|
|
886
1001
|
}
|
|
1002
|
+
|
|
887
1003
|
private _anchor_reply_comment(parent_id: string, new_id: string) {
|
|
888
1004
|
const docEl = this.doc.part._element.ownerDocument!;
|
|
889
1005
|
|
|
@@ -938,6 +1054,7 @@ export class RedlineEngine {
|
|
|
938
1054
|
|
|
939
1055
|
insertAfter(ref_run, new_end);
|
|
940
1056
|
}
|
|
1057
|
+
|
|
941
1058
|
private _clean_wrapping_comments(element: Element) {
|
|
942
1059
|
let first_node: Element = element;
|
|
943
1060
|
while (true) {
|
|
@@ -1046,6 +1163,7 @@ export class RedlineEngine {
|
|
|
1046
1163
|
}
|
|
1047
1164
|
}
|
|
1048
1165
|
}
|
|
1166
|
+
|
|
1049
1167
|
public validate_edits(edits: any[]): string[] {
|
|
1050
1168
|
const errors: string[] = [];
|
|
1051
1169
|
if (!this.mapper.full_text) this.mapper["_build_map"]();
|
|
@@ -1066,6 +1184,24 @@ export class RedlineEngine {
|
|
|
1066
1184
|
if (matches.length > 0) activeText = this.clean_mapper.full_text;
|
|
1067
1185
|
}
|
|
1068
1186
|
|
|
1187
|
+
// BUG-23-5: a copy of the target that lives entirely inside a tracked
|
|
1188
|
+
// deletion (<w:del>) is not a live, editable occurrence and must not
|
|
1189
|
+
// count toward ambiguity. Drop matches whose overlapping real text is
|
|
1190
|
+
// exclusively deleted. Only applies to the raw mapper (the clean mapper
|
|
1191
|
+
// already omits deleted text).
|
|
1192
|
+
if (activeText === this.mapper.full_text && matches.length > 1) {
|
|
1193
|
+
const liveMatches = matches.filter(([start, length]) => {
|
|
1194
|
+
const realSpans = this.mapper.spans.filter(
|
|
1195
|
+
(s) => s.run !== null && s.end > start && s.start < start + length,
|
|
1196
|
+
);
|
|
1197
|
+
if (realSpans.length === 0) return true; // virtual-only; keep
|
|
1198
|
+
// Keep only if at least one overlapping real span is live (not
|
|
1199
|
+
// part of a tracked deletion).
|
|
1200
|
+
return realSpans.some((s) => !s.del_id);
|
|
1201
|
+
});
|
|
1202
|
+
if (liveMatches.length > 0) matches = liveMatches;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1069
1205
|
if (matches.length === 0) {
|
|
1070
1206
|
errors.push(
|
|
1071
1207
|
`- Edit ${i + 1} Failed: Target text not found in document:\n "${edit.target_text}"`,
|
|
@@ -1085,6 +1221,35 @@ export class RedlineEngine {
|
|
|
1085
1221
|
);
|
|
1086
1222
|
}
|
|
1087
1223
|
|
|
1224
|
+
// BUG-23-4: when the effective (context-trimmed) target spans a
|
|
1225
|
+
// paragraph boundary with real body text on BOTH sides, we must reject
|
|
1226
|
+
// the modification to prevent silent corruption of the paragraph structure.
|
|
1227
|
+
if (matches.length === 1) {
|
|
1228
|
+
const [m_start, m_len] = matches[0];
|
|
1229
|
+
const matched = activeText.substring(m_start, m_start + m_len);
|
|
1230
|
+
const [pfx, sfx] = trim_common_context(matched, edit.new_text || "");
|
|
1231
|
+
const t_end = matched.length - sfx;
|
|
1232
|
+
const final_target = matched.substring(pfx, t_end);
|
|
1233
|
+
const final_new = (edit.new_text || "").substring(pfx, (edit.new_text || "").length - sfx);
|
|
1234
|
+
if (final_target.includes("\n\n")) {
|
|
1235
|
+
if (final_new.includes("\n\n")) {
|
|
1236
|
+
const parts = matched.split("\n\n");
|
|
1237
|
+
if (parts.length >= 2 && parts[0].trim() !== "" && parts[parts.length - 1].trim() !== "") {
|
|
1238
|
+
errors.push(
|
|
1239
|
+
`- Edit ${i + 1} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
} else {
|
|
1243
|
+
const parts = final_target.split("\n\n");
|
|
1244
|
+
if (parts.length >= 2 && parts[0].trim() !== "" && parts[parts.length - 1].trim() !== "") {
|
|
1245
|
+
errors.push(
|
|
1246
|
+
`- Edit ${i + 1} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1088
1253
|
for (const [start, length] of matches) {
|
|
1089
1254
|
const spans = this.mapper.spans.filter(
|
|
1090
1255
|
(s) => s.end > start && s.start < start + length,
|
|
@@ -1111,6 +1276,7 @@ export class RedlineEngine {
|
|
|
1111
1276
|
}
|
|
1112
1277
|
return errors;
|
|
1113
1278
|
}
|
|
1279
|
+
|
|
1114
1280
|
public validate_review_actions(actions: any[]): string[] {
|
|
1115
1281
|
const errors: string[] = [];
|
|
1116
1282
|
for (let i = 0; i < actions.length; i++) {
|
|
@@ -1151,7 +1317,35 @@ export class RedlineEngine {
|
|
|
1151
1317
|
}
|
|
1152
1318
|
return errors;
|
|
1153
1319
|
}
|
|
1154
|
-
|
|
1320
|
+
|
|
1321
|
+
public process_batch(changes: DocumentChange[], dry_run: boolean = false): any {
|
|
1322
|
+
if (dry_run) {
|
|
1323
|
+
const baselines = new Map<any, Element>();
|
|
1324
|
+
for (const part of this.doc.pkg.parts) {
|
|
1325
|
+
if (part._element) {
|
|
1326
|
+
baselines.set(part, part._element.cloneNode(true) as Element);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
try {
|
|
1330
|
+
return this._process_batch_internal(changes, true);
|
|
1331
|
+
} finally {
|
|
1332
|
+
for (const [part, originalEl] of baselines.entries()) {
|
|
1333
|
+
const doc = part._element.ownerDocument;
|
|
1334
|
+
if (doc && doc.documentElement) {
|
|
1335
|
+
doc.replaceChild(originalEl, doc.documentElement);
|
|
1336
|
+
}
|
|
1337
|
+
part._element = originalEl;
|
|
1338
|
+
}
|
|
1339
|
+
this.mapper = new DocumentMapper(this.doc);
|
|
1340
|
+
this.comments_manager = new CommentsManager(this.doc);
|
|
1341
|
+
this.clean_mapper = null;
|
|
1342
|
+
}
|
|
1343
|
+
} else {
|
|
1344
|
+
return this._process_batch_internal(changes, false);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
private _process_batch_internal(changes: DocumentChange[], dry_run_mode: boolean = false): any {
|
|
1155
1349
|
this.skipped_details = [];
|
|
1156
1350
|
const actions = changes.filter((c) =>
|
|
1157
1351
|
["accept", "reject", "reply"].includes(c.type),
|
|
@@ -1160,37 +1354,124 @@ export class RedlineEngine {
|
|
|
1160
1354
|
(c) => !["accept", "reject", "reply"].includes(c.type),
|
|
1161
1355
|
);
|
|
1162
1356
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1357
|
+
// BUG-7: Unified single-pass validation in wet-run / standard mode
|
|
1358
|
+
if (!dry_run_mode) {
|
|
1359
|
+
const all_errors: string[] = [];
|
|
1360
|
+
if (actions.length > 0) {
|
|
1361
|
+
all_errors.push(...this.validate_review_actions(actions));
|
|
1362
|
+
}
|
|
1363
|
+
if (edits.length > 0) {
|
|
1364
|
+
all_errors.push(...this.validate_edits(edits));
|
|
1365
|
+
}
|
|
1366
|
+
if (all_errors.length > 0) {
|
|
1367
|
+
throw new BatchValidationError(all_errors);
|
|
1368
|
+
}
|
|
1369
|
+
} else {
|
|
1370
|
+
if (actions.length > 0) {
|
|
1371
|
+
const action_errors = this.validate_review_actions(actions);
|
|
1372
|
+
if (action_errors.length > 0) {
|
|
1373
|
+
throw new BatchValidationError(action_errors);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1174
1376
|
}
|
|
1175
1377
|
|
|
1176
|
-
let applied_actions = 0
|
|
1177
|
-
|
|
1378
|
+
let applied_actions = 0;
|
|
1379
|
+
let skipped_actions = 0;
|
|
1178
1380
|
if (actions.length > 0) {
|
|
1179
1381
|
const res = this.apply_review_actions(actions);
|
|
1180
1382
|
applied_actions = res[0];
|
|
1181
1383
|
skipped_actions = res[1];
|
|
1384
|
+
if (skipped_actions > 0) {
|
|
1385
|
+
throw new BatchValidationError(this.skipped_details);
|
|
1386
|
+
}
|
|
1182
1387
|
if (applied_actions > 0) {
|
|
1183
1388
|
this.mapper["_build_map"]();
|
|
1184
1389
|
if (this.clean_mapper) this.clean_mapper["_build_map"]();
|
|
1185
1390
|
}
|
|
1186
1391
|
}
|
|
1187
1392
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1393
|
+
const edits_reports: any[] = [];
|
|
1394
|
+
let applied_edits = 0;
|
|
1395
|
+
let skipped_edits = 0;
|
|
1396
|
+
|
|
1190
1397
|
if (edits.length > 0) {
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1398
|
+
if (dry_run_mode) {
|
|
1399
|
+
for (const edit of edits) {
|
|
1400
|
+
const single_errors = this.validate_edits([edit]);
|
|
1401
|
+
const warning = this._check_punctuation_warning((edit as any).target_text || "");
|
|
1402
|
+
if (single_errors.length > 0) {
|
|
1403
|
+
skipped_edits++;
|
|
1404
|
+
edits_reports.push({
|
|
1405
|
+
status: "failed",
|
|
1406
|
+
target_text: (edit as any).target_text || "",
|
|
1407
|
+
new_text: (edit as any).new_text || "",
|
|
1408
|
+
warning: warning,
|
|
1409
|
+
error: single_errors[0],
|
|
1410
|
+
critic_markup: null,
|
|
1411
|
+
clean_text: null,
|
|
1412
|
+
});
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
const res = this.apply_edits([edit]);
|
|
1416
|
+
const applied = res[0];
|
|
1417
|
+
if (applied > 0) {
|
|
1418
|
+
applied_edits++;
|
|
1419
|
+
const previews = this._build_edit_context_previews(edit);
|
|
1420
|
+
edits_reports.push({
|
|
1421
|
+
status: "applied",
|
|
1422
|
+
target_text: (edit as any).target_text || "",
|
|
1423
|
+
new_text: (edit as any).new_text || "",
|
|
1424
|
+
warning: warning,
|
|
1425
|
+
error: null,
|
|
1426
|
+
critic_markup: previews[0],
|
|
1427
|
+
clean_text: previews[1],
|
|
1428
|
+
});
|
|
1429
|
+
} else {
|
|
1430
|
+
skipped_edits++;
|
|
1431
|
+
const error_msg = this.skipped_details.length > 0 ? this.skipped_details[this.skipped_details.length - 1] : "Failed to apply edit";
|
|
1432
|
+
edits_reports.push({
|
|
1433
|
+
status: "failed",
|
|
1434
|
+
target_text: (edit as any).target_text || "",
|
|
1435
|
+
new_text: (edit as any).new_text || "",
|
|
1436
|
+
warning: warning,
|
|
1437
|
+
error: error_msg,
|
|
1438
|
+
critic_markup: null,
|
|
1439
|
+
clean_text: null,
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
} else {
|
|
1444
|
+
const errors = this.validate_edits(edits);
|
|
1445
|
+
if (errors.length > 0) {
|
|
1446
|
+
throw new BatchValidationError(errors);
|
|
1447
|
+
}
|
|
1448
|
+
const cloned_edits = edits.map(e => JSON.parse(JSON.stringify(e)));
|
|
1449
|
+
const res = this.apply_edits(cloned_edits);
|
|
1450
|
+
applied_edits = res[0];
|
|
1451
|
+
skipped_edits = res[1];
|
|
1452
|
+
|
|
1453
|
+
for (const edit of cloned_edits) {
|
|
1454
|
+
const success = (edit as any)._applied_status || false;
|
|
1455
|
+
const error_msg = (edit as any)._error_msg || null;
|
|
1456
|
+
const warning = this._check_punctuation_warning((edit as any).target_text || "");
|
|
1457
|
+
let critic_markup = null;
|
|
1458
|
+
let clean_text = null;
|
|
1459
|
+
if (success) {
|
|
1460
|
+
const previews = this._build_edit_context_previews(edit);
|
|
1461
|
+
critic_markup = previews[0];
|
|
1462
|
+
clean_text = previews[1];
|
|
1463
|
+
}
|
|
1464
|
+
edits_reports.push({
|
|
1465
|
+
status: success ? "applied" : "failed",
|
|
1466
|
+
target_text: (edit as any).target_text || "",
|
|
1467
|
+
new_text: (edit as any).new_text || "",
|
|
1468
|
+
warning: warning,
|
|
1469
|
+
error: error_msg,
|
|
1470
|
+
critic_markup: critic_markup,
|
|
1471
|
+
clean_text: clean_text,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1194
1475
|
}
|
|
1195
1476
|
|
|
1196
1477
|
return {
|
|
@@ -1199,6 +1480,9 @@ export class RedlineEngine {
|
|
|
1199
1480
|
edits_applied: applied_edits,
|
|
1200
1481
|
edits_skipped: skipped_edits,
|
|
1201
1482
|
skipped_details: this.skipped_details,
|
|
1483
|
+
edits: edits_reports,
|
|
1484
|
+
engine: "node",
|
|
1485
|
+
version: "1.9.0",
|
|
1202
1486
|
};
|
|
1203
1487
|
}
|
|
1204
1488
|
|
|
@@ -1207,47 +1491,84 @@ export class RedlineEngine {
|
|
|
1207
1491
|
let skipped = 0;
|
|
1208
1492
|
const resolved_edits: [any, string | null][] = [];
|
|
1209
1493
|
|
|
1494
|
+
for (const edit of edits) {
|
|
1495
|
+
edit._applied_status = false;
|
|
1496
|
+
edit._error_msg = null;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1210
1499
|
for (const edit of edits) {
|
|
1211
1500
|
if (
|
|
1501
|
+
edit._resolved_start_idx !== undefined &&
|
|
1502
|
+
edit._resolved_start_idx !== null
|
|
1503
|
+
) {
|
|
1504
|
+
resolved_edits.push([edit, edit.new_text || null]);
|
|
1505
|
+
} else if (
|
|
1212
1506
|
edit._match_start_index !== undefined &&
|
|
1213
1507
|
edit._match_start_index !== null
|
|
1214
1508
|
) {
|
|
1509
|
+
edit._resolved_start_idx = edit._match_start_index;
|
|
1215
1510
|
resolved_edits.push([edit, edit.new_text || null]);
|
|
1216
1511
|
} else if (edit.type === "insert_row" || edit.type === "delete_row") {
|
|
1217
|
-
|
|
1218
|
-
if (
|
|
1219
|
-
|
|
1512
|
+
let matches = this.mapper.find_all_match_indices(edit.target_text);
|
|
1513
|
+
if (matches.length === 0) {
|
|
1514
|
+
if (!this.clean_mapper) {
|
|
1515
|
+
this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
1516
|
+
}
|
|
1517
|
+
matches = this.clean_mapper.find_all_match_indices(edit.target_text);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
if (matches.length > 0) {
|
|
1521
|
+
edit._resolved_start_idx = matches[0][0];
|
|
1220
1522
|
resolved_edits.push([edit, null]);
|
|
1221
1523
|
} else {
|
|
1222
1524
|
skipped++;
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1525
|
+
edit._applied_status = false;
|
|
1526
|
+
const target_snippet = (edit.target_text || "").trim().substring(0, 40);
|
|
1527
|
+
const msg = `- Failed to locate row target: '${target_snippet}...'`;
|
|
1528
|
+
this.skipped_details.push(msg);
|
|
1529
|
+
edit._error_msg = msg;
|
|
1226
1530
|
}
|
|
1227
1531
|
} else {
|
|
1228
1532
|
const resolved = this._pre_resolve_heuristic_edit(edit);
|
|
1229
1533
|
if (resolved) {
|
|
1230
1534
|
if (Array.isArray(resolved)) {
|
|
1231
|
-
for (const r of resolved)
|
|
1535
|
+
for (const r of resolved) {
|
|
1536
|
+
r._resolved_start_idx = r._match_start_index;
|
|
1537
|
+
r._parent_edit_ref = edit;
|
|
1538
|
+
if (edit._resolved_start_idx === undefined || edit._resolved_start_idx === null) {
|
|
1539
|
+
edit._resolved_start_idx = r._resolved_start_idx;
|
|
1540
|
+
}
|
|
1541
|
+
if (!edit._resolved_proxy_edit) {
|
|
1542
|
+
edit._resolved_proxy_edit = r;
|
|
1543
|
+
}
|
|
1544
|
+
resolved_edits.push([r, r.new_text]);
|
|
1545
|
+
}
|
|
1232
1546
|
} else {
|
|
1547
|
+
resolved._resolved_start_idx = resolved._match_start_index;
|
|
1548
|
+
resolved._parent_edit_ref = edit;
|
|
1549
|
+
edit._resolved_start_idx = resolved._resolved_start_idx;
|
|
1550
|
+
edit._resolved_proxy_edit = resolved;
|
|
1233
1551
|
resolved_edits.push([resolved, (resolved as any).new_text]);
|
|
1234
1552
|
}
|
|
1235
1553
|
} else {
|
|
1236
1554
|
skipped++;
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
);
|
|
1555
|
+
edit._applied_status = false;
|
|
1556
|
+
const display_text = edit.target_text || "insertion";
|
|
1557
|
+
const target_snippet = display_text.trim().substring(0, 40);
|
|
1558
|
+
const msg = `- Failed to apply edit targeting: '${target_snippet}...'`;
|
|
1559
|
+
this.skipped_details.push(msg);
|
|
1560
|
+
edit._error_msg = msg;
|
|
1240
1561
|
}
|
|
1241
1562
|
}
|
|
1242
1563
|
}
|
|
1243
1564
|
|
|
1244
1565
|
resolved_edits.sort(
|
|
1245
|
-
(a, b) => (b[0].
|
|
1566
|
+
(a, b) => (b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0),
|
|
1246
1567
|
);
|
|
1247
1568
|
const occupied_ranges: [number, number][] = [];
|
|
1248
1569
|
|
|
1249
1570
|
for (const [edit, orig_new] of resolved_edits) {
|
|
1250
|
-
const start = edit.
|
|
1571
|
+
const start = edit._resolved_start_idx || 0;
|
|
1251
1572
|
const end = start + (edit.target_text ? edit.target_text.length : 0);
|
|
1252
1573
|
|
|
1253
1574
|
const overlaps = occupied_ranges.some(
|
|
@@ -1255,9 +1576,17 @@ export class RedlineEngine {
|
|
|
1255
1576
|
);
|
|
1256
1577
|
if (overlaps) {
|
|
1257
1578
|
skipped++;
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1579
|
+
const display_text = edit.target_text || "insertion";
|
|
1580
|
+
const target_snippet = display_text.trim().substring(0, 40);
|
|
1581
|
+
const msg = `- Skipped overlapping edit targeting: '${target_snippet}...'`;
|
|
1582
|
+
this.skipped_details.push(msg);
|
|
1583
|
+
edit._applied_status = false;
|
|
1584
|
+
edit._error_msg = msg;
|
|
1585
|
+
const parent = edit._parent_edit_ref;
|
|
1586
|
+
if (parent) {
|
|
1587
|
+
parent._applied_status = false;
|
|
1588
|
+
parent._error_msg = msg;
|
|
1589
|
+
}
|
|
1261
1590
|
continue;
|
|
1262
1591
|
}
|
|
1263
1592
|
|
|
@@ -1271,11 +1600,26 @@ export class RedlineEngine {
|
|
|
1271
1600
|
if (success) {
|
|
1272
1601
|
applied++;
|
|
1273
1602
|
occupied_ranges.push([start, end]);
|
|
1603
|
+
edit._applied_status = true;
|
|
1604
|
+
const parent = edit._parent_edit_ref;
|
|
1605
|
+
if (parent) {
|
|
1606
|
+
parent._applied_status = true;
|
|
1607
|
+
}
|
|
1274
1608
|
} else {
|
|
1275
1609
|
skipped++;
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1610
|
+
const display_text = edit.target_text || "insertion";
|
|
1611
|
+
const target_snippet = display_text.trim().substring(0, 40);
|
|
1612
|
+
const msg = `- Failed to apply edit targeting: '${target_snippet}...'`;
|
|
1613
|
+
this.skipped_details.push(msg);
|
|
1614
|
+
edit._applied_status = false;
|
|
1615
|
+
edit._error_msg = msg;
|
|
1616
|
+
const parent = edit._parent_edit_ref;
|
|
1617
|
+
if (parent) {
|
|
1618
|
+
if (!parent._applied_status) {
|
|
1619
|
+
parent._applied_status = false;
|
|
1620
|
+
parent._error_msg = msg;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1279
1623
|
}
|
|
1280
1624
|
}
|
|
1281
1625
|
|
|
@@ -1378,7 +1722,7 @@ export class RedlineEngine {
|
|
|
1378
1722
|
}
|
|
1379
1723
|
|
|
1380
1724
|
private _apply_table_edit(edit: any, rebuild_map: boolean): boolean {
|
|
1381
|
-
const start_idx = edit._match_start_index || 0;
|
|
1725
|
+
const start_idx = edit._resolved_start_idx !== undefined && edit._resolved_start_idx !== null ? edit._resolved_start_idx : (edit._match_start_index || 0);
|
|
1382
1726
|
const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(
|
|
1383
1727
|
start_idx,
|
|
1384
1728
|
rebuild_map,
|
|
@@ -1427,10 +1771,33 @@ export class RedlineEngine {
|
|
|
1427
1771
|
return false;
|
|
1428
1772
|
}
|
|
1429
1773
|
|
|
1774
|
+
/**
|
|
1775
|
+
* Returns the first match of `target_text` in the raw mapper that is NOT
|
|
1776
|
+
* entirely contained within a tracked deletion (<w:del>). Tracked-deleted
|
|
1777
|
+
* copies are not live, editable text, so an edit must resolve to a live
|
|
1778
|
+
* occurrence even when a dead copy appears earlier in the document
|
|
1779
|
+
* (BUG-23-5). Falls back to the plain first match when no live copy is
|
|
1780
|
+
* found (e.g. fuzzy/normalized matches the span filter cannot align).
|
|
1781
|
+
*/
|
|
1782
|
+
private _first_live_match(target_text: string): [number, number] {
|
|
1783
|
+
const all = this.mapper.find_all_match_indices(target_text);
|
|
1784
|
+
if (all.length <= 1) {
|
|
1785
|
+
return this.mapper.find_match_index(target_text);
|
|
1786
|
+
}
|
|
1787
|
+
for (const [start, length] of all) {
|
|
1788
|
+
const realSpans = this.mapper.spans.filter(
|
|
1789
|
+
(s) => s.run !== null && s.end > start && s.start < start + length,
|
|
1790
|
+
);
|
|
1791
|
+
if (realSpans.length === 0) return [start, length];
|
|
1792
|
+
if (realSpans.some((s) => !s.del_id)) return [start, length];
|
|
1793
|
+
}
|
|
1794
|
+
return this.mapper.find_match_index(target_text);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1430
1797
|
private _pre_resolve_heuristic_edit(edit: any): any {
|
|
1431
1798
|
if (!edit.target_text) return null;
|
|
1432
1799
|
|
|
1433
|
-
let [start_idx, match_len] = this.
|
|
1800
|
+
let [start_idx, match_len] = this._first_live_match(edit.target_text);
|
|
1434
1801
|
let use_clean_map = false;
|
|
1435
1802
|
|
|
1436
1803
|
if (start_idx === -1) {
|
|
@@ -1510,7 +1877,7 @@ export class RedlineEngine {
|
|
|
1510
1877
|
): boolean {
|
|
1511
1878
|
let op = edit._internal_op;
|
|
1512
1879
|
const active_mapper = edit._active_mapper_ref || this.mapper;
|
|
1513
|
-
const start_idx = edit._match_start_index || 0;
|
|
1880
|
+
const start_idx = edit._resolved_start_idx !== undefined && edit._resolved_start_idx !== null ? edit._resolved_start_idx : (edit._match_start_index || 0);
|
|
1514
1881
|
const length = edit.target_text ? edit.target_text.length : 0;
|
|
1515
1882
|
|
|
1516
1883
|
const del_id = ["DELETION", "MODIFICATION"].includes(op)
|
|
@@ -1576,6 +1943,88 @@ export class RedlineEngine {
|
|
|
1576
1943
|
);
|
|
1577
1944
|
if (!anchor_run && !anchor_para) return false;
|
|
1578
1945
|
|
|
1946
|
+
// BUG-23-3: a prefix insertion whose new_text ends in a paragraph break
|
|
1947
|
+
// (e.g. "Summary\n\n" inserted before "Conclusion") must become a NEW
|
|
1948
|
+
// paragraph placed BEFORE the anchor paragraph, not inline text merged
|
|
1949
|
+
// into a neighbouring paragraph. _track_insert_multiline drops the
|
|
1950
|
+
// trailing break and inlines the remainder, which both loses the
|
|
1951
|
+
// paragraph boundary and mis-orders the content. Handle this case here.
|
|
1952
|
+
const _bug233_new = edit.new_text || "";
|
|
1953
|
+
const _bug233_trailing_break = /\n\s*$/.test(_bug233_new);
|
|
1954
|
+
let _bug233_target_para: Element | null = null;
|
|
1955
|
+
{
|
|
1956
|
+
const startingSpans = active_mapper.spans.filter(
|
|
1957
|
+
(s: TextSpan) => s.paragraph !== null && s.start === start_idx,
|
|
1958
|
+
);
|
|
1959
|
+
if (startingSpans.length > 0 && startingSpans[0].paragraph) {
|
|
1960
|
+
_bug233_target_para = startingSpans[0].paragraph._element;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
if (
|
|
1964
|
+
_bug233_trailing_break &&
|
|
1965
|
+
_bug233_target_para &&
|
|
1966
|
+
_bug233_target_para.parentNode
|
|
1967
|
+
) {
|
|
1968
|
+
const body = _bug233_target_para.parentNode as Element;
|
|
1969
|
+
const xmlDoc = this.doc.part._element.ownerDocument!;
|
|
1970
|
+
const lines = _bug233_new.split(/[\r\n]+/).filter((l: string) => l !== "");
|
|
1971
|
+
let firstNew: Element | null = null;
|
|
1972
|
+
let lastNew: Element | null = null;
|
|
1973
|
+
let lastIns: Element | null = null;
|
|
1974
|
+
for (const raw_line of lines) {
|
|
1975
|
+
const [clean_text, style_name] = this._parse_markdown_style(raw_line);
|
|
1976
|
+
const new_p = xmlDoc.createElement("w:p");
|
|
1977
|
+
if (style_name) {
|
|
1978
|
+
this._set_paragraph_style(new_p, style_name);
|
|
1979
|
+
} else {
|
|
1980
|
+
const existing_pPr = findChild(_bug233_target_para, "w:pPr");
|
|
1981
|
+
if (existing_pPr) new_p.appendChild(existing_pPr.cloneNode(true));
|
|
1982
|
+
}
|
|
1983
|
+
let pPr = findChild(new_p, "w:pPr");
|
|
1984
|
+
if (!pPr) {
|
|
1985
|
+
pPr = xmlDoc.createElement("w:pPr");
|
|
1986
|
+
new_p.insertBefore(pPr, new_p.firstChild);
|
|
1987
|
+
}
|
|
1988
|
+
let rPr = findChild(pPr, "w:rPr");
|
|
1989
|
+
if (!rPr) {
|
|
1990
|
+
rPr = xmlDoc.createElement("w:rPr");
|
|
1991
|
+
pPr.appendChild(rPr);
|
|
1992
|
+
}
|
|
1993
|
+
rPr.appendChild(this._create_track_change_tag("w:ins", "", ins_id!));
|
|
1994
|
+
const content_ins = this._build_tracked_ins_for_line(
|
|
1995
|
+
clean_text,
|
|
1996
|
+
anchor_run,
|
|
1997
|
+
ins_id!,
|
|
1998
|
+
xmlDoc,
|
|
1999
|
+
);
|
|
2000
|
+
if (content_ins) new_p.appendChild(content_ins);
|
|
2001
|
+
body.insertBefore(new_p, _bug233_target_para);
|
|
2002
|
+
if (!firstNew) firstNew = new_p;
|
|
2003
|
+
lastNew = new_p;
|
|
2004
|
+
lastIns = content_ins;
|
|
2005
|
+
}
|
|
2006
|
+
if (firstNew) {
|
|
2007
|
+
if (edit.comment && lastNew && lastIns) {
|
|
2008
|
+
const ascend = (el: Element, p: Element): Element => {
|
|
2009
|
+
let cur: Element = el;
|
|
2010
|
+
while (cur.parentNode && cur.parentNode !== p)
|
|
2011
|
+
cur = cur.parentNode as Element;
|
|
2012
|
+
return cur;
|
|
2013
|
+
};
|
|
2014
|
+
const startIns =
|
|
2015
|
+
findAllDescendants(firstNew, "w:ins")[0] || firstNew;
|
|
2016
|
+
this._attach_comment_spanning(
|
|
2017
|
+
firstNew,
|
|
2018
|
+
ascend(startIns, firstNew),
|
|
2019
|
+
lastNew,
|
|
2020
|
+
ascend(lastIns, lastNew),
|
|
2021
|
+
edit.comment,
|
|
2022
|
+
);
|
|
2023
|
+
}
|
|
2024
|
+
return true;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
1579
2028
|
const result = this._track_insert_multiline(
|
|
1580
2029
|
edit.new_text || "",
|
|
1581
2030
|
anchor_run,
|