revise 3.0.1 → 3.1.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.
@@ -897,12 +897,38 @@ var ReviseDocument = class _ReviseDocument {
897
897
  pattern.source,
898
898
  pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`
899
899
  );
900
- return textBlocks(ydocToDocument2(this.currentYDoc)).map((block) => ({
901
- id: block.id,
902
- blockIndex: block.topIndex,
903
- count: (block.text.match(matcher) ?? []).length,
904
- text: block.text
905
- })).filter((hit) => hit.count > 0);
900
+ const view = options.view ?? "tracked";
901
+ return textBlocks(ydocToDocument2(this.currentYDoc)).map((block) => {
902
+ const matches = matchesIn(block.segments, matcher, view);
903
+ return {
904
+ id: block.id,
905
+ blockIndex: block.topIndex,
906
+ count: matches.length,
907
+ text: segmentsText(block.segments, view),
908
+ matches
909
+ };
910
+ }).filter((hit) => hit.count > 0);
911
+ }
912
+ /**
913
+ * HTML the read tools returned (`read_blocks_from_index`,
914
+ * `read_specific_blocks`), in a view. The tools read the accepted view;
915
+ * "tracked" marks pending changes as `<ins>`/`<del>` with
916
+ * `data-change-id` and `data-author`, and shows whole blocks a change
917
+ * deletes where they were; "original" shows the text before every change.
918
+ * Block ids are unchanged. The editing tools read `<ins>` as its text and
919
+ * ignore `<del>`, so tracked HTML pasted back edits the accepted text.
920
+ */
921
+ viewHtml(html, view) {
922
+ if (view === "accepted") return Promise.resolve(html);
923
+ return this.serial(async () => {
924
+ await installDomShims();
925
+ const { AgentHtmlConverter } = await engineModules2.agentHtml();
926
+ const raw = ydocToDocument2(this.currentYDoc);
927
+ const authors = new Map(
928
+ this.currentSession.listSuggestions().map((r) => [r.id, r.agentName ?? r.authorType ?? "unknown"])
929
+ );
930
+ return renderView(html, raw, view, authors, AgentHtmlConverter);
931
+ });
906
932
  }
907
933
  /** What the document holds, in one glance: page setup, header and footer
908
934
  * text with live fields, title, headings, lists, tables, review state. */
@@ -912,23 +938,60 @@ var ReviseDocument = class _ReviseDocument {
912
938
  );
913
939
  }
914
940
  // ── editing ──────────────────────────────────────────────────────────────
915
- /** Replace every exact occurrence, block by block (tracked changes in
916
- * suggesting mode). `ids` limits it to those blocks. Replacing text with
917
- * itself changes nothing and is not a failure. */
918
- replaceText(find, replace, options = {}) {
941
+ /**
942
+ * Replace every occurrence everywhere: the body, tables, lists, page
943
+ * headers and footers, and footnotes and endnotes. Tracked changes in
944
+ * suggesting mode. Curly and straight quotes and non-breaking spaces match
945
+ * each other unless `exact`, and the replacement keeps the document's
946
+ * characters (see matchDocumentCharacters). Replacing text with itself
947
+ * changes nothing and is not a failure.
948
+ */
949
+ replaceAll(find, replace, options = {}) {
919
950
  return this.serial(async () => {
920
951
  if (!find) {
921
952
  throw new ReviseError("Nothing to find: give some text.", "usage");
922
953
  }
923
- const targets = textBlocks(ydocToDocument2(this.currentYDoc)).filter(
924
- (block) => block.text.includes(find) && (!options.ids || options.ids.includes(block.id))
954
+ const only = options.blocks ?? options.ids;
955
+ const all = replaceTargets(ydocToDocument2(this.currentYDoc)).filter(
956
+ (block) => !only || only.includes(block.id)
925
957
  );
958
+ const exactMatch = textMatcher(find, {
959
+ exact: options.exact,
960
+ ignoreCase: options.ignoreCase
961
+ });
962
+ const loosest = textMatcher(find, { ignoreCase: true });
963
+ const caseOnly = textMatcher(find, {
964
+ exact: options.exact,
965
+ ignoreCase: true
966
+ });
967
+ const targets = [];
968
+ const missed = /* @__PURE__ */ new Map();
969
+ for (const block of all) {
970
+ const matches = exactMatch(block.text);
971
+ if (matches.length) targets.push({ ...block, matches });
972
+ const left = [...matches];
973
+ for (const text of loosest(block.text)) {
974
+ const at = left.indexOf(text);
975
+ if (at >= 0) {
976
+ left.splice(at, 1);
977
+ continue;
978
+ }
979
+ const reason = caseOnly(text).length ? "case" : "characters";
980
+ const entry = missed.get(text) ?? { count: 0, reason };
981
+ entry.count += 1;
982
+ missed.set(text, entry);
983
+ }
984
+ }
985
+ const nearMisses = [...missed].map(([text, m]) => ({ text, ...m }));
986
+ const regions = {};
926
987
  if (find === replace) {
927
988
  return {
928
989
  replaced: 0,
929
990
  blocks: targets.map((t) => t.id),
991
+ regions,
930
992
  changeIds: [],
931
- failures: []
993
+ failures: [],
994
+ nearMisses
932
995
  };
933
996
  }
934
997
  const failures = [];
@@ -939,11 +1002,16 @@ var ReviseDocument = class _ReviseDocument {
939
1002
  block_ids: [target.id],
940
1003
  context_notes: "N/A"
941
1004
  });
1005
+ const distinct = [...new Set(target.matches)];
942
1006
  const result = await this.currentSession.tools.executeDynamic(
943
1007
  "replace",
944
1008
  {
945
1009
  id: target.id,
946
- replacements: [{ find, replace, occurrence: "all" }],
1010
+ replacements: distinct.map((text) => ({
1011
+ find: text,
1012
+ replace: matchDocumentCharacters(find, text, replace),
1013
+ occurrence: "all"
1014
+ })),
947
1015
  context_notes: "N/A"
948
1016
  }
949
1017
  );
@@ -956,26 +1024,52 @@ var ReviseDocument = class _ReviseDocument {
956
1024
  });
957
1025
  }
958
1026
  } else {
959
- replaced += target.text.split(find).length - 1;
1027
+ replaced += target.matches.length;
1028
+ regions[target.region] = (regions[target.region] ?? 0) + target.matches.length;
960
1029
  changeIds.push(...result.value.suggestionIds ?? []);
961
1030
  }
962
1031
  }
963
1032
  return {
964
1033
  replaced,
965
1034
  blocks: targets.map((t) => t.id),
1035
+ regions,
966
1036
  changeIds,
967
- failures
1037
+ failures,
1038
+ nearMisses
968
1039
  };
969
1040
  });
970
1041
  }
1042
+ /** @deprecated The 3.0 name of `replaceAll`. */
1043
+ replaceText(find, replace, options = {}) {
1044
+ return this.replaceAll(find, replace, options);
1045
+ }
1046
+ /** The document as it was before every pending change, exported from
1047
+ * a copy: this document keeps its changes. */
1048
+ async exportOriginal(options) {
1049
+ const copy = await _ReviseDocument.restore(await this.snapshot(), {
1050
+ author: this.settings.author
1051
+ });
1052
+ try {
1053
+ await copy.reject({ all: true });
1054
+ const result = await copy.export({
1055
+ ...options,
1056
+ view: void 0,
1057
+ mode: "final"
1058
+ });
1059
+ return { ...result, view: "original" };
1060
+ } finally {
1061
+ copy.dispose();
1062
+ }
1063
+ }
971
1064
  // ── review ───────────────────────────────────────────────────────────────
972
1065
  /** Pending tracked changes with where they are and what they do. */
973
1066
  changes(filter = {}) {
974
1067
  return this.serial(() => this.filteredChanges(filter));
975
1068
  }
976
1069
  async filteredChanges(filter = {}) {
977
- return (await describeChanges(this.currentYDoc, this.currentSession)).filter((c) => filter.section ? inSection(c, filter.section) : true).filter(
978
- (c) => filter.exceptSection ? !inSection(c, filter.exceptSection) : true
1070
+ return applyChangeFilter(
1071
+ await describeChanges(this.currentYDoc, this.currentSession),
1072
+ filter
979
1073
  );
980
1074
  }
981
1075
  /** The tracked changes a selection would settle, without settling them. */
@@ -993,22 +1087,21 @@ var ReviseDocument = class _ReviseDocument {
993
1087
  return this.serial(() => this.settle(selection, false));
994
1088
  }
995
1089
  async selectedIds(selection, known) {
996
- const { section, exceptSection, all } = selection;
1090
+ const { section, exceptSection, blocks, all } = selection;
997
1091
  const ids = selection.ids ?? [];
998
- const kinds = [
999
- ids.length > 0,
1000
- Boolean(all),
1001
- Boolean(section || exceptSection)
1002
- ];
1092
+ const filtered = Boolean(section || exceptSection || blocks?.length);
1093
+ const kinds = [ids.length > 0, Boolean(all), filtered];
1003
1094
  if (kinds.filter(Boolean).length !== 1) {
1004
1095
  throw new ReviseError(
1005
- "Select tracked changes one way: ids, all, or a section filter (section/exceptSection).",
1096
+ "Select tracked changes one way: ids, all, or filters (section, exceptSection, blocks).",
1006
1097
  "invalid_selection"
1007
1098
  );
1008
1099
  }
1009
- if (section || exceptSection) {
1010
- const changes = known ?? await this.filteredChanges();
1011
- return changes.filter((c) => section ? inSection(c, section) : true).filter((c) => exceptSection ? !inSection(c, exceptSection) : true).map((c) => c.id);
1100
+ if (filtered) {
1101
+ return applyChangeFilter(
1102
+ known ?? await describeChanges(this.currentYDoc, this.currentSession),
1103
+ { section, exceptSection, blocks }
1104
+ ).map((c) => c.id);
1012
1105
  }
1013
1106
  return all ? "all" : ids;
1014
1107
  }
@@ -1048,9 +1141,15 @@ var ReviseDocument = class _ReviseDocument {
1048
1141
  /** Write the document in a format. Reports what the output actually
1049
1142
  * contains: its pending changes, and for .docx how much of the original
1050
1143
  * file was carried over byte for byte. */
1051
- export(options) {
1144
+ async export(options) {
1145
+ const view = viewOfMode(options);
1146
+ if (view === "original") return this.exportOriginal(options);
1052
1147
  return this.serial(async () => {
1053
- options = { ...options, format: exportFormat(options.format) };
1148
+ options = {
1149
+ ...options,
1150
+ format: exportFormat(options.format),
1151
+ mode: view === "accepted" ? "final" : options.mode
1152
+ };
1054
1153
  const mode = options.format === "docx" ? options.mode ?? "review" : "final";
1055
1154
  const pending = this.currentSession.listSuggestions().length;
1056
1155
  const { bytes, outcome } = await exportYDoc(
@@ -1079,6 +1178,7 @@ var ReviseDocument = class _ReviseDocument {
1079
1178
  bytes,
1080
1179
  format: options.format,
1081
1180
  mode,
1181
+ view: mode === "final" ? "accepted" : "tracked",
1082
1182
  pendingTrackedChanges: mode === "final" ? 0 : pending,
1083
1183
  fidelity: outcome,
1084
1184
  warnings
@@ -1184,6 +1284,16 @@ async function compare(originalSource, revisedSource, options = {}) {
1184
1284
  }
1185
1285
  async function convert(source, format, options = {}) {
1186
1286
  format = exportFormat(format);
1287
+ const view = viewOfMode(options);
1288
+ if (view === "original") {
1289
+ const doc = await ReviseDocument.open(source);
1290
+ try {
1291
+ return (await doc.export({ format, view })).bytes;
1292
+ } finally {
1293
+ doc.dispose();
1294
+ }
1295
+ }
1296
+ if (view === "accepted") options = { mode: "final" };
1187
1297
  const { bytes, filename } = await readSource(source);
1188
1298
  assertReadable(filename);
1189
1299
  const ydoc = await importYDoc(bytes, filename);
@@ -1193,6 +1303,22 @@ async function convert(source, format, options = {}) {
1193
1303
  title: stem(filename)
1194
1304
  });
1195
1305
  }
1306
+ function viewOfMode(options) {
1307
+ const { mode, view } = options;
1308
+ if (view !== void 0 && !["tracked", "accepted", "original"].includes(view)) {
1309
+ throw new ReviseError(
1310
+ `Unknown view "${view}". Use tracked, accepted, or original.`,
1311
+ "usage"
1312
+ );
1313
+ }
1314
+ if (view && mode && mode === "final" !== (view !== "tracked")) {
1315
+ throw new ReviseError(
1316
+ `mode "${mode}" and view "${view}" disagree; give one of them.`,
1317
+ "usage"
1318
+ );
1319
+ }
1320
+ return view;
1321
+ }
1196
1322
  var EXPORT_FORMATS = ["docx", "pdf", "markdown", "html", "txt"];
1197
1323
  function exportFormat(format) {
1198
1324
  const name = String(format).toLowerCase();
@@ -1207,16 +1333,28 @@ function exportFormat(format) {
1207
1333
  }
1208
1334
  function textBlocks(doc) {
1209
1335
  const out = [];
1210
- const textOf = (node) => (node.children ?? []).map((child) => {
1211
- const c = child;
1212
- return c.type === "text" && !c.marks?.deletion ? c.text ?? "" : "";
1213
- }).join("");
1336
+ const segmentsOf = (node) => {
1337
+ const segments = [];
1338
+ for (const child of node.children ?? []) {
1339
+ const c = child;
1340
+ if (c.type !== "text" || !c.text) continue;
1341
+ const change = c.marks?.deletion ? "deleted" : c.marks?.insertion ? "inserted" : null;
1342
+ const changeId = change ? c.marks?.suggestionId ?? null : null;
1343
+ const last = segments[segments.length - 1];
1344
+ if (last && last.change === change && last.changeId === changeId) {
1345
+ last.text += c.text;
1346
+ } else {
1347
+ segments.push({ text: c.text, change, changeId });
1348
+ }
1349
+ }
1350
+ return segments;
1351
+ };
1214
1352
  doc.children.forEach((block, topIndex) => {
1215
1353
  const visit = (node) => {
1216
1354
  const n = node;
1217
1355
  if (!n || typeof n !== "object") return;
1218
1356
  if ((n.type === "paragraph" || n.type === "list-item") && n.id) {
1219
- out.push({ id: n.id, text: textOf(n), topIndex });
1357
+ out.push({ id: n.id, segments: segmentsOf(n), topIndex });
1220
1358
  return;
1221
1359
  }
1222
1360
  n.children?.forEach(visit);
@@ -1225,36 +1363,308 @@ function textBlocks(doc) {
1225
1363
  });
1226
1364
  return out;
1227
1365
  }
1366
+ function replaceTargets(doc) {
1367
+ const out = [];
1368
+ const textOf = (node) => (node.children ?? []).map((child) => {
1369
+ const c = child;
1370
+ return c.type === "text" && !c.marks?.deletion ? c.text ?? "" : "";
1371
+ }).join("");
1372
+ const visit = (node, region) => {
1373
+ const n = node;
1374
+ if (!n || typeof n !== "object") return;
1375
+ if ((n.type === "paragraph" || n.type === "list-item") && n.id) {
1376
+ out.push({ id: n.id, text: textOf(n), region });
1377
+ return;
1378
+ }
1379
+ n.children?.forEach((child) => visit(child, region));
1380
+ };
1381
+ doc.children.forEach((block) => visit(block, "body"));
1382
+ for (const block of doc.chrome ?? []) {
1383
+ block.children.forEach((column) => visit(column, block.side));
1384
+ }
1385
+ for (const note of doc.notes ?? []) {
1386
+ const region = note.noteKind === "endnote" ? "endnote" : "footnote";
1387
+ note.children.forEach((child) => visit(child, region));
1388
+ }
1389
+ return out;
1390
+ }
1391
+ var LOOSE_CHARACTERS = {
1392
+ "\u2018": "'",
1393
+ // ‘
1394
+ "\u2019": "'",
1395
+ // ’
1396
+ "\u201A": "'",
1397
+ // ‚
1398
+ "\u201B": "'",
1399
+ // ‛
1400
+ "\u2032": "'",
1401
+ // ′
1402
+ "\u201C": '"',
1403
+ // “
1404
+ "\u201D": '"',
1405
+ // ”
1406
+ "\u201E": '"',
1407
+ // „
1408
+ "\u201F": '"',
1409
+ // ‟
1410
+ "\u2033": '"',
1411
+ // ″
1412
+ "\xA0": " ",
1413
+ // no-break space
1414
+ "\u202F": " ",
1415
+ // narrow no-break space
1416
+ "\u2007": " ",
1417
+ // figure space
1418
+ "\u2009": " ",
1419
+ // thin space
1420
+ "\u2011": "-",
1421
+ // non-breaking hyphen
1422
+ "\u2010": "-"
1423
+ // hyphen
1424
+ };
1425
+ function fold(text, options) {
1426
+ return Array.from(text, (char) => {
1427
+ let c = options.exact ? char : LOOSE_CHARACTERS[char] ?? char;
1428
+ if (options.ignoreCase) {
1429
+ const lower = c.toLowerCase();
1430
+ if (lower.length === c.length) c = lower;
1431
+ }
1432
+ return c;
1433
+ });
1434
+ }
1435
+ function textMatcher(find, options) {
1436
+ const needle = fold(find, options).join("");
1437
+ return (text) => {
1438
+ const chars = Array.from(text);
1439
+ const hay = fold(text, options).join("");
1440
+ const matches = [];
1441
+ const starts = [];
1442
+ let offset = 0;
1443
+ for (const c of fold(text, options)) {
1444
+ starts.push(offset);
1445
+ offset += c.length;
1446
+ }
1447
+ let from = 0;
1448
+ while (needle) {
1449
+ const at = hay.indexOf(needle, from);
1450
+ if (at < 0) break;
1451
+ const first = starts.indexOf(at);
1452
+ let last = first;
1453
+ while (last < starts.length && starts[last] < at + needle.length) last++;
1454
+ if (first >= 0) matches.push(chars.slice(first, last).join(""));
1455
+ from = at + Math.max(needle.length, 1);
1456
+ }
1457
+ return matches;
1458
+ };
1459
+ }
1460
+ function matchDocumentCharacters(find, matched, replace) {
1461
+ const findChars = Array.from(find);
1462
+ const matchedChars = Array.from(matched);
1463
+ const out = Array.from(replace);
1464
+ for (const plain of ["'", '"', " ", "-"]) {
1465
+ const documentChars = findChars.map((c, i) => c === plain ? matchedChars[i] : void 0).filter((c) => c !== void 0);
1466
+ if (documentChars.every((c) => c === plain)) continue;
1467
+ const slots = out.flatMap((c, i) => c === plain ? [i] : []);
1468
+ if (slots.length !== documentChars.length) continue;
1469
+ slots.forEach((slot, n) => out[slot] = documentChars[n]);
1470
+ }
1471
+ return out.join("");
1472
+ }
1473
+ var shows = (segment, view) => view === "tracked" || segment.change === null || segment.change === "inserted" === (view === "accepted");
1474
+ function segmentsText(segments, view) {
1475
+ return segments.filter((segment) => shows(segment, view)).map(
1476
+ (segment) => view !== "tracked" || segment.change === null ? segment.text : segment.change === "deleted" ? `[-${segment.text}-]` : `{+${segment.text}+}`
1477
+ ).join("");
1478
+ }
1479
+ function matchesIn(segments, matcher, view) {
1480
+ const found = [];
1481
+ const scan = (side, keep) => {
1482
+ const places = [];
1483
+ const owners = [];
1484
+ let text = "";
1485
+ let at = 0;
1486
+ for (const segment of segments) {
1487
+ if (shows(segment, side)) {
1488
+ for (let i = 0; i < segment.text.length; i++) {
1489
+ places.push(at + i);
1490
+ owners.push(segment);
1491
+ }
1492
+ text += segment.text;
1493
+ }
1494
+ at += segment.text.length;
1495
+ }
1496
+ for (const match of text.matchAll(matcher)) {
1497
+ if (!match[0]) continue;
1498
+ const start = match.index ?? 0;
1499
+ const touched = owners.slice(start, start + match[0].length).find((owner) => owner.change !== null);
1500
+ const change = touched?.change ?? null;
1501
+ if (!keep(change)) continue;
1502
+ found.push({
1503
+ text: match[0],
1504
+ change,
1505
+ changeId: touched?.changeId ?? null,
1506
+ at: places[start]
1507
+ });
1508
+ }
1509
+ };
1510
+ if (view === "tracked") {
1511
+ scan("accepted", (change) => change !== "deleted");
1512
+ scan("original", (change) => change === "deleted");
1513
+ } else {
1514
+ scan(view, () => true);
1515
+ }
1516
+ return found.sort(
1517
+ (a, b) => a.at - b.at || Number(b.change === "deleted") - Number(a.change === "deleted")
1518
+ ).map(({ text, change, changeId }) => ({ text, change, changeId }));
1519
+ }
1520
+ var OPEN = "\uE000";
1521
+ var DELETED = "\uE001";
1522
+ var INSERTED = "\uE002";
1523
+ var CLOSE = "\uE003";
1524
+ var CHANGE_BASE = 57600;
1525
+ var SENTINEL_RUN = /([])([-])([\s\S]*?)/g;
1526
+ function renderView(html, raw, view, authors, AgentHtmlConverter) {
1527
+ const ids = topLevelIds(html);
1528
+ const blocks = [...raw.children, ...raw.notes ?? []];
1529
+ const position = new Map(blocks.map((block, i) => [block.id, i]));
1530
+ const known = ids.map((id) => position.get(id)).filter((at) => at !== void 0);
1531
+ if (!known.length) return html;
1532
+ const deleted = (block) => block !== void 0 && wholeBlockDeletion(block) !== null;
1533
+ let first = Math.min(...known);
1534
+ let last = Math.max(...known);
1535
+ if (blocks.slice(0, first).every((block) => deleted(block))) first = 0;
1536
+ while (last + 1 < blocks.length && deleted(blocks[last + 1])) last += 1;
1537
+ const changes = [];
1538
+ const indexOf = (id) => {
1539
+ let i = changes.indexOf(id);
1540
+ if (i < 0) i = changes.push(id) - 1;
1541
+ return String.fromCharCode(CHANGE_BASE + Math.min(i, 63743 - CHANGE_BASE));
1542
+ };
1543
+ const marked = (node, deletedBy) => {
1544
+ if (!node || typeof node !== "object") return node;
1545
+ const n = node;
1546
+ if (n.type === "text" && n.text) {
1547
+ const deletion = n.marks?.deletion || deletedBy && !n.marks?.insertion;
1548
+ if (!deletion && !n.marks?.insertion) return node;
1549
+ const id = n.marks?.suggestionId ?? deletedBy ?? "";
1550
+ const kind = deletion ? DELETED : INSERTED;
1551
+ return { ...n, text: `${OPEN}${kind}${indexOf(id)}${n.text}${CLOSE}` };
1552
+ }
1553
+ const structural = node;
1554
+ const by = structural.structuralSuggestionKind === "delete" ? structural.structuralSuggestionId : deletedBy;
1555
+ return Array.isArray(n.children) ? { ...n, children: n.children.map((child) => marked(child, by)) } : node;
1556
+ };
1557
+ const range = blocks.slice(first, last + 1).map((block) => marked(block));
1558
+ const doc = { ...raw, children: range, notes: void 0 };
1559
+ const rendered = AgentHtmlConverter.extractSpecificBlocks(
1560
+ doc,
1561
+ range.map((block) => block.id),
1562
+ void 0,
1563
+ { scanDocumentFontFamily: false }
1564
+ ).foundBlocks.map((block, i) => {
1565
+ const whole = view === "tracked" ? wholeBlockDeletion(range[i]) : null;
1566
+ return whole && !block.html.includes(OPEN) ? block.html.replace(
1567
+ /^<([a-z][\w-]*)/,
1568
+ `<$1 data-change="deleted" data-change-id="${escapeAttr(whole)}" data-author="${escapeAttr(authors.get(whole) ?? "unknown")}"`
1569
+ ) : block.html;
1570
+ });
1571
+ const header = /^\s*Showing \d+ block\(s\):\s*/.exec(html) ? `Showing ${rendered.length} block(s):
1572
+
1573
+ ` : "";
1574
+ return (header + rendered.join("\n")).replace(
1575
+ SENTINEL_RUN,
1576
+ (_, kind, index, text) => {
1577
+ const deletion = kind === DELETED;
1578
+ if (view === "original") return deletion ? text : "";
1579
+ const id = changes[index.charCodeAt(0) - CHANGE_BASE] ?? "";
1580
+ const tag = deletion ? "del" : "ins";
1581
+ return `<${tag} data-change-id="${escapeAttr(id)}" data-author="${escapeAttr(authors.get(id) ?? "unknown")}">${text}</${tag}>`;
1582
+ }
1583
+ );
1584
+ }
1585
+ function wholeBlockDeletion(block) {
1586
+ const b = block;
1587
+ if (b.structuralSuggestionKind === "delete" && b.structuralSuggestionId) {
1588
+ return b.structuralSuggestionId;
1589
+ }
1590
+ let id = null;
1591
+ let all = true;
1592
+ let any = false;
1593
+ const visit = (node) => {
1594
+ if (!all || !node || typeof node !== "object") return;
1595
+ const n = node;
1596
+ if (n.type === "text") {
1597
+ if (!n.text) return;
1598
+ any = true;
1599
+ if (!n.marks?.deletion) all = false;
1600
+ else id ??= n.marks.suggestionId ?? null;
1601
+ return;
1602
+ }
1603
+ n.children?.forEach(visit);
1604
+ };
1605
+ visit(block);
1606
+ return any && all ? id : null;
1607
+ }
1608
+ function topLevelIds(html) {
1609
+ const body = new DOMParser().parseFromString(
1610
+ `<!doctype html><body>${html}</body>`,
1611
+ "text/html"
1612
+ ).body;
1613
+ return Array.from(body.children).map((el) => el.getAttribute("id")).filter((id) => Boolean(id));
1614
+ }
1615
+ var escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
1616
+ var placesByEntries = /* @__PURE__ */ new WeakMap();
1228
1617
  async function describeChanges(ydoc, session) {
1229
1618
  const records = session.listSuggestions();
1230
1619
  const doc = ydocToDocument2(ydoc);
1231
- const measured = await session.tools.call("measure_document", {
1232
- include_sections: true,
1233
- section_limit: 1e3
1234
- });
1235
- const sections = measured.data.sections ?? [];
1236
- const positionOf = new Map(
1237
- doc.children.map((block, index) => [block.id, index])
1238
- );
1239
- const starts = sections.map((s) => ({ title: s.title, at: positionOf.get(s.startBlockId) })).filter((s) => s.at !== void 0).sort((a, b) => a.at - b.at);
1240
- const sectionAt = (index) => {
1620
+ const { documentHeadingSections } = await engineModules2.outline();
1621
+ const span = /* @__PURE__ */ new Map();
1622
+ let next = 0;
1623
+ const number = (node) => {
1624
+ if (!node || typeof node !== "object") return;
1625
+ const n = node;
1626
+ const isBlock = typeof n.id === "string" && n.type !== "text";
1627
+ const start = next;
1628
+ if (isBlock) next += 1;
1629
+ n.children?.forEach(number);
1630
+ if (isBlock) span.set(n.id, { start, end: next - 1 });
1631
+ };
1632
+ doc.children.forEach(number);
1633
+ const bodyEnd = next;
1634
+ (doc.chrome ?? []).forEach(number);
1635
+ (doc.notes ?? []).forEach(number);
1636
+ const headings = documentHeadingSections(doc, { maxDepth: 3 }).flatMap((h) => {
1637
+ const start = span.get(h.itemId ?? h.blockId)?.start;
1638
+ return start === void 0 ? [] : [{ title: h.title, level: h.level, start }];
1639
+ }).sort((a, b) => a.start - b.start);
1640
+ const sections = headings.map((h, i) => ({
1641
+ ...h,
1642
+ end: (headings.slice(i + 1).find((later) => later.level <= h.level)?.start ?? bodyEnd) - 1
1643
+ }));
1644
+ const sectionAt = (at) => {
1241
1645
  let title;
1242
- for (const start of starts) {
1243
- if (start.at > index) break;
1244
- title = start.title;
1646
+ for (const section of sections) {
1647
+ if (section.start > at) break;
1648
+ title = section.title;
1245
1649
  }
1246
1650
  return title;
1247
1651
  };
1652
+ const positionOf = new Map(
1653
+ doc.children.map((block, index) => [block.id, index])
1654
+ );
1248
1655
  const found = /* @__PURE__ */ new Map();
1249
- doc.children.forEach((block, blockIndex) => {
1250
- const visit = (node) => {
1656
+ const collect = (block, blockIndex, label) => {
1657
+ const visit = (node, path) => {
1251
1658
  if (!node || typeof node !== "object") return;
1252
1659
  const n = node;
1660
+ const here = typeof n.id === "string" && n.type !== "text" ? [...path, n.id] : path;
1253
1661
  const id = n.marks?.suggestionId ?? n.suggestionId ?? n.structuralSuggestionId;
1254
1662
  if (id) {
1255
1663
  const entry = found.get(id) ?? {
1256
1664
  blockIndex,
1257
1665
  blockId: block.id ?? "",
1666
+ label,
1667
+ path: here,
1258
1668
  deleted: "",
1259
1669
  inserted: ""
1260
1670
  };
@@ -1264,32 +1674,142 @@ async function describeChanges(ydoc, session) {
1264
1674
  }
1265
1675
  found.set(id, entry);
1266
1676
  }
1267
- n.children?.forEach(visit);
1677
+ n.children?.forEach((child) => visit(child, here));
1268
1678
  };
1269
- visit(block);
1270
- });
1271
- return records.map((record) => {
1679
+ visit(block, []);
1680
+ };
1681
+ doc.children.forEach(
1682
+ (block, blockIndex) => collect(block, blockIndex, void 0)
1683
+ );
1684
+ for (const block of doc.chrome ?? []) {
1685
+ collect(block, void 0, block.side === "header" ? "Header" : "Footer");
1686
+ }
1687
+ const noteCount = { footnote: 0, endnote: 0 };
1688
+ for (const note of doc.notes ?? []) {
1689
+ const kind = note.noteKind === "endnote" ? "endnote" : "footnote";
1690
+ noteCount[kind] += 1;
1691
+ collect(
1692
+ note,
1693
+ void 0,
1694
+ `${kind === "endnote" ? "Endnote" : "Footnote"} ${noteCount[kind]}`
1695
+ );
1696
+ }
1697
+ const changes = /* @__PURE__ */ new WeakMap();
1698
+ const entries = records.map((record) => {
1272
1699
  const at = found.get(record.id);
1273
1700
  const blockIndex = at?.blockIndex ?? (record.blockId ? positionOf.get(record.blockId) : void 0) ?? (record.tableId ? positionOf.get(record.tableId) : void 0);
1274
- return {
1701
+ const path = at?.path ?? [record.tableId, record.blockId].filter(
1702
+ (id) => Boolean(id && span.has(id))
1703
+ );
1704
+ const place = path.length ? span.get(path[path.length - 1]) : void 0;
1705
+ const entry = {
1275
1706
  id: record.id,
1276
1707
  type: record.type ?? "text",
1277
1708
  author: record.agentName ?? record.authorType ?? "unknown",
1278
1709
  ...at?.blockId || record.blockId ? { blockId: at?.blockId || record.blockId } : {},
1279
- ...blockIndex !== void 0 ? { blockIndex, section: sectionAt(blockIndex) } : {},
1710
+ ...blockIndex !== void 0 ? { blockIndex } : {},
1711
+ ...at?.label ? { section: at.label } : place && place.start < bodyEnd ? { section: sectionAt(place.start) } : {},
1280
1712
  deleted: at?.deleted ?? "",
1281
1713
  inserted: at?.inserted ?? ""
1282
1714
  };
1715
+ if (place) changes.set(entry, { at: place.start, path });
1716
+ return entry;
1283
1717
  });
1718
+ placesByEntries.set(entries, { span, sections, changes });
1719
+ return entries;
1284
1720
  }
1285
- function inSection(entry, query) {
1286
- if (!entry.section) return false;
1287
- const title = entry.section.trim().toLowerCase();
1721
+ function headingMatches(title, query) {
1722
+ const heading = title.trim().toLowerCase();
1288
1723
  const q = query.trim().toLowerCase().replace(/\.$/, "");
1289
1724
  if (/^[\d.]+$/.test(q)) {
1290
- return new RegExp(`^${q.replace(/\./g, "\\.")}(?:[.\\s)]|$)`).test(title);
1725
+ return new RegExp(`^${q.replace(/\./g, "\\.")}(?:[.\\s)]|$)`).test(heading);
1291
1726
  }
1292
- return title.includes(q);
1727
+ return heading.includes(q);
1728
+ }
1729
+ function parseBlockQuery(query) {
1730
+ return query.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
1731
+ const [from, to, ...rest] = part.split(/\.\.\.?|…/).map((id) => id.trim());
1732
+ if (to === void 0) return from;
1733
+ if (!from || !to || rest.length) {
1734
+ throw new ReviseError(
1735
+ `"${part}" is not a block range; write first..last.`,
1736
+ "invalid_selection"
1737
+ );
1738
+ }
1739
+ return { from, to };
1740
+ });
1741
+ }
1742
+ function applyChangeFilter(entries, filter) {
1743
+ const { section, exceptSection, blocks } = filter;
1744
+ if (!section && !exceptSection && !blocks?.length) return entries;
1745
+ const places = placesByEntries.get(entries);
1746
+ if (!places) return entries;
1747
+ const sectionRanges = (query) => {
1748
+ const q = query.trim().toLowerCase();
1749
+ const exact = places.sections.filter(
1750
+ (s) => s.title.toLowerCase() === q || s.title.toLowerCase().replace(/^[\d.]+[.)]?\s+/, "") === q
1751
+ );
1752
+ const candidates = exact.length ? exact : places.sections.filter((s) => headingMatches(s.title, query));
1753
+ const matched = candidates.filter(
1754
+ (s) => !candidates.some(
1755
+ (outer) => outer !== s && outer.start < s.start && s.end <= outer.end
1756
+ )
1757
+ );
1758
+ if (matched.length > 1) {
1759
+ throw new ReviseError(
1760
+ `"${query}" matches ${matched.length} headings: ${matched.map((s) => `"${s.title}"`).join(", ")}. Name one by its number or full title.`,
1761
+ "section_ambiguous"
1762
+ );
1763
+ }
1764
+ if (!matched.length) {
1765
+ throw new ReviseError(
1766
+ places.sections.length ? `No heading matches "${query}". Headings: ${places.sections.slice(0, 12).map((s) => `"${s.title}"`).join(", ")}${places.sections.length > 12 ? ", \u2026" : ""}.` : `No heading matches "${query}": the document has no headings. Select by blocks or change ids instead.`,
1767
+ "section_not_found"
1768
+ );
1769
+ }
1770
+ return matched;
1771
+ };
1772
+ const spanOf = (id) => {
1773
+ const found = places.span.get(id);
1774
+ if (!found) {
1775
+ throw new ReviseError(`No block with id "${id}".`, "block_not_found");
1776
+ }
1777
+ return found;
1778
+ };
1779
+ const inside = sectionRanges;
1780
+ const keepSections = section ? inside(section) : void 0;
1781
+ const dropSections = exceptSection ? inside(exceptSection) : void 0;
1782
+ const ids = /* @__PURE__ */ new Set();
1783
+ const ranges = [];
1784
+ for (const item of blocks ?? []) {
1785
+ if (typeof item === "string") {
1786
+ spanOf(item);
1787
+ ids.add(item);
1788
+ continue;
1789
+ }
1790
+ const from = spanOf(item.from);
1791
+ const to = spanOf(item.to);
1792
+ if (from.start > to.start) {
1793
+ throw new ReviseError(
1794
+ `Block "${item.from}" comes after "${item.to}"; write the range first..last.`,
1795
+ "invalid_selection"
1796
+ );
1797
+ }
1798
+ ranges.push({ start: from.start, end: to.end });
1799
+ }
1800
+ const within = (at, r) => at >= r.start && at <= r.end;
1801
+ return entries.filter((entry) => {
1802
+ const place = places.changes.get(entry);
1803
+ if (!place) return false;
1804
+ if (keepSections && !keepSections.some((r) => within(place.at, r))) {
1805
+ return false;
1806
+ }
1807
+ if (dropSections?.some((r) => within(place.at, r))) return false;
1808
+ if (blocks?.length && !place.path.some((id) => ids.has(id)) && !ranges.some((r) => within(place.at, r))) {
1809
+ return false;
1810
+ }
1811
+ return true;
1812
+ });
1293
1813
  }
1294
1814
  async function markdownOf(ydoc) {
1295
1815
  const bytes = await exportDocument(ydoc, {
@@ -1645,6 +2165,7 @@ export {
1645
2165
  seedYDoc,
1646
2166
  compare,
1647
2167
  convert,
2168
+ parseBlockQuery,
1648
2169
  formatToolResult,
1649
2170
  agentTools
1650
2171
  };