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