@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/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: remove any free-standing comments
355
- const comment_ids = new Set<string>();
356
- for (const tag of [
357
- "w:commentRangeStart",
358
- "w:commentRangeEnd",
359
- "w:commentReference",
360
- ]) {
361
- for (const node of findAllDescendants(this.doc.element, tag)) {
362
- const cid = node.getAttribute("w:id");
363
- if (cid) comment_ids.add(cid);
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 comments_part = this.doc.pkg.parts.find(
368
- (p) =>
369
- p.contentType ===
370
- "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
371
- );
372
- if (comments_part) {
373
- for (const c of findAllDescendants(comments_part._element, "w:comment")) {
374
- const cid = c.getAttribute("w:id");
375
- if (cid) comment_ids.add(cid);
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
- for (const cid of comment_ids) {
380
- this.comments_manager.deleteComment(cid);
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
- for (const tag of ["w:vanish", "w:strike", "w:dstrike"]) {
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
- public process_batch(changes: DocumentChange[]): any {
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
- const all_errors: string[] = [];
1164
-
1165
- if (actions.length > 0) {
1166
- all_errors.push(...this.validate_review_actions(actions));
1167
- }
1168
- if (edits.length > 0) {
1169
- all_errors.push(...this.validate_edits(edits));
1170
- }
1171
-
1172
- if (all_errors.length > 0) {
1173
- throw new BatchValidationError(all_errors);
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
- skipped_actions = 0;
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
- let applied_edits = 0,
1189
- skipped_edits = 0;
1393
+ const edits_reports: any[] = [];
1394
+ let applied_edits = 0;
1395
+ let skipped_edits = 0;
1396
+
1190
1397
  if (edits.length > 0) {
1191
- const res = this.apply_edits(edits as any[]);
1192
- applied_edits = res[0];
1193
- skipped_edits = res[1];
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
- const [idx] = this.mapper.find_match_index(edit.target_text);
1218
- if (idx !== -1) {
1219
- edit._match_start_index = idx;
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
- this.skipped_details.push(
1224
- `- Failed to locate row target: '${(edit.target_text || "").substring(0, 40)}...'`,
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) resolved_edits.push([r, r.new_text]);
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
- this.skipped_details.push(
1238
- `- Failed to apply edit targeting: '${(edit.target_text || "insertion").substring(0, 40)}...'`,
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]._match_start_index || 0) - (a[0]._match_start_index || 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._match_start_index || 0;
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
- this.skipped_details.push(
1259
- `- Skipped overlapping edit targeting: '${(edit.target_text || "insertion").substring(0, 40)}...'`,
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
- this.skipped_details.push(
1277
- `- Failed to apply edit targeting: '${(edit.target_text || "insertion").substring(0, 40)}...'`,
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.mapper.find_match_index(edit.target_text);
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,