@adeu/core 1.14.0 → 1.15.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/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(
@@ -1501,6 +1548,10 @@ export class RedlineEngine {
1501
1548
  }
1502
1549
  }
1503
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
+
1504
1555
  const edits_reports: any[] = [];
1505
1556
  let applied_edits = 0;
1506
1557
  let skipped_edits = 0;
@@ -1525,9 +1576,8 @@ export class RedlineEngine {
1525
1576
  });
1526
1577
  continue;
1527
1578
  }
1528
- const res = this.apply_edits([edit]);
1529
- const applied = res[0];
1530
- if (applied > 0) {
1579
+ const res = this.apply_edits([edit], page_offsets);
1580
+ if ((edit as any)._applied_status) {
1531
1581
  applied_edits++;
1532
1582
  const previews = this._build_edit_context_previews(edit);
1533
1583
  edits_reports.push({
@@ -1538,6 +1588,10 @@ export class RedlineEngine {
1538
1588
  error: null,
1539
1589
  critic_markup: previews[0],
1540
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",
1541
1595
  });
1542
1596
  } else {
1543
1597
  skipped_edits++;
@@ -1562,9 +1616,9 @@ export class RedlineEngine {
1562
1616
  throw new BatchValidationError(errors);
1563
1617
  }
1564
1618
  const cloned_edits = edits.map((e) => JSON.parse(JSON.stringify(e)));
1565
- const res = this.apply_edits(cloned_edits);
1566
- applied_edits = res[0];
1567
- 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;
1568
1622
 
1569
1623
  for (const edit of cloned_edits) {
1570
1624
  const success = (edit as any)._applied_status || false;
@@ -1587,6 +1641,10 @@ export class RedlineEngine {
1587
1641
  error: error_msg,
1588
1642
  critic_markup: critic_markup,
1589
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",
1590
1648
  });
1591
1649
  }
1592
1650
  }
@@ -1604,9 +1662,14 @@ export class RedlineEngine {
1604
1662
  };
1605
1663
  }
1606
1664
 
1607
- public apply_edits(edits: any[]): [number, number] {
1665
+ public apply_edits(edits: any[], page_offsets: number[] = []): [number, number] {
1608
1666
  let applied = 0;
1609
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
+ }
1610
1673
  const resolved_edits: [any, string | null][] = [];
1611
1674
 
1612
1675
  for (const edit of edits) {
@@ -1728,6 +1791,19 @@ export class RedlineEngine {
1728
1791
  const parent = edit._parent_edit_ref;
1729
1792
  if (parent) {
1730
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;
1731
1807
  }
1732
1808
  } else {
1733
1809
  skipped++;
@@ -1899,103 +1975,138 @@ export class RedlineEngine {
1899
1975
  return false;
1900
1976
  }
1901
1977
 
1902
- /**
1903
- * Returns the first match of `target_text` in the raw mapper that is NOT
1904
- * entirely contained within a tracked deletion (<w:del>). Tracked-deleted
1905
- * copies are not live, editable text, so an edit must resolve to a live
1906
- * occurrence even when a dead copy appears earlier in the document
1907
- * (BUG-23-5). Falls back to the plain first match when no live copy is
1908
- * found (e.g. fuzzy/normalized matches the span filter cannot align).
1909
- */
1910
- private _first_live_match(target_text: string): [number, number] {
1911
- const all = this.mapper.find_all_match_indices(target_text);
1912
- if (all.length <= 1) {
1913
- return this.mapper.find_match_index(target_text);
1914
- }
1915
- for (const [start, length] of all) {
1916
- const realSpans = this.mapper.spans.filter(
1917
- (s) => s.run !== null && s.end > start && s.start < start + length,
1918
- );
1919
- if (realSpans.length === 0) return [start, length];
1920
- if (realSpans.some((s) => !s.del_id)) return [start, length];
1921
- }
1922
- return this.mapper.find_match_index(target_text);
1923
- }
1924
-
1925
1978
  private _pre_resolve_heuristic_edit(edit: any): any {
1926
1979
  if (!edit.target_text) return null;
1927
1980
 
1928
- 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);
1929
1985
  let use_clean_map = false;
1930
1986
 
1931
- if (start_idx === -1) {
1987
+ if (matches.length === 0) {
1932
1988
  if (!this.clean_mapper)
1933
1989
  this.clean_mapper = new DocumentMapper(this.doc, true);
1934
- [start_idx, match_len] = this.clean_mapper.find_match_index(
1935
- edit.target_text,
1936
- );
1937
- 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;
1938
1992
  else return null;
1939
1993
  }
1940
1994
 
1941
1995
  const active_mapper = use_clean_map ? this.clean_mapper! : this.mapper;
1942
- const effective_new_text = edit.new_text || "";
1943
- const actual_doc_text = this.mapper.full_text.substring(
1944
- start_idx,
1945
- start_idx + match_len,
1946
- );
1947
1996
 
1948
- if (
1949
- actual_doc_text === effective_new_text ||
1950
- edit.target_text === effective_new_text
1951
- ) {
1952
- return {
1953
- type: "modify",
1954
- target_text: actual_doc_text,
1955
- new_text: actual_doc_text,
1956
- comment: edit.comment,
1957
- _match_start_index: start_idx,
1958
- _internal_op: "COMMENT_ONLY",
1959
- _active_mapper_ref: active_mapper,
1960
- };
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
+ }
1961
2005
  }
1962
2006
 
1963
- let effective_op = "";
1964
- let final_target = "";
1965
- let final_new = "";
1966
- let effective_start_idx = start_idx;
2007
+ if (live_matches.length === 0) return null;
1967
2008
 
1968
- if (effective_new_text.startsWith(actual_doc_text)) {
1969
- effective_op = "INSERTION";
1970
- final_new = effective_new_text.substring(actual_doc_text.length);
1971
- effective_start_idx = start_idx + match_len;
1972
- } else {
1973
- const [prefix_len, suffix_len] = trim_common_context(
1974
- actual_doc_text,
1975
- 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,
1976
2019
  );
1977
- const t_end = actual_doc_text.length - suffix_len;
1978
- 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
+ }
1979
2052
 
1980
- final_target = actual_doc_text.substring(prefix_len, t_end);
1981
- final_new = effective_new_text.substring(prefix_len, n_end);
1982
- effective_start_idx = start_idx + prefix_len;
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
+ }
2068
+
2069
+ let effective_op = "";
2070
+ let final_target = "";
2071
+ let final_new = "";
2072
+ let effective_start_idx = start_idx;
1983
2073
 
1984
- if (!final_target && final_new) effective_op = "INSERTION";
1985
- else if (final_target && !final_new) effective_op = "DELETION";
1986
- else if (final_target && final_new) effective_op = "MODIFICATION";
1987
- 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
+ });
1988
2105
  }
1989
2106
 
1990
- return {
1991
- type: "modify",
1992
- target_text: final_target,
1993
- new_text: final_new,
1994
- comment: edit.comment,
1995
- _match_start_index: effective_start_idx,
1996
- _internal_op: effective_op,
1997
- _active_mapper_ref: active_mapper,
1998
- };
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];
1999
2110
  }
2000
2111
 
2001
2112
  private _apply_single_edit_indexed(
@@ -2012,6 +2123,80 @@ export class RedlineEngine {
2012
2123
  : edit._match_start_index || 0;
2013
2124
  const length = edit.target_text ? edit.target_text.length : 0;
2014
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
+
2015
2200
  const del_id = ["DELETION", "MODIFICATION"].includes(op)
2016
2201
  ? this._getNextId()
2017
2202
  : null;