@adeu/core 1.17.2 → 1.18.1

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.d.cts CHANGED
@@ -185,6 +185,16 @@ declare class RedlineEngine {
185
185
  original_mapper: DocumentMapper | null;
186
186
  skipped_details: string[];
187
187
  constructor(doc: DocumentObject, author?: string);
188
+ /**
189
+ * Return a hint when a short, single-token anchor contains punctuation that
190
+ * can split awkwardly, else null.
191
+ *
192
+ * Surface this ONLY for edits that actually failed to match/apply. On a
193
+ * successful edit the batch report already carries the redline preview, so
194
+ * emitting this would be a false positive: the punctuation (dates,
195
+ * `[_name_]` placeholders, `____` blanks) is frequently the literal target
196
+ * and the edit succeeds despite it. Mirrors the Python engine.
197
+ */
188
198
  private _check_punctuation_warning;
189
199
  /**
190
200
  * Best-effort "did you mean" hint for a failed target. The common loop trap
@@ -200,6 +210,35 @@ declare class RedlineEngine {
200
210
  private _scan_existing_ids;
201
211
  private _get_heading_path_and_page;
202
212
  accept_all_revisions(): void;
213
+ /**
214
+ * Revert every tracked change, returning the document to the state it had
215
+ * before any revision was proposed. The exact inverse of
216
+ * accept_all_revisions:
217
+ *
218
+ * - <w:ins> -> removed together with all of its content (the proposed
219
+ * insertion never existed); an inserted row (<w:ins> in
220
+ * <w:trPr>) drops the whole row.
221
+ * - <w:del> -> unwrapped, restoring the original text (<w:delText> becomes
222
+ * <w:t> again); a row-deletion mark in <w:trPr> is removed so
223
+ * the row survives.
224
+ * - paragraph-mark <w:del> in pPr/rPr -> removed, undoing a proposed merge.
225
+ *
226
+ * Comments are annotations, not revisions, so standalone comments are left in
227
+ * place; only anchors stranded inside a rejected insertion are cleaned up.
228
+ *
229
+ * Insertions are reverted before deletions are restored so a deletion nested
230
+ * inside a foreign author's insertion is removed wholesale with the insertion
231
+ * — the contingent text disappears rather than being promoted to committed
232
+ * body text.
233
+ *
234
+ * Known limitation: tracked paragraph STRUCTURE changes (a split recorded as a
235
+ * pilcrow <w:ins>, or a merge recorded as a pilcrow <w:del>) are reverted only
236
+ * to the extent of dropping/keeping the mark; the original paragraph boundary
237
+ * is not reconstructed, because the merge protocol coalesces paragraphs
238
+ * destructively at edit time. Reverting run-level insertions/deletions (the
239
+ * common case) is exact. This limitation is shared with the Python engine.
240
+ */
241
+ reject_all_revisions(): void;
203
242
  private _getNextId;
204
243
  private _create_track_change_tag;
205
244
  private _set_text_content;
@@ -286,6 +325,15 @@ declare class RedlineEngine {
286
325
  apply_review_actions(actions: any[]): [number, number];
287
326
  private _apply_table_edit;
288
327
  private _pre_resolve_heuristic_edit;
328
+ /**
329
+ * Split a <w:ins> so that everything up to and INCLUDING split_after stays in
330
+ * a left <w:ins>, new_elem is placed between, and the remainder moves to a
331
+ * right <w:ins> — all at the grandparent level. Used when revising another
332
+ * author's pending insertion: the <w:del> stays nested in their <w:ins> while
333
+ * our replacement <w:ins> lands as a sibling, so we never nest <w:ins> in
334
+ * <w:ins>.
335
+ */
336
+ private _insert_and_split_ins;
289
337
  private _apply_single_edit_indexed;
290
338
  }
291
339
 
package/dist/index.d.ts CHANGED
@@ -185,6 +185,16 @@ declare class RedlineEngine {
185
185
  original_mapper: DocumentMapper | null;
186
186
  skipped_details: string[];
187
187
  constructor(doc: DocumentObject, author?: string);
188
+ /**
189
+ * Return a hint when a short, single-token anchor contains punctuation that
190
+ * can split awkwardly, else null.
191
+ *
192
+ * Surface this ONLY for edits that actually failed to match/apply. On a
193
+ * successful edit the batch report already carries the redline preview, so
194
+ * emitting this would be a false positive: the punctuation (dates,
195
+ * `[_name_]` placeholders, `____` blanks) is frequently the literal target
196
+ * and the edit succeeds despite it. Mirrors the Python engine.
197
+ */
188
198
  private _check_punctuation_warning;
189
199
  /**
190
200
  * Best-effort "did you mean" hint for a failed target. The common loop trap
@@ -200,6 +210,35 @@ declare class RedlineEngine {
200
210
  private _scan_existing_ids;
201
211
  private _get_heading_path_and_page;
202
212
  accept_all_revisions(): void;
213
+ /**
214
+ * Revert every tracked change, returning the document to the state it had
215
+ * before any revision was proposed. The exact inverse of
216
+ * accept_all_revisions:
217
+ *
218
+ * - <w:ins> -> removed together with all of its content (the proposed
219
+ * insertion never existed); an inserted row (<w:ins> in
220
+ * <w:trPr>) drops the whole row.
221
+ * - <w:del> -> unwrapped, restoring the original text (<w:delText> becomes
222
+ * <w:t> again); a row-deletion mark in <w:trPr> is removed so
223
+ * the row survives.
224
+ * - paragraph-mark <w:del> in pPr/rPr -> removed, undoing a proposed merge.
225
+ *
226
+ * Comments are annotations, not revisions, so standalone comments are left in
227
+ * place; only anchors stranded inside a rejected insertion are cleaned up.
228
+ *
229
+ * Insertions are reverted before deletions are restored so a deletion nested
230
+ * inside a foreign author's insertion is removed wholesale with the insertion
231
+ * — the contingent text disappears rather than being promoted to committed
232
+ * body text.
233
+ *
234
+ * Known limitation: tracked paragraph STRUCTURE changes (a split recorded as a
235
+ * pilcrow <w:ins>, or a merge recorded as a pilcrow <w:del>) are reverted only
236
+ * to the extent of dropping/keeping the mark; the original paragraph boundary
237
+ * is not reconstructed, because the merge protocol coalesces paragraphs
238
+ * destructively at edit time. Reverting run-level insertions/deletions (the
239
+ * common case) is exact. This limitation is shared with the Python engine.
240
+ */
241
+ reject_all_revisions(): void;
203
242
  private _getNextId;
204
243
  private _create_track_change_tag;
205
244
  private _set_text_content;
@@ -286,6 +325,15 @@ declare class RedlineEngine {
286
325
  apply_review_actions(actions: any[]): [number, number];
287
326
  private _apply_table_edit;
288
327
  private _pre_resolve_heuristic_edit;
328
+ /**
329
+ * Split a <w:ins> so that everything up to and INCLUDING split_after stays in
330
+ * a left <w:ins>, new_elem is placed between, and the remainder moves to a
331
+ * right <w:ins> — all at the grandparent level. Used when revising another
332
+ * author's pending insertion: the <w:del> stays nested in their <w:ins> while
333
+ * our replacement <w:ins> lands as a sibling, so we never nest <w:ins> in
334
+ * <w:ins>.
335
+ */
336
+ private _insert_and_split_ins;
289
337
  private _apply_single_edit_indexed;
290
338
  }
291
339
 
package/dist/index.js CHANGED
@@ -3012,8 +3012,19 @@ var RedlineEngine = class {
3012
3012
  this.mapper = new DocumentMapper(this.doc);
3013
3013
  this.comments_manager = new CommentsManager(this.doc);
3014
3014
  }
3015
+ /**
3016
+ * Return a hint when a short, single-token anchor contains punctuation that
3017
+ * can split awkwardly, else null.
3018
+ *
3019
+ * Surface this ONLY for edits that actually failed to match/apply. On a
3020
+ * successful edit the batch report already carries the redline preview, so
3021
+ * emitting this would be a false positive: the punctuation (dates,
3022
+ * `[_name_]` placeholders, `____` blanks) is frequently the literal target
3023
+ * and the edit succeeds despite it. Mirrors the Python engine.
3024
+ */
3015
3025
  _check_punctuation_warning(target_text) {
3016
3026
  if (!target_text) return null;
3027
+ if (target_text.length > 20 || target_text.includes(" ")) return null;
3017
3028
  if (target_text.includes("_") || target_text.includes("-")) {
3018
3029
  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.`;
3019
3030
  }
@@ -3295,6 +3306,93 @@ var RedlineEngine = class {
3295
3306
  }
3296
3307
  }
3297
3308
  }
3309
+ /**
3310
+ * Revert every tracked change, returning the document to the state it had
3311
+ * before any revision was proposed. The exact inverse of
3312
+ * accept_all_revisions:
3313
+ *
3314
+ * - <w:ins> -> removed together with all of its content (the proposed
3315
+ * insertion never existed); an inserted row (<w:ins> in
3316
+ * <w:trPr>) drops the whole row.
3317
+ * - <w:del> -> unwrapped, restoring the original text (<w:delText> becomes
3318
+ * <w:t> again); a row-deletion mark in <w:trPr> is removed so
3319
+ * the row survives.
3320
+ * - paragraph-mark <w:del> in pPr/rPr -> removed, undoing a proposed merge.
3321
+ *
3322
+ * Comments are annotations, not revisions, so standalone comments are left in
3323
+ * place; only anchors stranded inside a rejected insertion are cleaned up.
3324
+ *
3325
+ * Insertions are reverted before deletions are restored so a deletion nested
3326
+ * inside a foreign author's insertion is removed wholesale with the insertion
3327
+ * — the contingent text disappears rather than being promoted to committed
3328
+ * body text.
3329
+ *
3330
+ * Known limitation: tracked paragraph STRUCTURE changes (a split recorded as a
3331
+ * pilcrow <w:ins>, or a merge recorded as a pilcrow <w:del>) are reverted only
3332
+ * to the extent of dropping/keeping the mark; the original paragraph boundary
3333
+ * is not reconstructed, because the merge protocol coalesces paragraphs
3334
+ * destructively at edit time. Reverting run-level insertions/deletions (the
3335
+ * common case) is exact. This limitation is shared with the Python engine.
3336
+ */
3337
+ reject_all_revisions() {
3338
+ const parts_to_process = [this.doc.element];
3339
+ for (const part of this.doc.pkg.parts) {
3340
+ if (part === this.doc.part) continue;
3341
+ if (part.contentType.includes("wordprocessingml") && part.contentType.endsWith("+xml")) {
3342
+ parts_to_process.push(part._element);
3343
+ }
3344
+ }
3345
+ for (const root_element of parts_to_process) {
3346
+ const insNodes = findAllDescendants(root_element, "w:ins");
3347
+ for (const ins of insNodes) {
3348
+ const parent = ins.parentNode;
3349
+ if (!parent) continue;
3350
+ this._clean_wrapping_comments(ins);
3351
+ this._delete_comments_in_element(ins);
3352
+ if (parent.tagName === "w:trPr") {
3353
+ const row = parent.parentNode;
3354
+ if (row && row.parentNode) {
3355
+ row.parentNode.removeChild(row);
3356
+ }
3357
+ } else {
3358
+ parent.removeChild(ins);
3359
+ }
3360
+ }
3361
+ const pNodes = findAllDescendants(root_element, "w:p");
3362
+ for (const p of pNodes) {
3363
+ const pPr = findChild(p, "w:pPr");
3364
+ if (pPr) {
3365
+ const rPr = findChild(pPr, "w:rPr");
3366
+ const delMark = rPr ? findChild(rPr, "w:del") : null;
3367
+ if (rPr && delMark) {
3368
+ rPr.removeChild(delMark);
3369
+ }
3370
+ }
3371
+ }
3372
+ const delNodes = findAllDescendants(root_element, "w:del");
3373
+ for (const d of delNodes) {
3374
+ const parent = d.parentNode;
3375
+ if (!parent) continue;
3376
+ this._clean_wrapping_comments(d);
3377
+ if (parent.tagName === "w:trPr") {
3378
+ parent.removeChild(d);
3379
+ continue;
3380
+ }
3381
+ const delTexts = Array.from(d.getElementsByTagName("w:delText"));
3382
+ for (const dt of delTexts) {
3383
+ const t = d.ownerDocument.createElement("w:t");
3384
+ t.textContent = dt.textContent;
3385
+ if (dt.hasAttribute("xml:space"))
3386
+ t.setAttribute("xml:space", "preserve");
3387
+ dt.parentNode?.replaceChild(t, dt);
3388
+ }
3389
+ while (d.firstChild) {
3390
+ parent.insertBefore(d.firstChild, d);
3391
+ }
3392
+ parent.removeChild(d);
3393
+ }
3394
+ }
3395
+ }
3298
3396
  _getNextId() {
3299
3397
  this.current_id++;
3300
3398
  return this.current_id.toString();
@@ -3942,29 +4040,36 @@ var RedlineEngine = class {
3942
4040
  (edit.new_text || "").length - sfx
3943
4041
  );
3944
4042
  if (final_target.includes("\n\n")) {
3945
- if (final_new.includes("\n\n")) {
3946
- const parts = matched.split("\n\n");
3947
- if (parts.length >= 2 && parts[0].trim() !== "" && parts[parts.length - 1].trim() !== "") {
3948
- errors.push(
3949
- `- Edit ${i + 1 + index_offset} 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.`
3950
- );
3951
- }
3952
- } else {
3953
- const parts = final_target.split("\n\n");
3954
- if (parts.length >= 2 && parts[0].trim() !== "" && parts[parts.length - 1].trim() !== "") {
3955
- errors.push(
3956
- `- Edit ${i + 1 + index_offset} 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.`
3957
- );
4043
+ const balanced = matched.split("\n\n").length === (edit.new_text || "").split("\n\n").length;
4044
+ if (!balanced) {
4045
+ if (final_new.includes("\n\n")) {
4046
+ const parts = matched.split("\n\n");
4047
+ if (parts.length >= 2 && parts[0].trim() !== "" && parts[parts.length - 1].trim() !== "") {
4048
+ errors.push(
4049
+ `- Edit ${i + 1 + index_offset} 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.`
4050
+ );
4051
+ }
4052
+ } else {
4053
+ const parts = final_target.split("\n\n");
4054
+ if (parts.length >= 2 && parts[0].trim() !== "" && parts[parts.length - 1].trim() !== "") {
4055
+ errors.push(
4056
+ `- Edit ${i + 1 + index_offset} 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.`
4057
+ );
4058
+ }
3958
4059
  }
3959
4060
  }
3960
4061
  }
3961
4062
  }
3962
4063
  for (const [start, length] of matches) {
3963
- const spans = this.mapper.spans.filter(
4064
+ const spans = target_mapper.spans.filter(
3964
4065
  (s) => s.end > start && s.start < start + length
3965
4066
  );
3966
- const nestedAuthors = /* @__PURE__ */ new Set();
4067
+ const insAuthors = /* @__PURE__ */ new Set();
4068
+ const commentAuthors = /* @__PURE__ */ new Set();
4069
+ let hasNonForeignRealText = false;
3967
4070
  for (const s of spans) {
4071
+ if (s.run === null) continue;
4072
+ let isForeignIns = false;
3968
4073
  if (s.ins_id) {
3969
4074
  const insNodes = findAllDescendants(
3970
4075
  this.doc.element,
@@ -3973,24 +4078,32 @@ var RedlineEngine = class {
3973
4078
  if (insNodes.length > 0) {
3974
4079
  const auth = insNodes[0].getAttribute("w:author");
3975
4080
  if (auth && auth !== this.author) {
3976
- const is_fully_contained_in_ins = start >= s.start && start + length <= s.end;
3977
- const is_lockout = is_fully_contained_in_ins || match_mode === "all";
3978
- if (is_lockout) {
3979
- nestedAuthors.add(auth);
3980
- }
4081
+ insAuthors.add(auth);
4082
+ isForeignIns = true;
3981
4083
  }
3982
4084
  }
3983
4085
  }
4086
+ if (!isForeignIns) hasNonForeignRealText = true;
4087
+ }
4088
+ for (const s of spans) {
3984
4089
  if (s.comment_ids) {
3985
4090
  for (const cid of s.comment_ids) {
3986
4091
  const c_data = this.mapper.comments_map[cid];
3987
4092
  if (c_data && c_data.author && c_data.author !== this.author) {
3988
- nestedAuthors.add(c_data.author);
4093
+ commentAuthors.add(c_data.author);
3989
4094
  }
3990
4095
  }
3991
4096
  }
3992
4097
  }
3993
- if (nestedAuthors.size > 0) {
4098
+ if (insAuthors.size > 0 || commentAuthors.size > 0) {
4099
+ const fullyWithinForeignIns = insAuthors.size > 0 && !hasNonForeignRealText && commentAuthors.size === 0;
4100
+ if ((match_mode === "strict" || match_mode === "first") && fullyWithinForeignIns) {
4101
+ continue;
4102
+ }
4103
+ const nestedAuthors = /* @__PURE__ */ new Set([
4104
+ ...insAuthors,
4105
+ ...commentAuthors
4106
+ ]);
3994
4107
  errors.push(
3995
4108
  `- Edit ${i + 1 + index_offset} Failed: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`
3996
4109
  );
@@ -4084,59 +4197,6 @@ var RedlineEngine = class {
4084
4197
  const edits = changes.filter(
4085
4198
  (c) => c === null || typeof c !== "object" || !["accept", "reject", "reply"].includes(c.type)
4086
4199
  );
4087
- let mapper_dirty = false;
4088
- for (const edit of edits) {
4089
- if (typeof edit !== "object" || edit === null || !edit.target_text) continue;
4090
- const is_regex = edit.regex || false;
4091
- let matches = this.mapper.find_all_match_indices(edit.target_text, is_regex);
4092
- let target_mapper = this.mapper;
4093
- if (matches.length === 0) {
4094
- if (!this.clean_mapper) {
4095
- this.clean_mapper = new DocumentMapper(this.doc, true);
4096
- }
4097
- matches = this.clean_mapper.find_all_match_indices(edit.target_text, is_regex);
4098
- target_mapper = this.clean_mapper;
4099
- }
4100
- for (const [start, length] of matches) {
4101
- const spans = target_mapper.spans.filter(
4102
- (s) => s.end > start && s.start < start + length
4103
- );
4104
- for (const s of spans) {
4105
- if (s.ins_id) {
4106
- const insNodes = findAllDescendants(this.doc.element, "w:ins").filter(
4107
- (n) => n.getAttribute("w:id") === s.ins_id
4108
- );
4109
- if (insNodes.length > 0) {
4110
- const auth = insNodes[0].getAttribute("w:author");
4111
- if (auth && auth !== this.author) {
4112
- const is_fully_contained_in_ins = start >= s.start && start + length <= s.end;
4113
- const match_mode = edit.match_mode || "strict";
4114
- if (!is_fully_contained_in_ins && match_mode !== "all") {
4115
- const node = insNodes[0];
4116
- this._clean_wrapping_comments(node);
4117
- const parent = node.parentNode;
4118
- if (parent) {
4119
- if (parent.tagName === "w:trPr") {
4120
- parent.removeChild(node);
4121
- } else {
4122
- while (node.firstChild) {
4123
- parent.insertBefore(node.firstChild, node);
4124
- }
4125
- parent.removeChild(node);
4126
- }
4127
- }
4128
- mapper_dirty = true;
4129
- }
4130
- }
4131
- }
4132
- }
4133
- }
4134
- }
4135
- }
4136
- if (mapper_dirty) {
4137
- this.mapper = new DocumentMapper(this.doc);
4138
- this.clean_mapper = null;
4139
- }
4140
4200
  if (!dry_run_mode) {
4141
4201
  const action_errors = actions.length > 0 ? this.validate_review_actions(actions) : [];
4142
4202
  const validate_edits_now = edits.length > 0 && action_errors.length > 0;
@@ -4181,11 +4241,11 @@ var RedlineEngine = class {
4181
4241
  for (let i = 0; i < edits.length; i++) {
4182
4242
  const edit = edits[i];
4183
4243
  const single_errors = this.validate_edits([edit], i);
4184
- const warning = this._check_punctuation_warning(
4185
- edit.target_text || ""
4186
- );
4187
4244
  if (single_errors.length > 0) {
4188
4245
  skipped_edits++;
4246
+ const warning = this._check_punctuation_warning(
4247
+ edit.target_text || ""
4248
+ );
4189
4249
  edits_reports.push({
4190
4250
  status: "failed",
4191
4251
  target_text: edit.target_text || "",
@@ -4205,7 +4265,7 @@ var RedlineEngine = class {
4205
4265
  status: "applied",
4206
4266
  target_text: edit.target_text || "",
4207
4267
  new_text: edit.new_text || "",
4208
- warning,
4268
+ warning: null,
4209
4269
  error: null,
4210
4270
  critic_markup: previews[0],
4211
4271
  clean_text: previews[1],
@@ -4219,6 +4279,9 @@ var RedlineEngine = class {
4219
4279
  } else {
4220
4280
  skipped_edits++;
4221
4281
  const error_msg = this.skipped_details.length > 0 ? this.skipped_details[this.skipped_details.length - 1] : "Failed to apply edit";
4282
+ const warning = this._check_punctuation_warning(
4283
+ edit.target_text || ""
4284
+ );
4222
4285
  edits_reports.push({
4223
4286
  status: "failed",
4224
4287
  target_text: edit.target_text || "",
@@ -4264,15 +4327,17 @@ var RedlineEngine = class {
4264
4327
  for (const edit of edits) {
4265
4328
  const success = edit._applied_status || false;
4266
4329
  const error_msg = edit._error_msg || null;
4267
- const warning = this._check_punctuation_warning(
4268
- edit.target_text || ""
4269
- );
4270
4330
  let critic_markup = null;
4271
4331
  let clean_text = null;
4332
+ let warning = null;
4272
4333
  if (success) {
4273
4334
  const previews = this._build_edit_context_previews(edit);
4274
4335
  critic_markup = previews[0];
4275
4336
  clean_text = previews[1];
4337
+ } else {
4338
+ warning = this._check_punctuation_warning(
4339
+ edit.target_text || ""
4340
+ );
4276
4341
  }
4277
4342
  edits_reports.push({
4278
4343
  status: success ? "applied" : "failed",
@@ -4380,6 +4445,7 @@ var RedlineEngine = class {
4380
4445
  (a, b) => (b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0)
4381
4446
  );
4382
4447
  const occupied_ranges = [];
4448
+ const counted_split_groups = /* @__PURE__ */ new Set();
4383
4449
  for (const [edit, orig_new] of resolved_edits) {
4384
4450
  const start = edit._resolved_start_idx || 0;
4385
4451
  const end = start + (edit.target_text ? edit.target_text.length : 0);
@@ -4408,13 +4474,20 @@ var RedlineEngine = class {
4408
4474
  success = this._apply_table_edit(edit, false);
4409
4475
  }
4410
4476
  if (success) {
4411
- applied++;
4477
+ const group_id = edit._split_group_id;
4478
+ const first_in_group = group_id === void 0 || group_id === null || !counted_split_groups.has(group_id);
4479
+ if (first_in_group && group_id !== void 0 && group_id !== null) {
4480
+ counted_split_groups.add(group_id);
4481
+ }
4482
+ if (first_in_group) applied++;
4412
4483
  occupied_ranges.push([start, end]);
4413
4484
  edit._applied_status = true;
4414
4485
  const parent = edit._parent_edit_ref;
4415
4486
  if (parent) {
4416
4487
  parent._applied_status = true;
4417
- parent._occurrences_modified = (parent._occurrences_modified || 0) + 1;
4488
+ if (first_in_group) {
4489
+ parent._occurrences_modified = (parent._occurrences_modified || 0) + 1;
4490
+ }
4418
4491
  const [path, page] = this._get_heading_path_and_page(
4419
4492
  start,
4420
4493
  this.mapper.full_text,
@@ -4425,7 +4498,9 @@ var RedlineEngine = class {
4425
4498
  parent._pages = pages;
4426
4499
  parent._heading_path = path;
4427
4500
  } else {
4428
- edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
4501
+ if (first_in_group) {
4502
+ edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
4503
+ }
4429
4504
  const [path, page] = this._get_heading_path_and_page(
4430
4505
  start,
4431
4506
  this.mapper.full_text,
@@ -4722,6 +4797,51 @@ var RedlineEngine = class {
4722
4797
  final_target = actual_doc_text.substring(prefix_len, t_end);
4723
4798
  final_new = current_effective_new_text.substring(prefix_len, n_end);
4724
4799
  effective_start_idx = start_idx + prefix_len;
4800
+ const target_segs = actual_doc_text.split("\n\n");
4801
+ const new_segs = current_effective_new_text.split("\n\n");
4802
+ if (actual_doc_text.includes("\n\n") && target_segs.length === new_segs.length) {
4803
+ const split_sub_edits = [];
4804
+ let seg_offset = start_idx;
4805
+ let comment_assigned = false;
4806
+ for (let k = 0; k < target_segs.length; k++) {
4807
+ const t_seg = target_segs[k];
4808
+ const n_seg = new_segs[k];
4809
+ if (t_seg !== n_seg) {
4810
+ const [seg_prefix, seg_suffix] = trim_common_context(t_seg, n_seg);
4811
+ const seg_target = t_seg.substring(
4812
+ seg_prefix,
4813
+ t_seg.length - seg_suffix
4814
+ );
4815
+ const seg_new = n_seg.substring(
4816
+ seg_prefix,
4817
+ n_seg.length - seg_suffix
4818
+ );
4819
+ const seg_start = seg_offset + seg_prefix;
4820
+ let seg_op;
4821
+ if (!seg_target && seg_new) seg_op = "INSERTION";
4822
+ else if (seg_target && !seg_new) seg_op = "DELETION";
4823
+ else if (seg_target && seg_new) seg_op = "MODIFICATION";
4824
+ else seg_op = "COMMENT_ONLY";
4825
+ const seg_comment = edit.comment && !comment_assigned ? edit.comment : null;
4826
+ if (seg_comment) comment_assigned = true;
4827
+ split_sub_edits.push({
4828
+ type: "modify",
4829
+ target_text: seg_target,
4830
+ new_text: seg_new,
4831
+ comment: seg_comment,
4832
+ _match_start_index: seg_start,
4833
+ _internal_op: seg_op,
4834
+ _active_mapper_ref: active_mapper,
4835
+ _split_group_id: start_idx
4836
+ });
4837
+ }
4838
+ seg_offset += t_seg.length + 2;
4839
+ }
4840
+ if (split_sub_edits.length > 0) {
4841
+ for (const sub of split_sub_edits) all_sub_edits.push(sub);
4842
+ continue;
4843
+ }
4844
+ }
4725
4845
  if (!final_target && final_new) effective_op = "INSERTION";
4726
4846
  else if (final_target && !final_new) effective_op = "DELETION";
4727
4847
  else if (final_target && final_new) effective_op = "MODIFICATION";
@@ -4741,6 +4861,35 @@ var RedlineEngine = class {
4741
4861
  if (match_mode === "all" || all_sub_edits.length > 1) return all_sub_edits;
4742
4862
  return all_sub_edits[0];
4743
4863
  }
4864
+ /**
4865
+ * Split a <w:ins> so that everything up to and INCLUDING split_after stays in
4866
+ * a left <w:ins>, new_elem is placed between, and the remainder moves to a
4867
+ * right <w:ins> — all at the grandparent level. Used when revising another
4868
+ * author's pending insertion: the <w:del> stays nested in their <w:ins> while
4869
+ * our replacement <w:ins> lands as a sibling, so we never nest <w:ins> in
4870
+ * <w:ins>.
4871
+ */
4872
+ _insert_and_split_ins(parent_ins, split_after, new_elem) {
4873
+ const grandparent = parent_ins.parentNode;
4874
+ if (!grandparent) return;
4875
+ const left = parent_ins.cloneNode(false);
4876
+ const right = parent_ins.cloneNode(false);
4877
+ let toRight = false;
4878
+ for (const kid of Array.from(parent_ins.childNodes)) {
4879
+ parent_ins.removeChild(kid);
4880
+ if (!toRight) {
4881
+ left.appendChild(kid);
4882
+ if (kid === split_after) toRight = true;
4883
+ } else {
4884
+ right.appendChild(kid);
4885
+ }
4886
+ }
4887
+ if (left.childNodes.length > 0) grandparent.insertBefore(left, parent_ins);
4888
+ grandparent.insertBefore(new_elem, parent_ins);
4889
+ if (right.childNodes.length > 0)
4890
+ grandparent.insertBefore(right, parent_ins);
4891
+ grandparent.removeChild(parent_ins);
4892
+ }
4744
4893
  _apply_single_edit_indexed(edit, orig_new, rebuild_map) {
4745
4894
  let op = edit._internal_op;
4746
4895
  const active_mapper = edit._active_mapper_ref || this.mapper;
@@ -4948,7 +5097,16 @@ var RedlineEngine = class {
4948
5097
  const is_inline_first = result.first_node.tagName === "w:ins";
4949
5098
  if (is_inline_first) {
4950
5099
  if (anchor_run) {
4951
- insertAfter(result.first_node, anchor_run._element);
5100
+ const anchor_parent = anchor_run._element.parentNode;
5101
+ if (anchor_parent && anchor_parent.tagName === "w:ins") {
5102
+ this._insert_and_split_ins(
5103
+ anchor_parent,
5104
+ anchor_run._element,
5105
+ result.first_node
5106
+ );
5107
+ } else {
5108
+ insertAfter(result.first_node, anchor_run._element);
5109
+ }
4952
5110
  } else if (anchor_para) {
4953
5111
  anchor_para._element.appendChild(result.first_node);
4954
5112
  }
@@ -5058,7 +5216,12 @@ var RedlineEngine = class {
5058
5216
  if (result.first_node) {
5059
5217
  const is_inline_first = result.first_node.tagName === "w:ins";
5060
5218
  if (is_inline_first) {
5061
- insertAfter(result.first_node, last_del);
5219
+ const del_parent = last_del.parentNode;
5220
+ if (del_parent && del_parent.tagName === "w:ins") {
5221
+ this._insert_and_split_ins(del_parent, last_del, result.first_node);
5222
+ } else {
5223
+ insertAfter(result.first_node, last_del);
5224
+ }
5062
5225
  ins_elem = result.first_node;
5063
5226
  } else {
5064
5227
  ins_elem = result.last_ins;