@adeu/core 1.22.0 → 1.25.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/ingest.ts CHANGED
@@ -9,19 +9,46 @@ import {
9
9
  get_run_text,
10
10
  apply_formatting_to_segments,
11
11
  iter_block_items,
12
- iter_document_parts,
12
+ iter_document_parts_with_kind,
13
13
  iter_paragraph_content,
14
14
  } from "./utils/docx.js";
15
15
  import { findChild } from "./docx/dom.js";
16
16
  import { build_structural_appendix } from "./domain.js";
17
17
  import { extract_comments_data } from "./comments.js";
18
18
 
19
+ /** Text-space extent of one projected table row plus its cell texts. */
20
+ export interface RowGeometry {
21
+ start: number;
22
+ end: number;
23
+ cells: string[];
24
+ }
25
+
26
+ /** Text-space extent of one projected top-level table. */
27
+ export interface TableGeometry {
28
+ start: number;
29
+ end: number;
30
+ rows: RowGeometry[];
31
+ }
32
+
33
+ /**
34
+ * Structural map of a projection: which offset ranges belong to which OPC
35
+ * part, and where top-level table rows live. Produced in the SAME pass as
36
+ * the text, so offsets always agree with it. Consumed by the diff pipeline
37
+ * to keep generated edits from crossing part boundaries (QA 2026-07-18 C1)
38
+ * and to emit structured row operations for table changes (QA C2).
39
+ */
40
+ export interface ExtractStructure {
41
+ part_ranges: [number, number, string][]; // [start, end, kind]
42
+ tables: TableGeometry[];
43
+ }
44
+
19
45
  export async function extractTextFromBuffer(
20
46
  buffer: Buffer,
21
47
  cleanView = false,
48
+ includeAppendix = true,
22
49
  ): Promise<string> {
23
50
  const doc = await DocumentObject.load(buffer);
24
- return _extractTextFromDoc(doc, cleanView) as string;
51
+ return _extractTextFromDoc(doc, cleanView, includeAppendix) as string;
25
52
  }
26
53
 
