@adeu/core 1.13.0 → 1.15.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
@@ -13,6 +13,7 @@ import {
13
13
  } from "./models.js";
14
14
  import { trim_common_context } from "./diff.js";
15
15
  import { findChild, findAllDescendants, serializeXml } from "./docx/dom.js";
16
+ import { split_structural_appendix, paginate } from "./pagination.js";
16
17
  import {
17
18
  is_heading_paragraph,
18
19
  is_native_heading,
@@ -279,7 +280,8 @@ export class RedlineEngine {
279
280
  start_idx + length + 30,
280
281
  );
281
282
 
282
- const critic_markup = `${context_before}{--${target_text}--}{++${new_text}++}${context_after}`;
283
+ const insertion = new_text ? `{++${new_text}++}` : "";
284
+ const critic_markup = `${context_before}{--${target_text}--}${insertion}${context_after}`;
283
285
 
284
286
  let clean_text = critic_markup;
285
287
  clean_text = clean_text.replace(/\{>>.*?<<\}/gs, "");
@@ -301,6 +303,40 @@ export class RedlineEngine {
301
303
  return maxId;
302
304
  }
303
305
 
306
+ private _get_heading_path_and_page(start_idx: number, text: string, page_offsets: number[]): [string, number] {
307
+ let page = 1;
308
+ for (let i = 0; i < page_offsets.length; i++) {
309
+ if (start_idx >= page_offsets[i]) {
310
+ page = i + 1;
311
+ } else {
312
+ break;
313
+ }
314
+ }
315
+
316
+ const textBefore = text.substring(0, start_idx);
317
+ const lines = textBefore.split("\n");
318
+ const path: string[] = [];
319
+ let current_level = 999;
320
+
321
+ for (let i = lines.length - 1; i >= 0; i--) {
322
+ const line = lines[i];
323
+ const m = line.match(/^(#{1,6})\s+(.*)/);
324
+ if (m) {
325
+ const level = m[1].length;
326
+ if (level < current_level) {
327
+ let cleanHeading = m[2].replace(/\*\*|__|[*_]/g, "").replace(/\{#[^}]+\}/g, "").trim();
328
+ if (cleanHeading.length > 80) {
329
+ cleanHeading = cleanHeading.substring(0, 80) + "...";
330
+ }
331
+ path.unshift(cleanHeading);
332
+ current_level = level;
333
+ if (level === 1) break;
334
+ }
335
+ }
336
+ }
337
+ return [path.join(" > "), page];
338
+ }
339
+
304
340
  public accept_all_revisions() {
305
341
  const parts_to_process: Element[] = [this.doc.element];
306
342
 
@@ -1193,15 +1229,18 @@ export class RedlineEngine {
1193
1229
  for (let i = 0; i < edits.length; i++) {
1194
1230
  const edit = edits[i];
1195
1231
  if (!edit.target_text) continue;
1232
+
1233
+ const is_regex = (edit as any).regex || false;
1234
+ const match_mode = (edit as any).match_mode || "strict";
1196
1235
 
1197
- let matches = this.mapper.find_all_match_indices(edit.target_text);
1236
+ let matches = this.mapper.find_all_match_indices(edit.target_text, is_regex);
1198
1237
  let activeText = this.mapper.full_text;
1199
1238
  let target_mapper = this.mapper;
1200
1239
 
1201
1240
  if (matches.length === 0) {
1202
1241
  if (!this.clean_mapper)
1203
1242
  this.clean_mapper = new DocumentMapper(this.doc, true);
1204
- matches = this.clean_mapper.find_all_match_indices(edit.target_text);
1243
+ matches = this.clean_mapper.find_all_match_indices(edit.target_text, is_regex);
1205
1244
  if (matches.length > 0) {
1206
1245
  activeText = this.clean_mapper.full_text;
1207
1246
  target_mapper = this.clean_mapper;
@@ -1233,7 +1272,7 @@ export class RedlineEngine {
1233
1272
  if (!this.original_mapper) {
1234
1273
  this.original_mapper = new DocumentMapper(this.doc, false, true);
1235
1274
  }
1236
- const orig_matches = this.original_mapper.find_all_match_indices(edit.target_text);
1275
+ const orig_matches = this.original_mapper.find_all_match_indices(edit.target_text, is_regex);
1237
1276
  if (orig_matches.length > 0) {
1238
1277
  is_deleted_text = true;
1239
1278
  for (const [start, length] of orig_matches) {
@@ -1272,7 +1311,7 @@ export class RedlineEngine {
1272
1311
  `- Edit ${i + 1} Failed: Target text not found in document:\n "${edit.target_text}"`,
1273
1312
  );
1274
1313
  }
1275
- } else if (matches.length > 1) {
1314
+ } else if (matches.length > 1 && match_mode === "strict") {
1276
1315
  const positions: [number, number][] = matches.map(([start, length]) => [
1277
1316
  start,
1278
1317
  start + length,
@@ -1343,6 +1382,14 @@ export class RedlineEngine {
1343
1382
  if (auth && auth !== this.author) nestedAuthors.add(auth);
1344
1383
  }
1345
1384
  }
1385
+ if (s.comment_ids) {
1386
+ for (const cid of s.comment_ids) {
1387
+ const c_data = this.mapper.comments_map[cid];
1388
+ if (c_data && c_data.author && c_data.author !== this.author) {
1389
+ nestedAuthors.add(c_data.author);
1390
+ }
1391
+ }
1392
+ }
1346
1393
  }
1347
1394
  if (nestedAuthors.size > 0) {
1348
1395
  errors.push(
@@ -1399,6 +1446,34 @@ export class RedlineEngine {
1399
1446
  changes: DocumentChange[],
1400
1447
  dry_run: boolean = false,
1401
1448
  ): any {
1449
+ // Defensive sanitization: some LLM clients "double-serialize" nested
1450
+ // arrays, delivering each element of `changes` as a JSON string instead of
1451
+ // a parsed object. Downstream code mutates state trackers (e.g.
1452
+ // `edit._applied_status`) and reads `change.type` on these elements, which
1453
+ // throws a TypeError on string primitives. Parse stringified elements back
1454
+ // into objects here, leaving genuine objects (and unparseable strings)
1455
+ // untouched so validation can surface a clear error rather than crashing.
1456
+ if (Array.isArray(changes)) {
1457
+ changes = changes.map((item: any) => {
1458
+ if (typeof item === "string") {
1459
+ try {
1460
+ const parsed = JSON.parse(item);
1461
+ // Only swap in the parsed value if it is an object; a string that
1462
+ // parses to a scalar (e.g. "42") is not a valid change.
1463
+ if (parsed !== null && typeof parsed === "object") {
1464
+ return parsed;
1465
+ }
1466
+ return item;
1467
+ } catch {
1468
+ // Leave malformed strings as-is; the validation pass downstream
1469
+ // will report them rather than crashing on a raw TypeError.
1470
+ return item;
1471
+ }
1472
+ }
1473
+ return item;
1474
+ }) as DocumentChange[];
1475
+ }
1476
+
1402
1477
  if (dry_run) {
1403
1478
  const baselines = new Map<any, Element>();
1404
1479
  for (const part of this.doc.pkg.parts) {
@@ -1473,6 +1548,10 @@ export class RedlineEngine {
1473
1548
  }
1474
1549
  }
1475
1550
 
1551
+ const [body_text] = split_structural_appendix(this.mapper.full_text);
1552
+ const pag_res = paginate(body_text, "");
1553
+ const page_offsets = pag_res.body_page_offsets;
1554
+
1476
1555
  const edits_reports: any[] = [];
1477
1556
  let applied_edits = 0;
1478
1557
  let skipped_edits = 0;
@@ -1497,9 +1576,8 @@ export class RedlineEngine {
1497
1576
  });
1498
1577
  continue;
1499
1578
  }
1500
- const res = this.apply_edits([edit]);
1501
- const applied = res[0];
1502
- if (applied > 0) {
1579
+ const res = this.apply_edits([edit], page_offsets);
1580
+ if ((edit as any)._applied_status) {
1503
1581
  applied_edits++;
1504
1582
  const previews = this._build_edit_context_previews(edit);
1505
1583
  edits_reports.push({
@@ -1510,6 +1588,10 @@ export class RedlineEngine {
1510
1588
  error: null,
1511
1589
  critic_markup: previews[0],
1512
1590
  clean_text: previews[1],
1591
+ pages: (edit as any)._pages || [],
1592
+ heading_path: (edit as any)._heading_path || "",
1593
+ occurrences_modified: (edit as any)._occurrences_modified || 0,
1594
+ match_mode: (edit as any).match_mode || "strict",
1513
1595
  });
1514
1596
  } else {
1515
1597
  skipped_edits++;
@@ -1534,9 +1616,9 @@ export class RedlineEngine {
1534
1616
  throw new BatchValidationError(errors);
1535
1617
  }
1536
1618
  const cloned_edits = edits.map((e) => JSON.parse(JSON.stringify(e)));
1537
- const res = this.apply_edits(cloned_edits);
1538
- applied_edits = res[0];
1539
- skipped_edits = res[1];
1619
+ const res = this.apply_edits(cloned_edits, page_offsets);
1620
+ applied_edits = cloned_edits.filter((e) => (e as any)._applied_status).length;
1621
+ skipped_edits = cloned_edits.length - applied_edits;
1540
1622
 
1541
1623
  for (const edit of cloned_edits) {
1542
1624
  const success = (edit as any)._applied_status || false;
@@ -1559,6 +1641,10 @@ export class RedlineEngine {
1559
1641
  error: error_msg,
1560
1642
  critic_markup: critic_markup,
1561
1643
  clean_text: clean_text,
1644
+ pages: (edit as any)._pages || [],
1645
+ heading_path: (edit as any)._heading_path || "",
1646
+ occurrences_modified: (edit as any)._occurrences_modified || 0,
1647
+ match_mode: (edit as any).match_mode || "strict",
1562
1648
  });
1563
1649
  }
1564
1650
  }
@@ -1576,9 +1662,14 @@ export class RedlineEngine {
1576
1662
  };
1577
1663
  }
1578
1664
 
1579
- public apply_edits(edits: any[]): [number, number] {
1665
+ public apply_edits(edits: any[], page_offsets: number[] = []): [number, number] {
1580
1666
  let applied = 0;
1581
1667
  let skipped = 0;
1668
+
1669
+ if (!page_offsets || page_offsets.length === 0) {
1670
+ const [body_text] = split_structural_appendix(this.mapper.full_text);
1671
+ page_offsets = paginate(body_text, "").body_page_offsets;
1672
+ }
1582
1673
  const resolved_edits: [any, string | null][] = [];
1583
1674
 
1584
1675
  for (const edit of edits) {
@@ -1700,6 +1791,19 @@ export class RedlineEngine {
1700
1791
  const parent = edit._parent_edit_ref;
1701
1792
  if (parent) {
1702
1793
  parent._applied_status = true;
1794
+ parent._occurrences_modified = (parent._occurrences_modified || 0) + 1;
1795
+ const [path, page] = this._get_heading_path_and_page(start, this.mapper.full_text, page_offsets);
1796
+ const pages: number[] = parent._pages || [];
1797
+ if (!pages.includes(page)) pages.unshift(page);
1798
+ parent._pages = pages;
1799
+ parent._heading_path = path;
1800
+ } else {
1801
+ edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
1802
+ const [path, page] = this._get_heading_path_and_page(start, this.mapper.full_text, page_offsets);
1803
+ const pages: number[] = edit._pages || [];
1804
+ if (!pages.includes(page)) pages.unshift(page);
1805
+ edit._pages = pages;
1806
+ edit._heading_path = path;
1703
1807
  }
1704
1808
  } else {
1705
1809
  skipped++;
@@ -1871,103 +1975,138 @@ export class RedlineEngine {
1871
1975
  return false;
1872
1976
  }
1873
1977
 
1874
- /**
1875
- * Returns the first match of `target_text` in the raw mapper that is NOT
1876
- * entirely contained within a tracked deletion (<w:del>). Tracked-deleted
1877
- * copies are not live, editable text, so an edit must resolve to a live
1878
- * occurrence even when a dead copy appears earlier in the document
1879
- * (BUG-23-5). Falls back to the plain first match when no live copy is
1880
- * found (e.g. fuzzy/normalized matches the span filter cannot align).
1881
- */
1882
- private _first_live_match(target_text: string): [number, number] {
1883
- const all = this.mapper.find_all_match_indices(target_text);
1884
- if (all.length <= 1) {
1885
- return this.mapper.find_match_index(target_text);
1886
- }
1887
- for (const [start, length] of all) {
1888
- const realSpans = this.mapper.spans.filter(
1889
- (s) => s.run !== null && s.end > start && s.start < start + length,
1890
- );
1891
- if (realSpans.length === 0) return [start, length];
1892
- if (realSpans.some((s) => !s.del_id)) return [start, length];
1893
- }
1894
- return this.mapper.find_match_index(target_text);
1895
- }
1896
-
1897
1978
  private _pre_resolve_heuristic_edit(edit: any): any {
1898
1979
  if (!edit.target_text) return null;
1899
1980
 
1900
- let [start_idx, match_len] = this._first_live_match(edit.target_text);
1981
+ const is_regex = edit.regex || false;
1982
+ const match_mode = edit.match_mode || "strict";
1983
+
1984
+ let matches = this.mapper.find_all_match_indices(edit.target_text, is_regex);
1901
1985
  let use_clean_map = false;
1902
1986
 
1903
- if (start_idx === -1) {
1987
+ if (matches.length === 0) {
1904
1988
  if (!this.clean_mapper)
1905
1989
  this.clean_mapper = new DocumentMapper(this.doc, true);
1906
- [start_idx, match_len] = this.clean_mapper.find_match_index(
1907
- edit.target_text,
1908
- );
1909
- if (start_idx !== -1) use_clean_map = true;
1990
+ matches = this.clean_mapper.find_all_match_indices(edit.target_text, is_regex);
1991
+ if (matches.length > 0) use_clean_map = true;
1910
1992
  else return null;
1911
1993
  }
1912
1994
 
1913
1995
  const active_mapper = use_clean_map ? this.clean_mapper! : this.mapper;
1914
- const effective_new_text = edit.new_text || "";
1915
- const actual_doc_text = this.mapper.full_text.substring(
1916
- start_idx,
1917
- start_idx + match_len,
1918
- );
1919
1996
 
1920
- if (
1921
- actual_doc_text === effective_new_text ||
1922
- edit.target_text === effective_new_text
1923
- ) {
1924
- return {
1925
- type: "modify",
1926
- target_text: actual_doc_text,
1927
- new_text: actual_doc_text,
1928
- comment: edit.comment,
1929
- _match_start_index: start_idx,
1930
- _internal_op: "COMMENT_ONLY",
1931
- _active_mapper_ref: active_mapper,
1932
- };
1997
+ let live_matches: [number, number][] = [];
1998
+ for (const [s, match_len] of matches) {
1999
+ const realSpans = active_mapper.spans.filter(
2000
+ (span) => span.run !== null && span.end > s && span.start < s + match_len
2001
+ );
2002
+ if (realSpans.length === 0 || realSpans.some((span) => !span.del_id)) {
2003
+ live_matches.push([s, match_len]);
2004
+ }
1933
2005
  }
1934
2006
 
1935
- let effective_op = "";
1936
- let final_target = "";
1937
- let final_new = "";
1938
- let effective_start_idx = start_idx;
2007
+ if (live_matches.length === 0) return null;
1939
2008
 
1940
- if (effective_new_text.startsWith(actual_doc_text)) {
1941
- effective_op = "INSERTION";
1942
- final_new = effective_new_text.substring(actual_doc_text.length);
1943
- effective_start_idx = start_idx + match_len;
1944
- } else {
1945
- const [prefix_len, suffix_len] = trim_common_context(
1946
- actual_doc_text,
1947
- effective_new_text,
2009
+ if (match_mode === "strict" || match_mode === "first") {
2010
+ live_matches = live_matches.slice(0, 1);
2011
+ }
2012
+
2013
+ const all_sub_edits: any[] = [];
2014
+
2015
+ for (const [start_idx, match_len] of live_matches) {
2016
+ const actual_doc_text = active_mapper.full_text.substring(
2017
+ start_idx,
2018
+ start_idx + match_len,
1948
2019
  );
1949
- const t_end = actual_doc_text.length - suffix_len;
1950
- const n_end = effective_new_text.length - suffix_len;
2020
+ let current_effective_new_text = edit.new_text || "";
2021
+
2022
+ if (is_regex && current_effective_new_text) {
2023
+ try {
2024
+ current_effective_new_text = actual_doc_text.replace(new RegExp(edit.target_text), current_effective_new_text);
2025
+ } catch (e) {}
2026
+ }
2027
+
2028
+ const [edit_target_clean, edit_target_style] = this._parse_markdown_style(edit.target_text);
2029
+ const [edit_new_clean, edit_new_style] = this._parse_markdown_style(current_effective_new_text);
2030
+
2031
+ if (edit_target_style !== edit_new_style) {
2032
+ const [actual_clean] = this._parse_markdown_style(actual_doc_text);
2033
+ const final_target = actual_clean;
2034
+ const final_new = edit_new_clean;
2035
+ const style_op = final_target === final_new ? "STYLE_ONLY" : "STYLE_AND_TEXT";
2036
+ const prefix_offset = actual_doc_text.indexOf(actual_clean);
2037
+ const effective_start_idx = start_idx + (prefix_offset !== -1 ? prefix_offset : 0);
2038
+ const resolved_style = edit_new_style !== null ? edit_new_style : "Normal";
2039
+
2040
+ all_sub_edits.push({
2041
+ type: "modify",
2042
+ target_text: final_target,
2043
+ new_text: final_new,
2044
+ comment: edit.comment,
2045
+ _match_start_index: effective_start_idx,
2046
+ _internal_op: style_op,
2047
+ _new_style: resolved_style,
2048
+ _active_mapper_ref: active_mapper,
2049
+ });
2050
+ continue;
2051
+ }
2052
+
2053
+ if (
2054
+ actual_doc_text === current_effective_new_text ||
2055
+ edit.target_text === current_effective_new_text
2056
+ ) {
2057
+ all_sub_edits.push({
2058
+ type: "modify",
2059
+ target_text: actual_doc_text,
2060
+ new_text: actual_doc_text,
2061
+ comment: edit.comment,
2062
+ _match_start_index: start_idx,
2063
+ _internal_op: "COMMENT_ONLY",
2064
+ _active_mapper_ref: active_mapper,
2065
+ });
2066
+ continue;
2067
+ }
1951
2068
 
1952
- final_target = actual_doc_text.substring(prefix_len, t_end);
1953
- final_new = effective_new_text.substring(prefix_len, n_end);
1954
- effective_start_idx = start_idx + prefix_len;
2069
+ let effective_op = "";
2070
+ let final_target = "";
2071
+ let final_new = "";
2072
+ let effective_start_idx = start_idx;
1955
2073
 
1956
- if (!final_target && final_new) effective_op = "INSERTION";
1957
- else if (final_target && !final_new) effective_op = "DELETION";
1958
- else if (final_target && final_new) effective_op = "MODIFICATION";
1959
- else effective_op = "COMMENT_ONLY";
2074
+ if (current_effective_new_text.startsWith(actual_doc_text)) {
2075
+ effective_op = "INSERTION";
2076
+ final_new = current_effective_new_text.substring(actual_doc_text.length);
2077
+ effective_start_idx = start_idx + match_len;
2078
+ } else {
2079
+ const [prefix_len, suffix_len] = trim_common_context(
2080
+ actual_doc_text,
2081
+ current_effective_new_text,
2082
+ );
2083
+ const t_end = actual_doc_text.length - suffix_len;
2084
+ const n_end = current_effective_new_text.length - suffix_len;
2085
+
2086
+ final_target = actual_doc_text.substring(prefix_len, t_end);
2087
+ final_new = current_effective_new_text.substring(prefix_len, n_end);
2088
+ effective_start_idx = start_idx + prefix_len;
2089
+
2090
+ if (!final_target && final_new) effective_op = "INSERTION";
2091
+ else if (final_target && !final_new) effective_op = "DELETION";
2092
+ else if (final_target && final_new) effective_op = "MODIFICATION";
2093
+ else effective_op = "COMMENT_ONLY";
2094
+ }
2095
+
2096
+ all_sub_edits.push({
2097
+ type: "modify",
2098
+ target_text: final_target,
2099
+ new_text: final_new,
2100
+ comment: edit.comment,
2101
+ _match_start_index: effective_start_idx,
2102
+ _internal_op: effective_op,
2103
+ _active_mapper_ref: active_mapper,
2104
+ });
1960
2105
  }
1961
2106
 
1962
- return {
1963
- type: "modify",
1964
- target_text: final_target,
1965
- new_text: final_new,
1966
- comment: edit.comment,
1967
- _match_start_index: effective_start_idx,
1968
- _internal_op: effective_op,
1969
- _active_mapper_ref: active_mapper,
1970
- };
2107
+ if (all_sub_edits.length === 0) return null;
2108
+ if (match_mode === "all" || all_sub_edits.length > 1) return all_sub_edits;
2109
+ return all_sub_edits[0];
1971
2110
  }
1972
2111
 
1973
2112
  private _apply_single_edit_indexed(
@@ -1984,6 +2123,80 @@ export class RedlineEngine {
1984
2123
  : edit._match_start_index || 0;
1985
2124
  const length = edit.target_text ? edit.target_text.length : 0;
1986
2125
 
2126
+ if (op === "STYLE_ONLY" || op === "STYLE_AND_TEXT") {
2127
+ const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
2128
+ start_idx,
2129
+ rebuild_map,
2130
+ );
2131
+ let target_para_el: Element | null = null;
2132
+ if (anchor_para) {
2133
+ target_para_el = anchor_para._element;
2134
+ } else if (anchor_run) {
2135
+ let walker: Element | null = anchor_run._element;
2136
+ while (walker && walker.tagName !== "w:p") {
2137
+ walker = walker.parentNode as Element | null;
2138
+ }
2139
+ target_para_el = walker;
2140
+ }
2141
+
2142
+ if (target_para_el && edit._new_style) {
2143
+ this._set_paragraph_style(target_para_el, edit._new_style);
2144
+ }
2145
+
2146
+ if (op === "STYLE_ONLY") {
2147
+ if (edit.comment) {
2148
+ const target_runs = active_mapper.find_target_runs_by_index(
2149
+ start_idx,
2150
+ length,
2151
+ rebuild_map,
2152
+ );
2153
+ if (target_runs.length > 0) {
2154
+ const first_el = target_runs[0]._element;
2155
+ const last_el = target_runs[target_runs.length - 1]._element;
2156
+ let start_p: Element | null = first_el;
2157
+ while (start_p && start_p.tagName !== "w:p")
2158
+ start_p = start_p.parentNode as Element;
2159
+ let end_p: Element | null = last_el;
2160
+ while (end_p && end_p.tagName !== "w:p")
2161
+ end_p = end_p.parentNode as Element;
2162
+ if (start_p && end_p) {
2163
+ const ascend_to_paragraph_child = (el: Element, p: Element): Element => {
2164
+ let cur: Element = el;
2165
+ while (cur.parentNode && cur.parentNode !== p) {
2166
+ cur = cur.parentNode as Element;
2167
+ }
2168
+ return cur;
2169
+ };
2170
+ const first_anchor = ascend_to_paragraph_child(first_el, start_p);
2171
+ const last_anchor = ascend_to_paragraph_child(last_el, end_p);
2172
+ if (start_p === end_p) {
2173
+ this._attach_comment(start_p, first_anchor, last_anchor, edit.comment);
2174
+ } else {
2175
+ this._attach_comment_spanning(
2176
+ start_p,
2177
+ first_anchor,
2178
+ end_p,
2179
+ last_anchor,
2180
+ edit.comment,
2181
+ );
2182
+ }
2183
+ }
2184
+ }
2185
+ }
2186
+ return true;
2187
+ }
2188
+
2189
+ if (edit.target_text && edit.new_text) {
2190
+ op = "MODIFICATION";
2191
+ } else if (!edit.target_text && edit.new_text) {
2192
+ op = "INSERTION";
2193
+ } else if (edit.target_text && !edit.new_text) {
2194
+ op = "DELETION";
2195
+ } else {
2196
+ op = "COMMENT_ONLY";
2197
+ }
2198
+ }
2199
+
1987
2200
  const del_id = ["DELETION", "MODIFICATION"].includes(op)
1988
2201
  ? this._getNextId()
1989
2202
  : null;