@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.
- package/dist/agent/action-descriptors.json +2 -2
- package/dist/agent/actions.cjs +295 -134
- package/dist/agent/actions.d.ts +3 -0
- package/dist/agent/actions.js +295 -134
- package/dist/agent/catalog.cjs +15 -1
- package/dist/agent/catalog.js +15 -1
- package/dist/embedded-prompts.generated.cjs +2 -2
- package/dist/embedded-prompts.generated.js +2 -2
- package/dist/embedded-tools.generated.cjs +7 -7
- package/dist/embedded-tools.generated.js +7 -7
- package/dist/generated/client.d.ts +1538 -494
- package/dist/generated/contract.cjs +1 -1
- package/dist/generated/contract.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/prompts/mcp-prompt.md +2 -0
- package/dist/prompts/system-prompt.md +1 -1
- package/dist/runtime/sdk-version.generated.cjs +1 -1
- package/dist/runtime/sdk-version.generated.d.ts +1 -1
- package/dist/runtime/sdk-version.generated.js +1 -1
- package/package.json +6 -6
- package/tools/catalog.json +171 -3
- package/tools/generation-receipt.json +24 -24
- package/tools/prompt-templates/system-prompt-core.md +1 -0
- package/tools/system-prompt-mcp.md +1 -0
- package/tools/system-prompt.md +1 -0
- package/tools/tools-policy.json +1 -1
- package/tools/tools.anthropic.json +171 -3
- package/tools/tools.generic.json +171 -3
- package/tools/tools.openai.json +171 -3
- package/tools/tools.vercel.json +171 -3
package/dist/agent/actions.cjs
CHANGED
|
@@ -165,13 +165,15 @@ function findSnapshotTextByNodeId(snapshot, nodeId) {
|
|
|
165
165
|
}
|
|
166
166
|
function createdBlockTarget(result) {
|
|
167
167
|
const rec = asRecord(result);
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
605
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1649
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
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
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
// `
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
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
|
|
4328
|
-
|
|
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
|
|
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
|
|
4365
|
-
//
|
|
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
|
-
|
|
4372
|
-
//
|
|
4373
|
-
//
|
|
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
|
-
|
|
4377
|
-
|
|
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: '
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
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
|
-
|
|
4393
|
-
result =
|
|
4488
|
+
if (steps.length === 0) {
|
|
4489
|
+
result = { noop: true };
|
|
4394
4490
|
}
|
|
4395
|
-
|
|
4396
|
-
|
|
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
|
-
|
|
4413
|
-
|
|
4414
|
-
//
|
|
4415
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|