@superdoc/sdk 2.22.0-next.9 → 2.23.0-next.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.
@@ -162,13 +162,15 @@ function findSnapshotTextByNodeId(snapshot, nodeId) {
162
162
  }
163
163
  function createdBlockTarget(result) {
164
164
  const rec = asRecord(result);
165
- const paragraph = asRecord(rec?.paragraph);
166
- if (typeof paragraph?.nodeId === 'string' && paragraph.nodeId.length > 0) {
167
- return { nodeId: paragraph.nodeId, nodeType: 'paragraph' };
168
- }
169
- const heading = asRecord(rec?.heading);
170
- if (typeof heading?.nodeId === 'string' && heading.nodeId.length > 0) {
171
- return { nodeId: heading.nodeId, nodeType: 'heading' };
165
+ for (const [key, nodeType] of [
166
+ ['paragraph', 'paragraph'],
167
+ ['heading', 'heading'],
168
+ ['item', 'listItem'],
169
+ ]) {
170
+ const block = asRecord(rec?.[key]);
171
+ if (typeof block?.nodeId === 'string' && block.nodeId.length > 0) {
172
+ return { nodeId: block.nodeId, nodeType };
173
+ }
172
174
  }
173
175
  return null;
174
176
  }
@@ -598,12 +600,10 @@ function normalizeTitleLikeRewriteText(targetText, rewrittenText) {
598
600
  const displayTitle = toDisplayTitleCase(targetText);
599
601
  if (rewrittenText.includes(displayTitle))
600
602
  return rewrittenText;
601
- const lowerTarget = targetText.toLocaleLowerCase();
602
- const lowerRewrite = rewrittenText.toLocaleLowerCase();
603
- const index = lowerRewrite.indexOf(lowerTarget);
604
- if (index < 0)
603
+ const match = findRanges(rewrittenText, targetText, false)[0];
604
+ if (!match)
605
605
  return rewrittenText;
606
- return `${rewrittenText.slice(0, index)}${displayTitle}${rewrittenText.slice(index + targetText.length)}`;
606
+ return `${rewrittenText.slice(0, match.start)}${displayTitle}${rewrittenText.slice(match.end)}`;
607
607
  }
608
608
  async function executeCreateParagraph(doc, text, placement, changeMode) {
609
609
  const fn = maybeMethod(doc, ['create', 'paragraph']);
@@ -972,6 +972,7 @@ async function runReplaceTextUnchecked(doc, args) {
972
972
  ? await buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
973
973
  : snapshotFromIdentity(preIdentity);
974
974
  try {
975
+ assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
975
976
  assertReplacementTextPayloads(args.edits);
976
977
  if (args.edits.length === 0) {
977
978
  return failedReceipt('replace_text', new Error('edits must be non-empty'), pre);
@@ -980,7 +981,7 @@ async function runReplaceTextUnchecked(doc, args) {
980
981
  const selectedTargets = [];
981
982
  let skippedEdits = [];
982
983
  let steps;
983
- let applyExpectedRevision;
984
+ let applyExpectedRevision = args.expectedRevision != null ? pre.revision : undefined;
984
985
  if (args.selector) {
985
986
  const target = selectorToBlockTarget(args.selector, pre);
986
987
  if (!target) {
@@ -1545,7 +1546,7 @@ async function runInsertListItems(doc, args) {
1545
1546
  }
1546
1547
  }
1547
1548
  async function runAddListItems(doc, args, opts) {
1548
- const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
1549
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
1549
1550
  try {
1550
1551
  const needle = args.anchorText?.trim();
1551
1552
  const anchorNodeId = args.anchorNodeId?.trim();
@@ -1596,25 +1597,47 @@ async function runAddListItems(doc, args, opts) {
1596
1597
  }
1597
1598
  const anchorPath = blockNumbering(anchor)?.path;
1598
1599
  const anchorLevel = Array.isArray(anchorPath) ? Math.max(0, anchorPath.length - 1) : 0;
1600
+ const listByNodeId = new Map((pre.lists ?? []).flatMap((list) => list.items.map((item) => [item.nodeId, list.listId])));
1601
+ const anchorListId = listByNodeId.get(anchor.nodeId);
1602
+ // The lists snapshot is paged, so a missing item is unknown, not foreign.
1603
+ const belongsToAnchorList = (row) => {
1604
+ const rowListId = row.nodeId == null ? undefined : listByNodeId.get(row.nodeId);
1605
+ return anchorListId == null || rowListId == null || rowListId === anchorListId;
1606
+ };
1599
1607
  // Place new items AFTER the anchor's whole sub-tree — every following item
1600
1608
  // whose numbering PATH is nested under the anchor's path (e.g. [12] ⊂ [12,4]).
1601
1609
  // Match by path prefix, not merely "deeper level", and SKIP interleaved
1602
1610
  // non-numbered paragraphs (list-item continuations) rather than stopping at
1603
1611
  // them — real lists wrap sub-items across plain paragraphs. Stop at the first
1604
1612
  // numbered item that is NOT a descendant (a sibling/ancestor, or a different
1605
- // list). Without this, a new same-level item wedges in among the anchor's
1613
+ // unrelated list). Without this, a new same-level item wedges in among the anchor's
1606
1614
  // children and steals the ones after it. No descendants → the anchor itself.
1607
1615
  let subtreeEnd = anchor;
1608
1616
  const anchorPathArr = Array.isArray(anchorPath) ? anchorPath : null;
1609
1617
  const anchorIdx = rows.findIndex((r) => r.nodeId === anchor.nodeId);
1610
1618
  if (anchorPathArr && anchorIdx >= 0) {
1611
1619
  const isDescendant = (p) => Array.isArray(p) && p.length > anchorPathArr.length && anchorPathArr.every((v, k) => p[k] === v);
1620
+ const descendantListIds = new Set();
1621
+ let lastDescendantIdx = anchorIdx;
1612
1622
  for (let i = anchorIdx + 1; i < rows.length; i += 1) {
1613
1623
  const p = blockNumbering(rows[i])?.path;
1614
- if (isDescendant(p)) {
1624
+ const listId = listByNodeId.get(rows[i].nodeId ?? '');
1625
+ const foreign = !belongsToAnchorList(rows[i]) && listId != null;
1626
+ // A separate numbering definition counts on its own, so its path need
1627
+ // not start with the anchor's path ([1, 2] under [2]); go by depth.
1628
+ const nestedForeign = foreign && Array.isArray(p) && p.length > anchorPathArr.length;
1629
+ if (isDescendant(p) || nestedForeign) {
1630
+ if (foreign && !descendantListIds.has(listId)) {
1631
+ // Nested bullets can use a separate numbering definition. Its first
1632
+ // item must be contiguous; a new list after plain text is unrelated.
1633
+ if (i !== lastDescendantIdx + 1)
1634
+ break;
1635
+ descendantListIds.add(listId);
1636
+ }
1615
1637
  subtreeEnd = rows[i]; // a nested child/grandchild of the anchor
1638
+ lastDescendantIdx = i;
1616
1639
  }
1617
- else if (Array.isArray(p)) {
1640
+ else if (Array.isArray(p) || (blockNumbering(rows[i]) && !belongsToAnchorList(rows[i]))) {
1618
1641
  break; // a numbered item that is NOT a descendant → sub-tree ended
1619
1642
  }
1620
1643
  // else: non-numbered paragraph (a continuation) — skip and keep scanning
@@ -1632,30 +1655,55 @@ async function runAddListItems(doc, args, opts) {
1632
1655
  if (firstRelative < 0) {
1633
1656
  const firstTargetLevel = Math.max(0, anchorLevel + firstRelative);
1634
1657
  const startIdx = rows.findIndex((r) => r.nodeId === subtreeEnd.nodeId);
1658
+ const descendantListIds = new Set();
1659
+ let lastDescendantIdx = startIdx;
1635
1660
  for (let i = startIdx + 1; i >= 1 && i < rows.length; i += 1) {
1636
1661
  const level = levelOfRow(rows[i]);
1637
1662
  if (level == null)
1638
1663
  continue; // non-numbered continuation paragraph
1639
1664
  if (level <= firstTargetLevel)
1640
1665
  break;
1666
+ const listId = listByNodeId.get(rows[i].nodeId ?? '');
1667
+ if (!belongsToAnchorList(rows[i]) && listId != null && !descendantListIds.has(listId)) {
1668
+ if (i !== lastDescendantIdx + 1)
1669
+ break;
1670
+ descendantListIds.add(listId);
1671
+ }
1641
1672
  subtreeEnd = rows[i];
1673
+ lastDescendantIdx = i;
1642
1674
  }
1643
1675
  }
1676
+ const anchorKind = blockNumbering(anchor)?.kind ?? null;
1677
+ // Search nearest first within the destination level. A known list may mix
1678
+ // ordered and bullet levels. Keep the numbering-kind fallback only when
1679
+ // the paged snapshot cannot establish that both items share a list.
1680
+ const findSiblingAt = (level) => {
1681
+ const usable = (r) => !!r?.nodeId &&
1682
+ belongsToAnchorList(r) &&
1683
+ levelOfRow(r) === level &&
1684
+ ((anchorListId != null && listByNodeId.get(r.nodeId) === anchorListId) ||
1685
+ (blockNumbering(r)?.kind ?? null) === anchorKind);
1686
+ for (let delta = 1; delta <= rows.length; delta += 1) {
1687
+ const before = anchorIdx >= 0 ? rows[anchorIdx - delta] : undefined;
1688
+ if (usable(before))
1689
+ return before;
1690
+ const after = anchorIdx >= 0 ? rows[anchorIdx + delta] : undefined;
1691
+ if (usable(after))
1692
+ return after;
1693
+ }
1694
+ return undefined;
1695
+ };
1696
+ /** The existing item a new one should look like: same level, nearest first. */
1697
+ const formattingSourceForLevel = (targetLevel) => targetLevel === anchorLevel ? anchor : findSiblingAt(targetLevel);
1644
1698
  const attachFn = maybeMethod(doc, ['lists', 'attach']);
1645
- if (!attachFn) {
1646
- throw new SuperDocCliError('doc.lists.attach is not available on the document handle.', {
1699
+ const insertFn = maybeMethod(doc, ['lists', 'insert']);
1700
+ if (!attachFn || !insertFn) {
1701
+ throw new SuperDocCliError('doc.lists.insert and doc.lists.attach are required on the document handle.', {
1647
1702
  code: 'TOOL_DISPATCH_NOT_FOUND',
1648
1703
  });
1649
1704
  }
1650
1705
  const executed = [];
1651
- let placement = {
1652
- kind: 'after',
1653
- target: {
1654
- kind: 'block',
1655
- nodeType: subtreeEnd.nodeType ?? 'listItem',
1656
- nodeId: subtreeEnd.nodeId ?? anchor.nodeId,
1657
- },
1658
- };
1706
+ let insertionAnchorId = subtreeEnd.nodeId ?? anchor.nodeId;
1659
1707
  const createdItems = [];
1660
1708
  for (const entry of args.entries) {
1661
1709
  // Styled items carry ordered `runs`; a plain item is a single run. The
@@ -1668,11 +1716,25 @@ async function runAddListItems(doc, args, opts) {
1668
1716
  // outline level is clamped at 0 (can't go above the root).
1669
1717
  const rel = typeof entry?.level === 'number' ? Math.floor(entry.level) : 0;
1670
1718
  const targetLevel = Math.max(0, anchorLevel + rel);
1671
- const created = await executeCreateParagraph(doc, text, placement, args.changeMode);
1719
+ const formattingSource = formattingSourceForLevel(targetLevel) ?? anchor;
1720
+ // Placement can follow a child with different direction. Inherit from
1721
+ // the intended list level independently of that insertion point.
1722
+ const created = await insertFn({
1723
+ target: { kind: 'block', nodeType: 'listItem', nodeId: insertionAnchorId },
1724
+ position: 'after',
1725
+ text,
1726
+ ...(formattingSource.nodeId !== insertionAnchorId
1727
+ ? { inheritFrom: { kind: 'block', nodeType: 'listItem', nodeId: formattingSource.nodeId } }
1728
+ : {}),
1729
+ ...(args.changeMode ? { changeMode: args.changeMode } : {}),
1730
+ }, args.changeMode ? { changeMode: args.changeMode } : undefined);
1672
1731
  const target = createdBlockTarget(created);
1673
- executed.push({ operationId: 'doc.create.paragraph', result: created });
1674
- if (!target)
1675
- continue;
1732
+ if (!target || target.nodeType !== 'listItem') {
1733
+ throw new SuperDocCliError('doc.lists.insert did not return a created list item.', {
1734
+ code: 'ACTION_FAILED',
1735
+ });
1736
+ }
1737
+ executed.push({ operationId: 'doc.lists.insert', result: created });
1676
1738
  // changeMode is a MutationOption — pass it in the second options arg, not
1677
1739
  // the input (lists.attach reads options.changeMode; input.changeMode is ignored).
1678
1740
  const attachResult = await attachFn({
@@ -1688,53 +1750,10 @@ async function runAddListItems(doc, args, opts) {
1688
1750
  rationale: `level ${targetLevel}`,
1689
1751
  });
1690
1752
  createdItems.push({ nodeId: target.nodeId, targetLevel, text, runs, itemMarks: entry?.marks });
1691
- // After attach the block is a listItem (same nodeId) — the next placement
1692
- // must address it as such or the create resolver rejects the anchor.
1693
- placement = { kind: 'after', target: { kind: 'block', nodeType: 'listItem', nodeId: target.nodeId } };
1694
- }
1695
- // A new item is created next to the ANCHOR, so it inherits the anchor's
1696
- // direct indentation — correct at the same level, wrong at any other one.
1697
- // `lists.attach` sets the numbering level (`w:ilvl`) but leaves that direct
1698
- // `w:ind` alone, so a level-changing add renders with a sub-item's indent
1699
- // under a top-level number: e.g. an item promoted to level 0 kept
1700
- // left=1800 while its "1./2./3." siblings sit at 1080. Copy the indentation
1701
- // from an EXISTING sibling at the item's own level. Best-effort: an
1702
- // unmatched indent is cosmetic and must not fail an item that landed.
1703
- const createdIds = new Set(createdItems.map((ci) => ci.nodeId));
1704
- const anchorKind = blockNumbering(anchor)?.kind ?? null;
1705
- // Search OUTWARD from the anchor, nearest first, and only accept items
1706
- // of the anchor's own numbering kind: a document holds several lists,
1707
- // and the first level-0 item in document order usually belongs to a
1708
- // different one (here: a bullet list far above the ordered list being
1709
- // extended), whose indentation is unrelated.
1710
- const findSiblingAt = (level) => {
1711
- const usable = (r) => !!r?.nodeId &&
1712
- !createdIds.has(r.nodeId) &&
1713
- levelOfRow(r) === level &&
1714
- (blockNumbering(r)?.kind ?? null) === anchorKind;
1715
- for (let delta = 1; delta <= rows.length; delta += 1) {
1716
- const before = anchorIdx >= 0 ? rows[anchorIdx - delta] : undefined;
1717
- if (usable(before))
1718
- return before;
1719
- const after = anchorIdx >= 0 ? rows[anchorIdx + delta] : undefined;
1720
- if (usable(after))
1721
- return after;
1722
- }
1723
- return undefined;
1724
- };
1725
- /** The existing item a new one should look like: same level, nearest first. */
1726
- const lookSourceFor = (targetLevel) => targetLevel === anchorLevel ? anchor : findSiblingAt(targetLevel);
1727
- // Most Word documents keep a list's look in a paragraph STYLE, not in direct
1728
- // properties: "MB Agr 2", "Quick 1.", even Heading3 carry the numbering, the
1729
- // indentation and the face. A created paragraph does not get the sibling's
1730
- // style — it gets the anchor's `next` style (Heading3 → BodyText), a default
1731
- // (ListParagraph, whose contextualSpacing changes the spacing), or the
1732
- // anchor's own style when the new item belongs to a DIFFERENT level (keeping
1733
- // "MB Agr 3" on an item whose numbering now says level 2). Reconcile it with
1734
- // the sibling at the item's own level.
1735
- //
1736
- // setStyleRef, not setStyle: setStyle clears direct run formatting, which
1737
- // would wipe the sampled run look applied below.
1753
+ insertionAnchorId = target.nodeId;
1754
+ }
1755
+ // Insertion may select a style's `next` style; keep the destination list level's
1756
+ // style instead. setStyleRef preserves the inherited direct run formatting.
1738
1757
  const styledItems = [];
1739
1758
  const styleSkipped = await bestEffort(async () => {
1740
1759
  const setStyleRefFn = maybeMethod(doc, ['styles', 'paragraph', 'setStyleRef']);
@@ -1743,7 +1762,7 @@ async function runAddListItems(doc, args, opts) {
1743
1762
  return;
1744
1763
  let current = null;
1745
1764
  for (const ci of createdItems) {
1746
- const source = lookSourceFor(ci.targetLevel);
1765
+ const source = formattingSourceForLevel(ci.targetLevel);
1747
1766
  if (!source)
1748
1767
  continue;
1749
1768
  const wanted = source.styleId ?? null;
@@ -1761,6 +1780,8 @@ async function runAddListItems(doc, args, opts) {
1761
1780
  styledItems.push({ nodeId: ci.nodeId, styleId: wanted });
1762
1781
  }
1763
1782
  });
1783
+ // lists.attach changes numbering level without normalizing direct indentation.
1784
+ // Match the existing destination level when its indentation is available.
1764
1785
  const indentedItems = [];
1765
1786
  const indentSkipped = await bestEffort(async () => {
1766
1787
  const setIndentFn = maybeMethod(doc, ['format', 'paragraph', 'setIndentation']);
@@ -1768,7 +1789,7 @@ async function runAddListItems(doc, args, opts) {
1768
1789
  if (!setIndentFn || !getNodeFn)
1769
1790
  return;
1770
1791
  for (const ci of createdItems) {
1771
- const sibling = lookSourceFor(ci.targetLevel);
1792
+ const sibling = formattingSourceForLevel(ci.targetLevel);
1772
1793
  if (!sibling?.nodeId)
1773
1794
  continue;
1774
1795
  const node = (await getNodeFn({ id: sibling.nodeId }));
@@ -1851,7 +1872,7 @@ async function runAddListItems(doc, args, opts) {
1851
1872
  // The BASE layer must come from the row the item will sit beside, not
1852
1873
  // from the anchor: a promoted or demoted item crosses levels, and the
1853
1874
  // anchor's font/size/colour/emphasis is then the wrong level's look.
1854
- // `lookSourceFor(targetLevel)` already picks the destination sibling for
1875
+ // `formattingSourceForLevel(targetLevel)` already picks the destination sibling for
1855
1876
  // style and indent, so the look follows the same source. Falls back to
1856
1877
  // the anchor only when the destination level has no sibling to sample.
1857
1878
  const anchorBase = inlineLookFromRow(anchor);
@@ -1889,7 +1910,7 @@ async function runAddListItems(doc, args, opts) {
1889
1910
  // An item the agent styled itself is left alone by layers 1–2: its
1890
1911
  // marks describe the whole intent, and sampling would fight them.
1891
1912
  const unstyled = ci.itemMarks == null && !ci.runs.some((r) => r.marks);
1892
- const source = unstyled ? lookSourceFor(ci.targetLevel) : undefined;
1913
+ const source = unstyled ? formattingSourceForLevel(ci.targetLevel) : undefined;
1893
1914
  const sourceText = source?.nodeId ? fullTextById.get(source.nodeId) : undefined;
1894
1915
  const pattern = source?.nodeId && sourceText ? await listItemRunPattern(doc, sourceText, source.nodeId) : null;
1895
1916
  // 1. sampled base over the whole item (row fields first, run-level
@@ -1969,7 +1990,7 @@ async function runAddListItems(doc, args, opts) {
1969
1990
  // promotion keeps the plain marker until that read lands.
1970
1991
  let markProps = markRunPropsFromMarks({ ...ci.itemMarks, ...ci.runs[0]?.marks });
1971
1992
  if (!markProps && getNodeFn) {
1972
- const source = lookSourceFor(ci.targetLevel);
1993
+ const source = formattingSourceForLevel(ci.targetLevel);
1973
1994
  if (source?.nodeId)
1974
1995
  markProps = paragraphMarkRunPropsOf(await getNodeFn({ id: source.nodeId }));
1975
1996
  }
@@ -3434,18 +3455,28 @@ function findRanges(haystack, needle, caseSensitive) {
3434
3455
  const ranges = [];
3435
3456
  if (needle.length === 0)
3436
3457
  return ranges;
3437
- const source = caseSensitive ? haystack : haystack.toLocaleLowerCase();
3438
- const target = caseSensitive ? needle : needle.toLocaleLowerCase();
3439
- let offset = 0;
3440
- while (offset < source.length) {
3441
- const idx = source.indexOf(target, offset);
3442
- if (idx < 0)
3443
- break;
3444
- ranges.push({ start: idx, end: idx + target.length });
3445
- offset = idx + target.length;
3458
+ if (caseSensitive) {
3459
+ let offset = 0;
3460
+ while (offset < haystack.length) {
3461
+ const idx = haystack.indexOf(needle, offset);
3462
+ if (idx < 0)
3463
+ break;
3464
+ ranges.push({ start: idx, end: idx + needle.length });
3465
+ offset = idx + needle.length;
3466
+ }
3467
+ return ranges;
3468
+ }
3469
+ // Match case-insensitively on the original string. Lowercasing a copy first
3470
+ // changes its length for characters such as "İ", which shifts every later
3471
+ // offset onto the wrong text.
3472
+ for (const match of haystack.matchAll(caseInsensitivePattern(needle))) {
3473
+ ranges.push({ start: match.index, end: match.index + match[0].length });
3446
3474
  }
3447
3475
  return ranges;
3448
3476
  }
3477
+ function caseInsensitivePattern(needle) {
3478
+ return new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'giu');
3479
+ }
3449
3480
  async function runApplyLetterSpacing(doc, args) {
3450
3481
  const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3451
3482
  try {
@@ -4276,29 +4307,64 @@ function trackedChangeRefIds(result) {
4276
4307
  return entityId ? [entityId] : [];
4277
4308
  }));
4278
4309
  }
4279
- function parseScopedReplaceArgs(args, changeMode) {
4280
- if (args.action !== 'replace_text')
4281
- return null;
4310
+ function parseScopedReplaceArgs(args, edits, changeMode, expectedRevision) {
4282
4311
  if (!isRecord(args.selector))
4283
4312
  return null;
4284
- if (!Array.isArray(args.edits) || args.edits.length === 0)
4285
- return null;
4286
- const edits = [];
4287
- for (const entry of args.edits) {
4288
- if (!isRecord(entry) || typeof entry.find !== 'string' || entry.find.length === 0)
4289
- return null;
4290
- edits.push({ find: entry.find, replace: typeof entry.replace === 'string' ? entry.replace : '' });
4291
- }
4292
4313
  return {
4293
4314
  edits,
4294
4315
  selector: args.selector,
4295
4316
  caseSensitive: args.caseSensitive === true,
4317
+ expectedRevision,
4296
4318
  changeMode,
4297
4319
  };
4298
4320
  }
4321
+ function assertExpectedReplacementRevision(expected, actual) {
4322
+ if (expected != null && expected !== actual) {
4323
+ throw new SuperDocCliError('replace_text: expectedRevision is stale. Inspect this live document again before editing.', {
4324
+ code: 'REVISION_CONFLICT',
4325
+ });
4326
+ }
4327
+ }
4328
+ // Ranges of the block text that may show a body field's result. Field
4329
+ // results carry no offsets, so every place the result text occurs counts,
4330
+ // overlapping ones included. A field whose result text is not reported (some
4331
+ // imported fields expose an empty result while displaying cached text) covers
4332
+ // the whole block. Returns null when the field catalog cannot be read.
4333
+ async function fieldResultRanges(doc, nodeId, blockText) {
4334
+ const fn = maybeMethod(doc, ['fields', 'list']);
4335
+ if (!fn)
4336
+ return null;
4337
+ const results = [];
4338
+ const limit = 500;
4339
+ for (let offset = 0; offset < 100_000; offset += limit) {
4340
+ const page = await fn({ limit, offset });
4341
+ const items = isRecord(page) && Array.isArray(page.items) ? page.items.filter(isRecord) : [];
4342
+ for (const item of items) {
4343
+ const address = isRecord(item.address) ? item.address : null;
4344
+ const inBody = typeof address?.storyId !== 'string' || address.storyId.startsWith('main:');
4345
+ if (address?.blockId !== nodeId || !inBody)
4346
+ continue;
4347
+ if (typeof item.resolvedText !== 'string' || item.resolvedText.length === 0) {
4348
+ return [{ start: 0, end: blockText.length }];
4349
+ }
4350
+ results.push(item.resolvedText);
4351
+ }
4352
+ const total = isRecord(page) && typeof page.total === 'number' ? page.total : null;
4353
+ if (items.length < limit || (total != null && offset + limit >= total))
4354
+ break;
4355
+ }
4356
+ const ranges = [];
4357
+ for (const result of results) {
4358
+ for (let at = blockText.indexOf(result); at >= 0; at = blockText.indexOf(result, at + 1)) {
4359
+ ranges.push({ start: at, end: at + result.length });
4360
+ }
4361
+ }
4362
+ return ranges;
4363
+ }
4299
4364
  async function runScopedReplaceUnchecked(doc, args) {
4300
4365
  assertReplacementTextPayloads(args.edits);
4301
4366
  const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4367
+ assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
4302
4368
  const matched = resolveSnapshotSelector(pre, args.selector);
4303
4369
  if (matched.length !== 1) {
4304
4370
  const failure = describeSelectorFailure(args.selector, matched, pre, args.edits.map((e) => e.find));
@@ -4318,13 +4384,24 @@ async function runScopedReplaceUnchecked(doc, args) {
4318
4384
  };
4319
4385
  }
4320
4386
  const caseSensitive = args.caseSensitive === true;
4387
+ const hasExplicitOccurrence = args.edits.some((edit) => edit.occurrence != null);
4321
4388
  const perEdit = [];
4322
4389
  const allSpans = [];
4323
4390
  for (const edit of args.edits) {
4324
- const spans = findRanges(block.text, edit.find, caseSensitive);
4325
- perEdit.push({ find: edit.find, occurrences: spans.length });
4391
+ const matches = findRanges(block.text, edit.find, caseSensitive);
4392
+ const spans = edit.occurrence == null ? matches : matches.slice(edit.occurrence - 1, edit.occurrence);
4393
+ if (hasExplicitOccurrence && spans.length === 0) {
4394
+ return failedReceipt('replace_text', new SuperDocCliError(edit.occurrence == null
4395
+ ? `replace_text: ${JSON.stringify(edit.find)} not found in the selected block. Inspect and choose a valid target.`
4396
+ : `replace_text: ${JSON.stringify(edit.find)} occurrence ${edit.occurrence} not found in the selected block (${matches.length} matches). Inspect and choose a valid target.`, { code: 'MATCH_NOT_FOUND' }), pre);
4397
+ }
4398
+ perEdit.push({
4399
+ find: edit.find,
4400
+ occurrences: spans.length,
4401
+ ...(edit.occurrence != null ? { occurrence: edit.occurrence } : {}),
4402
+ });
4326
4403
  for (const span of spans)
4327
- allSpans.push({ ...span, replace: edit.replace ?? '', find: edit.find });
4404
+ allSpans.push({ ...span, replace: edit.replace, find: edit.find });
4328
4405
  }
4329
4406
  const appliedEdits = perEdit.filter((e) => e.occurrences > 0);
4330
4407
  const skippedEdits = perEdit.filter((e) => e.occurrences === 0);
@@ -4352,45 +4429,82 @@ async function runScopedReplaceUnchecked(doc, args) {
4352
4429
  let lastEnd = -1;
4353
4430
  for (const span of allSpans) {
4354
4431
  if (span.start < lastEnd) {
4432
+ if (hasExplicitOccurrence) {
4433
+ return failedReceipt('replace_text', new SuperDocCliError('replace_text: explicit occurrence targets overlap. Choose non-overlapping edits in the pre-edit block.', { code: 'INVALID_ARGUMENT' }), pre);
4434
+ }
4355
4435
  droppedOverlaps.push(span);
4356
4436
  continue;
4357
4437
  }
4358
4438
  planned.push(span);
4359
4439
  lastEnd = span.end;
4360
4440
  }
4361
- // The v2 engine resolves `where` by block/select/ref only — a
4362
- // selection-targeted step ("by: 'target'") dies in resolveStepRef with
4363
- // "requires a ref produced by query.match/find".
4441
+ // The block text once every planned span is replaced: the whole-block
4442
+ // rewrite writes it, and exact-target writes must produce it.
4364
4443
  const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
4365
4444
  const tracked = (args.changeMode ?? 'direct') === 'tracked';
4366
4445
  // Unique tracked finds can be resolved within the block. Repeated finds
4367
4446
  // use one whole-block rewrite, whose engine diff preserves unchanged runs.
4368
- let result = null;
4369
- // Dedupe on the same basis the selector will match on: under a
4370
- // case-sensitive request `Foo` and `foo` are distinct targets, and folding
4371
- // them together would push a legitimately unique pair onto the fallback.
4447
+ // Dedupe on the same basis the planner matched on: under a case-sensitive
4448
+ // request `Foo` and `foo` are distinct targets, and folding them together
4449
+ // would push a legitimately unique pair onto the fallback.
4372
4450
  const uniqueFinds = new Set(planned.map((s) => (caseSensitive ? s.find : s.find.toLowerCase())));
4373
- if (tracked && uniqueFinds.size === planned.length) {
4374
- const spanSteps = planned.map((span, index) => ({
4451
+ // Explicit occurrences and unique tracked finds are written at the offsets
4452
+ // matched above. A whole-block diff could redline a different identical
4453
+ // occurrence while producing the same final text, and the engine's own text
4454
+ // search would match again with its own case rules.
4455
+ // A direct deletion through an exact target that touches a field deletes
4456
+ // the whole field. Such deletions keep the whole-block rewrite, whose
4457
+ // planned text is exact for explicit occurrences too; every other span keeps
4458
+ // its exact target and with it the chosen occurrence and its formatting.
4459
+ const deletions = tracked ? [] : planned.filter((span) => span.replace.length === 0);
4460
+ const fieldRanges = deletions.length > 0 ? await fieldResultRanges(doc, nodeId, block.text) : [];
4461
+ const fieldDeletionRisk = fieldRanges == null ||
4462
+ deletions.some((span) => fieldRanges.some((range) => span.start < range.end && span.end > range.start));
4463
+ const exactTargets = !fieldDeletionRisk && (hasExplicitOccurrence || (tracked && uniqueFinds.size === planned.length));
4464
+ let result = null;
4465
+ let wroteExactTargets = false;
4466
+ if (exactTargets) {
4467
+ // A span already equal to its replacement has nothing to write, and the
4468
+ // engine refuses a no-op target, which would sink its sibling edits.
4469
+ const targetedSpans = [...planned]
4470
+ .reverse()
4471
+ .filter((span) => block.text.slice(span.start, span.end) !== span.replace);
4472
+ const steps = targetedSpans.map((span, index) => ({
4375
4473
  id: `scoped-replace-${index + 1}`,
4376
4474
  op: 'text.rewrite',
4377
4475
  where: {
4378
- by: 'select',
4379
- // The planner honours caseSensitive, so the selector must match on the
4380
- // same terms. Without it a case-sensitive request can redline a
4381
- // differently cased occurrence and still verify, because verification
4382
- // only looks for the replacement text somewhere in the block.
4383
- select: { type: 'text', pattern: span.find, mode: 'contains', caseSensitive },
4384
- require: 'first',
4385
- within: { kind: 'block', nodeType: block.nodeType, nodeId },
4476
+ by: 'target',
4477
+ target: {
4478
+ kind: 'selection',
4479
+ start: { kind: 'text', blockId: nodeId, offset: span.start },
4480
+ end: { kind: 'text', blockId: nodeId, offset: span.end },
4481
+ },
4386
4482
  },
4387
4483
  args: { replacement: { text: span.replace }, style: preserveRewriteStyle() },
4388
4484
  }));
4389
- try {
4390
- result = await executeMutations(doc, spanSteps, args.changeMode, pre.revision);
4485
+ if (steps.length === 0) {
4486
+ result = { noop: true };
4391
4487
  }
4392
- catch {
4393
- result = null; // select lane unavailable (e.g. case mismatch) — fall back
4488
+ else {
4489
+ // The engine resolves these offsets against its own paragraph text. Check
4490
+ // that each one still covers the text matched in the snapshot, so a
4491
+ // projection mismatch never rewrites other text.
4492
+ const preview = await previewMutations(doc, steps, args.changeMode, pre.revision);
4493
+ const previewSteps = Array.isArray(preview.steps) ? preview.steps.filter(isRecord) : [];
4494
+ const aligned = preview.valid === true &&
4495
+ steps.every((step, index) => {
4496
+ const resolutions = previewSteps.find((candidate) => candidate.stepId === step.id)?.resolutions;
4497
+ const resolved = Array.isArray(resolutions) && isRecord(resolutions[0]) ? resolutions[0].text : undefined;
4498
+ const span = targetedSpans[index];
4499
+ return resolved === block.text.slice(span.start, span.end);
4500
+ });
4501
+ if (aligned) {
4502
+ result = await executeMutations(doc, steps, args.changeMode, pre.revision);
4503
+ wroteExactTargets = true;
4504
+ }
4505
+ else if (hasExplicitOccurrence) {
4506
+ return failedReceipt('replace_text', new SuperDocCliError('replace_text: the chosen occurrence does not resolve to the same text in the document. Inspect again before editing.', { code: 'TARGET_NOT_FOUND' }), pre);
4507
+ }
4394
4508
  }
4395
4509
  }
4396
4510
  if (result == null) {
@@ -4406,12 +4520,34 @@ async function runScopedReplaceUnchecked(doc, args) {
4406
4520
  ];
4407
4521
  result = await executeMutations(doc, steps, args.changeMode, pre.revision);
4408
4522
  }
4409
- const post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4410
- const postBlock = findTextTarget(post, nodeId);
4411
- // In tracked mode the deleted text is still present in the block, so only
4412
- // require the inserted text to be visible.
4523
+ let post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4524
+ let postBlock = findTextTarget(post, nodeId);
4525
+ // Exact-target edits must leave exactly the planned visible text. If the
4526
+ // engine changed more than the requested spans, undo the edit, which is one
4527
+ // atomic history step. Only do so while the document is still at the
4528
+ // revision this plan produced, so the undo cannot reach any other change.
4529
+ let rolledBack = false;
4530
+ const appliedRevision = isRecord(result) && isRecord(result.revision) ? result.revision.after : undefined;
4531
+ if (wroteExactTargets &&
4532
+ postBlock != null &&
4533
+ postBlock.text !== expected &&
4534
+ postBlock.text !== block.text &&
4535
+ typeof appliedRevision === 'string' &&
4536
+ appliedRevision === post.revision) {
4537
+ const undoFn = maybeMethod(doc, ['history', 'undo']);
4538
+ if (undoFn) {
4539
+ await undoFn({});
4540
+ post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4541
+ postBlock = findTextTarget(post, nodeId);
4542
+ rolledBack = postBlock?.text === block.text;
4543
+ }
4544
+ }
4545
+ // Block text is the visible (final) text. Exact-offset edits must produce
4546
+ // exactly the planned text; tracked edits that fell back to a whole-block
4547
+ // rewrite only need each inserted text to be visible.
4413
4548
  const passed = postBlock != null &&
4414
- (tracked
4549
+ !rolledBack &&
4550
+ (tracked && !hasExplicitOccurrence && !wroteExactTargets
4415
4551
  ? planned.every((span) => span.replace.length === 0 || postBlock.text.includes(span.replace))
4416
4552
  : postBlock.text === expected);
4417
4553
  // Partially-applied requests must NOT read as success: a model that asked
@@ -4427,6 +4563,16 @@ async function runScopedReplaceUnchecked(doc, args) {
4427
4563
  recovery: { kind: 'retry' },
4428
4564
  }
4429
4565
  : {}),
4566
+ ...(rolledBack
4567
+ ? {
4568
+ errors: [
4569
+ {
4570
+ code: 'ACTION_FAILED',
4571
+ message: 'replace_text: the engine changed more than the requested text, so the edit was undone. Inspect the block and retry with a larger find or rewrite the whole block.',
4572
+ },
4573
+ ],
4574
+ }
4575
+ : {}),
4430
4576
  preSnapshot: { revision: pre.revision },
4431
4577
  postSnapshot: { revision: post.revision },
4432
4578
  selectedBlock: { nodeId, nodeType: block.nodeType },
@@ -6212,12 +6358,21 @@ async function dispatchSuperdocPerformAction(doc, args) {
6212
6358
  code: 'INVALID_ARGUMENT',
6213
6359
  });
6214
6360
  }
6361
+ const expectedRevision = args.expectedRevision;
6362
+ if (expectedRevision !== undefined && (typeof expectedRevision !== 'string' || expectedRevision.length === 0)) {
6363
+ throw new SuperDocCliError('replace_text: expectedRevision must be a non-empty string from this live document handle', { code: 'INVALID_ARGUMENT' });
6364
+ }
6365
+ if (edits.some((edit) => edit.occurrence != null) && !isRecord(args.selector)) {
6366
+ throw new SuperDocCliError('replace_text: explicit occurrence requires a selector resolving to one block', {
6367
+ code: 'INVALID_ARGUMENT',
6368
+ });
6369
+ }
6215
6370
  const changeMode = parseReplaceTextChangeMode(args.changeMode);
6216
6371
  // Selector-scoped replaces take the span-targeted path: per-find
6217
6372
  // text.rewrite spans inside the one selected block, so tabs/breaks/
6218
6373
  // images outside the spans survive and the receipt reports per-edit
6219
6374
  // applied/skipped truth.
6220
- const scoped = parseScopedReplaceArgs(args, changeMode);
6375
+ const scoped = parseScopedReplaceArgs(args, edits, changeMode, expectedRevision);
6221
6376
  if (scoped) {
6222
6377
  return runScopedReplace(doc, scoped);
6223
6378
  }
@@ -6230,6 +6385,7 @@ async function dispatchSuperdocPerformAction(doc, args) {
6230
6385
  edits,
6231
6386
  selector,
6232
6387
  caseSensitive: args.caseSensitive === true,
6388
+ expectedRevision,
6233
6389
  changeMode,
6234
6390
  });
6235
6391
  }
@@ -6911,7 +7067,12 @@ function parseEdits(value) {
6911
7067
  const replace = asString(entry.replace) ?? '';
6912
7068
  if (!find)
6913
7069
  return undefined;
6914
- result.push({ find, replace });
7070
+ const occurrence = entry.occurrence;
7071
+ if (occurrence !== undefined &&
7072
+ (typeof occurrence !== 'number' || !Number.isSafeInteger(occurrence) || occurrence < 1)) {
7073
+ throw new SuperDocCliError('replace_text: occurrence must be a positive safe integer (one-based within the selected block)', { code: 'INVALID_ARGUMENT' });
7074
+ }
7075
+ result.push({ find, replace, ...(occurrence !== undefined ? { occurrence } : {}) });
6915
7076
  }
6916
7077
  return result;
6917
7078
  }