27
54
  export function _extractTextFromDoc(
@@ -29,14 +56,21 @@ export function _extractTextFromDoc(
29
56
  cleanView = false,
30
57
  includeAppendix = true,
31
58
  return_paragraph_offsets = false,
32
- ): string | { text: string; paragraph_offsets: Map<any, [number, number]> } {
59
+ return_structure = false,
60
+ ):
61
+ | string
62
+ | { text: string; paragraph_offsets: Map<any, [number, number]> }
63
+ | { text: string; structure: ExtractStructure } {
33
64
  const comments_map = extract_comments_data(doc.pkg);
34
65
 
35
66
  const full_text: string[] = [];
36
67
  const paragraph_offsets = new Map<any, [number, number]>();
68
+ const structure: ExtractStructure | null = return_structure
69
+ ? { part_ranges: [], tables: [] }
70
+ : null;
37
71
  let cursor = 0;
38
72
 
39
- for (const part of iter_document_parts(doc)) {
73
+ for (const [part, part_kind] of iter_document_parts_with_kind(doc)) {
40
74
  const part_cursor = full_text.length > 0 ? cursor + 2 : cursor;
41
75
  const part_text = _extract_blocks(
42
76
  part,
@@ -44,10 +78,14 @@ export function _extractTextFromDoc(
44
78
  cleanView,
45
79
  part_cursor,
46
80
  return_paragraph_offsets ? paragraph_offsets : undefined,
81
+ structure ? structure.tables : undefined,
47
82
  );
48
83
  if (part_text) {
49
84
  if (full_text.length > 0) cursor += 2;
50
85
  full_text.push(part_text);
86
+ if (structure) {
87
+ structure.part_ranges.push([cursor, cursor + part_text.length, part_kind]);
88
+ }
51
89
  cursor += part_text.length;
52
90
  }
53
91
  }
@@ -62,15 +100,24 @@ export function _extractTextFromDoc(
62
100
  if (return_paragraph_offsets) {
63
101
  return { text: base_text, paragraph_offsets };
64
102
  }
103
+ if (structure) {
104
+ return { text: base_text, structure };
105
+ }
65
106
  return base_text;
66
107
  }
67
108
 
109
+ /**
110
+ * table_acc: optional list collecting TableGeometry for TOP-LEVEL tables.
111
+ * Deliberately not forwarded into cells — nested tables stay invisible to
112
+ * the structured row-op diff, whose row pairing assumes one flat grid.
113
+ */
68
114
  function _extract_blocks(
69
115
  container: any,
70
116
  comments_map: any,
71
117
  cleanView: boolean,
72
118
  cursor: number,
73
119
  paragraph_offsets?: Map<any, [number, number]>,
120
+ table_acc?: TableGeometry[],
74
121
  ): string {
75
122
  const part = container.part || container;
76
123
  const [style_cache, default_pstyle] = _get_style_cache(part);
@@ -129,17 +176,25 @@ function _extract_blocks(
129
176
  is_first_para = false;
130
177
  is_first_block = false;
131
178
  } else if (item instanceof Table) {
179
+ const geometry: TableGeometry | null = table_acc
180
+ ? { start: block_start, end: block_start, rows: [] }
181
+ : null;
132
182
  const table_text = extract_table(
133
183
  item,
134
184
  comments_map,
135
185
  cleanView,
136
186
  block_start,
137
187
  paragraph_offsets,
188
+ geometry,
138
189
  );
139
190
  if (table_text) {
140
191
  blocks.push(table_text);
141
192
  local_cursor = block_start + table_text.length;
142
193
  is_first_block = false;
194
+ if (geometry && table_acc) {
195
+ geometry.end = block_start + table_text.length;
196
+ table_acc.push(geometry);
197
+ }
143
198
  } else if (!is_first_block) {
144
199
  local_cursor -= 2;
145
200
  }
@@ -156,6 +211,7 @@ export function extract_table(
156
211
  cleanView: boolean,
157
212
  cursor: number,
158
213
  paragraph_offsets?: Map<any, [number, number]>,
214
+ geometry?: TableGeometry | null,
159
215
  ): string {
160
216
  const rows_text: string[] = [];
161
217
  let rows_processed = 0;
@@ -221,6 +277,13 @@ export function extract_table(
221
277
 
222
278
  rows_text.push(row_str);
223
279
  local_cursor = row_start + row_str.length;
280
+ if (geometry) {
281
+ geometry.rows.push({
282
+ start: row_start,
283
+ end: local_cursor,
284
+ cells: [...cell_texts],
285
+ });
286
+ }
224
287
  rows_processed++;
225
288
  }
226
289
 
@@ -424,7 +487,16 @@ export function build_paragraph_text(
424
487
  else if (ev.type === "del_end") delete active_del[ev.id];
425
488
  else if (ev.type === "fmt_start") active_fmt[ev.id] = ev;
426
489
  else if (ev.type === "fmt_end") delete active_fmt[ev.id];
427
- else if (ev.type === "footnote" || ev.type === "endnote") {
490
+ else if (ev.type === "image") {
491
+ // Read-only image marker (QA 2026-07-18 M5); hidden with its run in
492
+ // clean view when it sits inside an active tracked deletion.
493
+ if (!(cleanView && Object.keys(active_del).length > 0)) {
494
+ const alt = (ev.date || "image")
495
+ .replace(/\]/g, ")")
496
+ .replace(/\n/g, " ");
497
+ parts.push(`![${alt}](docx-image:${ev.id})`);
498
+ }
499
+ } else if (ev.type === "footnote" || ev.type === "endnote") {
428
500
  if (pending_text) {
429
501
  parts.push(
430
502
  `${current_wrappers[0]}${pending_text}${current_wrappers[1]}`,
package/src/mapper.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  is_heading_paragraph,
12
12
  is_native_heading,
13
13
  iter_block_items,
14
- iter_document_parts,
14
+ iter_document_parts_with_kind,
15
15
  iter_paragraph_content,
16
16
  } from "./utils/docx.js";
17
17
 
@@ -25,6 +25,12 @@ export interface TextSpan {
25
25
  del_id?: string | null;
26
26
  hyperlink_id?: string | null;
27
27
  comment_ids?: string[];
28
+ // Which OPC part (index into DocumentMapper.part_ranges) this span was
29
+ // projected from. Text edits may never resolve across two different
30
+ // parts — the QA 2026-07-18 C1 corruption wrote body text into a footer.
31
+ part_index: number;
32
+ // True for the read-only image marker projection ![alt](docx-image:N).
33
+ is_image_marker?: boolean;
28
34
  }
29
35
 
30
36
  export function renumber_snapshot_ids(
@@ -120,6 +126,11 @@ export class DocumentMapper {
120
126
  public full_text: string = "";
121
127
  public spans: TextSpan[] = [];
122
128
  public appendix_start_index: number = -1;
129
+ // [start, end, kind] per projected part, in projection order. Spans carry
130
+ // the matching index in .part_index. Together these let the engine refuse
131
+ // or re-anchor edits at OPC part boundaries (QA 2026-07-18 C1).
132
+ public part_ranges: [number, number, string][] = [];
133
+ private _current_part_index = 0;
123
134
  private _text_chunks: string[] = [];
124
135
  private _plain_projection: [string, number[]] | null = null;
125
136
 
@@ -141,9 +152,15 @@ export class DocumentMapper {
141
152
  this._text_chunks = [];
142
153
  this.full_text = "";
143
154
  this._plain_projection = null;
155
+ this.part_ranges = [];
156
+ this._current_part_index = 0;
144
157
 
145
- for (const part of iter_document_parts(this.doc)) {
158
+ let part_idx = 0;
159
+ for (const [part, part_kind] of iter_document_parts_with_kind(this.doc)) {
160
+ this._current_part_index = part_idx;
161
+ const part_start = current_offset;
146
162
  current_offset = this._map_blocks(part, current_offset);
163
+ this.part_ranges.push([part_start, current_offset, part_kind]);
147
164
 
148
165
  if (
149
166
  this.spans.length > 0 &&
@@ -152,6 +169,7 @@ export class DocumentMapper {
152
169
  this._add_virtual_text("\n\n", current_offset, null);
153
170
  current_offset += 2;
154
171
  }
172
+ part_idx++;
155
173
  }
156
174
 
157
175
  while (
@@ -166,6 +184,51 @@ export class DocumentMapper {
166
184
  this.appendix_start_index = -1;
167
185
  }
168
186
 
187
+ /** [part_index, start, end, kind] for parts that projected any text. */
188
+ private _nonempty_part_ranges(): [number, number, number, string][] {
189
+ const out: [number, number, number, string][] = [];
190
+ for (let i = 0; i < this.part_ranges.length; i++) {
191
+ const [s, e, k] = this.part_ranges[i];
192
+ if (e > s) out.push([i, s, e, k]);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ public part_kind_of(part_index: number): string | null {
198
+ if (part_index >= 0 && part_index < this.part_ranges.length) {
199
+ return this.part_ranges[part_index][2];
200
+ }
201
+ return null;
202
+ }
203
+
204
+ /** Kind of the part whose projected range contains `index`, or null. */
205
+ public part_kind_at(index: number): string | null {
206
+ for (const [, start, end, kind] of this._nonempty_part_ranges()) {
207
+ if (start <= index && index <= end) return kind;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * When `index` falls strictly AFTER one part's text and at-or-before the
214
+ * start of the next part's text (i.e. inside the "\n\n" separator or
215
+ * exactly at the next part's first character), returns
216
+ * [previous_part_index, next_part_index]. Returns null everywhere else —
217
+ * including index == previous part's end, which is an ordinary
218
+ * end-of-part text position, not a boundary gap.
219
+ */
220
+ public part_boundary_at(index: number): [number, number] | null {
221
+ const ranges = this._nonempty_part_ranges();
222
+ for (let j = 1; j < ranges.length; j++) {
223
+ const [prev_i, , prev_end] = ranges[j - 1];
224
+ const [next_i, next_start] = ranges[j];
225
+ if (prev_end < index && index <= next_start) {
226
+ return [prev_i, next_i];
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+
169
232
  private _map_blocks(container: any, offset: number): number {
170
233
  let current = offset;
171
234
  const c_type = container.constructor.name;
@@ -335,6 +398,7 @@ export class DocumentMapper {
335
398
  text: "",
336
399
  run: null,
337
400
  paragraph,
401
+ part_index: this._current_part_index,
338
402
  };
339
403
  this.spans.push(span);
340
404
 
@@ -377,6 +441,7 @@ export class DocumentMapper {
377
441
  del_id: d_id || undefined,
378
442
  hyperlink_id: active_hyperlink_id || undefined,
379
443
  comment_ids: c_ids.length > 0 ? c_ids : undefined,
444
+ part_index: this._current_part_index,
380
445
  };
381
446
  this.spans.push(s);
382
447
  this._text_chunks.push(txt);
@@ -609,7 +674,21 @@ export class DocumentMapper {
609
674
  else if (ev.type === "del_end") delete active_del[ev.id];
610
675
  else if (ev.type === "fmt_start") active_fmt[ev.id] = ev;
611
676
  else if (ev.type === "fmt_end") delete active_fmt[ev.id];
612
- else if (ev.type === "footnote" || ev.type === "endnote") {
677
+ else if (ev.type === "image") {
678
+ // Read-only image marker (QA 2026-07-18 M5). Hidden when the run is
679
+ // filtered by the active view, exactly like its neighbouring text.
680
+ const hidden =
681
+ (this.clean_view && Object.keys(active_del).length > 0) ||
682
+ (this.original_view && Object.keys(active_ins).length > 0);
683
+ if (!hidden) {
684
+ const alt = (ev.date || "image")
685
+ .replace(/\]/g, ")")
686
+ .replace(/\n/g, " ");
687
+ const txt = `![${alt}](docx-image:${ev.id})`;
688
+ this._add_virtual_text(txt, current, paragraph, null, true);
689
+ current += txt.length;
690
+ }
691
+ } else if (ev.type === "footnote" || ev.type === "endnote") {
613
692
  flush_pending_runs();
614
693
  current_wrappers = ["", ""];
615
694
  current_style = ["", ""];
@@ -743,6 +822,7 @@ export class DocumentMapper {
743
822
  offset: number,
744
823
  context_paragraph: Paragraph | null,
745
824
  hyperlink_id: string | null = null,
825
+ is_image_marker = false,
746
826
  ) {
747
827
  const span: TextSpan = {
748
828
  start: offset,
@@ -751,6 +831,8 @@ export class DocumentMapper {
751
831
  run: null,
752
832
  paragraph: context_paragraph,
753
833
  hyperlink_id: hyperlink_id || undefined,
834
+ part_index: this._current_part_index,
835
+ is_image_marker: is_image_marker || undefined,
754
836
  };
755
837
  this.spans.push(span);
756
838
  this._text_chunks.push(text);
package/src/markup.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import { trim_common_context } from "./diff.js";
2
2
  import { ModifyText } from "./models.js";
3
+ import { RegexTimeoutError, userFindAllMatches } from "./utils/safe-regex.js";
4
+
5
+ /** One entry per input edit in apply_edits_to_markdown's edit_reports. */
6
+ export interface MarkupEditReport {
7
+ index: number;
8
+ status: "applied" | "failed";
9
+ error: string | null;
10
+ occurrences: number;
11
+ }
3
12
  export const AMBIGUITY_EXAMPLES_CAP = 5;
4
13
  export const AMBIGUITY_CONTEXT_CHARS = 50;
5
14
  function _should_strip_markers(text: string, marker: string): boolean {
@@ -51,6 +60,45 @@ export function _replace_smart_quotes(text: string): string {
51
60
  .replace(/’/g, "'");
52
61
  }
53
62
 
63
+ /**
64
+ * Strips markdown formatting markers and builds a position map.
65
+ * Returns [stripped_text, position_map] where position_map[i] = original
66
+ * index. Mirrors Python markup._strip_markdown_for_matching.
67
+ */
68
+ function _strip_markdown_for_matching(text: string): [string, number[]] {
69
+ const result: string[] = [];
70
+ const position_map: number[] = [];
71
+ let i = 0;
72
+
73
+ while (i < text.length) {
74
+ // Skip ** or __
75
+ const pair = text.substring(i, i + 2);
76
+ if (i < text.length - 1 && (pair === "**" || pair === "__")) {
77
+ i += 2;
78
+ continue;
79
+ }
80
+ // Skip single * or _ that look like markdown (at word boundaries)
81
+ if (text[i] === "*" || text[i] === "_") {
82
+ const prev_char = i > 0 ? text[i - 1] : " ";
83
+ const next_char = i < text.length - 1 ? text[i + 1] : " ";
84
+ // If at boundary (space or start/end), likely markdown
85
+ if (
86
+ [" ", "\n", "\t"].includes(prev_char) ||
87
+ [" ", "\n", "\t"].includes(next_char)
88
+ ) {
89
+ i += 1;
90
+ continue;
91
+ }
92
+ }
93
+
94
+ position_map.push(i);
95
+ result.push(text[i]);
96
+ i += 1;
97
+ }
98
+
99
+ return [result.join(""), position_map];
100
+ }
101
+
54
102
  function _find_safe_boundaries(
55
103
  text: string,
56
104
  start: number,
@@ -198,35 +246,107 @@ export function _find_match_in_text(
198
246
  text: string,
199
247
  target: string,
200
248
  ): [number, number] {
201
- if (!target) return [-1, -1];
249
+ const matches = _find_all_matches_in_text(text, target);
250
+ if (matches.length > 0) return matches[0];
251
+ return [-1, -1];
252
+ }
253
+
254
+ /**
255
+ * Every non-overlapping match of `target` in `text` as [start, end] pairs,
256
+ * using the SAME strategy ladder as the apply engine's
257
+ * DocumentMapper.find_all_match_indices: regex (when requested) or
258
+ * exact → smart-quote-normalized → fuzzy. Markup previews must resolve
259
+ * matching identically to apply, or the preview lies (QA 2026-07-18 M1).
260
+ */
261
+ export function _find_all_matches_in_text(
262
+ text: string,
263
+ target: string,
264
+ is_regex = false,
265
+ ): [number, number][] {
266
+ if (!target) return [];
267
+
268
+ if (is_regex) {
269
+ // Same semantics as the mapper: budgeted user regex; an invalid pattern
270
+ // simply produces no matches (surfaced as "not found").
271
+ // RegexTimeoutError propagates for a clean per-edit error.
272
+ try {
273
+ return userFindAllMatches(target, text).map((m) => [m.start, m.end]);
274
+ } catch (e) {
275
+ if (e instanceof RegexTimeoutError) throw e;
276
+ return [];
277
+ }
278
+ }
279
+
280
+ // Literal indexOf scans (no RegExp over an arbitrarily long escaped
281
+ // target, mirroring the mapper's exact tiers).
282
+ const findAllLiteral = (
283
+ haystack: string,
284
+ needle: string,
285
+ ): [number, number][] => {
286
+ const out: [number, number][] = [];
287
+ let from = 0;
288
+ while (true) {
289
+ const idx = haystack.indexOf(needle, from);
290
+ if (idx === -1) break;
291
+ out.push([idx, idx + needle.length]);
292
+ from = idx + needle.length;
293
+ }
294
+ return out;
295
+ };
202
296
 
203
- let idx = text.indexOf(target);
204
- if (idx !== -1) return _find_safe_boundaries(text, idx, idx + target.length);
297
+ // 1. Exact matches
298
+ let spans = findAllLiteral(text, target);
299
+ if (spans.length > 0) {
300
+ return spans.map(([s, e]) => _find_safe_boundaries(text, s, e));
301
+ }
205
302
 
303
+ // 2. Smart quote normalization
206
304
  const norm_text = _replace_smart_quotes(text);
207
305
  const norm_target = _replace_smart_quotes(target);
208
- idx = norm_text.indexOf(norm_target);
209
- if (idx !== -1)
210
- return _find_safe_boundaries(text, idx, idx + norm_target.length);
306
+ spans = findAllLiteral(norm_text, norm_target);
307
+ if (spans.length > 0) {
308
+ return spans.map(([s, e]) => _find_safe_boundaries(text, s, e));
309
+ }
310
+
311
+ // 3. Markdown-stripped match, mirroring the mapper's strip-markdown and
312
+ // plain-projection rungs: a plain target must find text whose projection
313
+ // carries **bold**/_italic_ markers (even mid-word), and a marked target
314
+ // must find plain text.
315
+ const [stripped_text, pos_map] = _strip_markdown_for_matching(norm_text);
316
+ const [stripped_target] = _strip_markdown_for_matching(norm_target);
317
+ if (
318
+ stripped_target &&
319
+ (stripped_text !== norm_text || stripped_target !== norm_target)
320
+ ) {
321
+ const results: [number, number][] = [];
322
+ for (const [p_start, p_end] of findAllLiteral(stripped_text, stripped_target)) {
323
+ const raw_start = pos_map[p_start];
324
+ const raw_end = pos_map[p_end - 1] + 1;
325
+ results.push(_find_safe_boundaries(text, raw_start, raw_end));
326
+ }
327
+ if (results.length > 0) return results;
328
+ }
211
329
 
330
+ // 4. Fuzzy regex matches (handles markdown noise, list markers, etc.).
331
+ // The [*_]* character classes in _make_fuzzy_regex prevent catastrophic
332
+ // backtracking.
212
333
  try {
213
- const pattern = new RegExp(_make_fuzzy_regex(target));
214
- const match = pattern.exec(text);
215
- if (match) {
216
- const raw_start = match.index;
217
- const raw_end = match.index + match[0].length;
334
+ const pattern = new RegExp(_make_fuzzy_regex(target), "g");
335
+ const results: [number, number][] = [];
336
+ for (const match of text.matchAll(pattern)) {
218
337
  const [refined_start, refined_end] = _refine_match_boundaries(
219
338
  text,
220
- raw_start,
221
- raw_end,
339
+ match.index!,
340
+ match.index! + match[0].length,
222
341
  );
223
- return _find_safe_boundaries(text, refined_start, refined_end);
342
+ results.push(_find_safe_boundaries(text, refined_start, refined_end));
224
343
  }
344
+ if (results.length > 0) return results;
225
345
  } catch (e) {
226
346
  // Ignore regex compilation errors from edge cases
227
347
  }
228
348
 
229
- return [-1, -1];
349
+ return [];
230
350
  }
231
351
 
232
352
  export function _build_critic_markup(
@@ -283,31 +403,93 @@ export function _build_critic_markup(
283
403
  return parts.join("");
284
404
  }
285
405
 
406
+ /**
407
+ * Applies edits to Markdown text and returns CriticMarkup-annotated output.
408
+ *
409
+ * Edit resolution follows the SAME semantics as `apply` (QA 2026-07-18 M1):
410
+ * - `regex: true` targets match as regular expressions
411
+ * - `match_mode` is honored: "strict" (default) refuses ambiguous targets,
412
+ * "first" marks the first occurrence, "all" marks every occurrence
413
+ * - a missing target is a per-edit failure, never a silent skip
414
+ *
415
+ * When `edit_reports` is provided, one entry per input edit is appended:
416
+ * { index: 0-based input position, status: "applied"|"failed",
417
+ * error: string|null, occurrences: number }.
418
+ */
286
419
  export function apply_edits_to_markdown(
287
420
  markdown_text: string,
288
421
  edits: ModifyText[],
289
422
  include_index = false,
290
423
  highlight_only = false,
424
+ edit_reports?: MarkupEditReport[],
291
425
  ): string {
292
426
  if (!edits || edits.length === 0) return markdown_text;
293
427
 
428
+ const _report = (
429
+ idx: number,
430
+ status: "applied" | "failed",
431
+ error: string | null = null,
432
+ occurrences = 0,
433
+ ) => {
434
+ if (edit_reports) edit_reports.push({ index: idx, status, error, occurrences });
435
+ };
436
+
437
+ // Step 1: Find match positions for each edit
294
438
  const matched_edits: [number, number, string, ModifyText, number][] = [];
295
439
 
296
440
  for (let idx = 0; idx < edits.length; idx++) {
297
441
  const edit = edits[idx];
298
442
  const target = edit.target_text || "";
443
+ const match_mode = (edit as any).match_mode || "strict";
444
+ const is_regex = Boolean((edit as any).regex);
299
445
 
300
446
  if (!target) {
447
+ _report(
448
+ idx,
449
+ "failed",
450
+ `- Edit ${idx + 1} Failed: target_text is empty. Pure insertions are expressed as a ` +
451
+ "replacement: put the text immediately around the insertion point in target_text and " +
452
+ "repeat it (plus the new text) in new_text.",
453
+ );
454
+ continue;
455
+ }
456
+
457
+ let spans: [number, number][];
458
+ try {
459
+ spans = _find_all_matches_in_text(markdown_text, target, is_regex);
460
+ } catch (e) {
461
+ if (!(e instanceof RegexTimeoutError)) throw e;
462
+ _report(idx, "failed", `- Edit ${idx + 1} Failed: ${e.message}`);
463
+ continue;
464
+ }
465
+
466
+ if (spans.length === 0) {
467
+ _report(
468
+ idx,
469
+ "failed",
470
+ `- Edit ${idx + 1} Failed: Target text not found in document:\n "${target.substring(0, 80)}"`,
471
+ );
301
472
  continue;
302
473
  }
303
474
 
304
- const [start, end] = _find_match_in_text(markdown_text, target);
305
- if (start === -1) continue;
475
+ if (spans.length > 1 && match_mode === "strict") {
476
+ _report(
477
+ idx,
478
+ "failed",
479
+ format_ambiguity_error(idx + 1, target, markdown_text, spans),
480
+ );
481
+ continue;
482
+ }
306
483
 
307
- const actual_matched_text = markdown_text.substring(start, end);
308
- matched_edits.push([start, end, actual_matched_text, edit, idx]);
484
+ const selected =
485
+ match_mode === "strict" || match_mode === "first" ? spans.slice(0, 1) : spans;
486
+ for (const [start, end] of selected) {
487
+ matched_edits.push([start, end, markdown_text.substring(start, end), edit, idx]);
488
+ }
489
+ _report(idx, "applied", null, selected.length);
309
490
  }
310
491
 
492
+ // Step 2: Check for overlapping edits
311
493
  const matched_edits_filtered: [number, number, string, ModifyText, number][] =
312
494
  [];
313
495
  const occupied_ranges: [number, number][] = [];
@@ -319,6 +501,16 @@ export function apply_edits_to_markdown(
319
501
  for (const [occ_start, occ_end] of occupied_ranges) {
320
502
  if (start < occ_end && end > occ_start) {
321
503
  overlaps = true;
504
+ if (edit_reports) {
505
+ const msg = `- Edit ${orig_idx + 1} Failed: overlaps with a previously matched edit.`;
506
+ for (const r of edit_reports) {
507
+ if (r.index === orig_idx) {
508
+ r.status = "failed";
509
+ r.error = msg;
510
+ r.occurrences = 0;
511
+ }
512
+ }
513
+ }
322
514
  break;
323
515
  }
324
516
  }