@superdoc/sdk 2.24.0 → 2.25.0-next.10
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-change-mode.cjs +6 -0
- package/dist/agent/action-change-mode.d.ts +1 -0
- package/dist/agent/action-change-mode.js +5 -0
- package/dist/agent/action-descriptors.json +2 -2
- package/dist/agent/actions.cjs +886 -530
- package/dist/agent/actions.d.ts +2 -0
- package/dist/agent/actions.js +887 -531
- package/dist/agent/catalog.cjs +5 -0
- package/dist/agent/catalog.js +5 -0
- package/dist/agent/doc-snapshot.cjs +10 -6
- package/dist/agent/doc-snapshot.d.ts +4 -1
- package/dist/agent/doc-snapshot.js +10 -6
- package/dist/agent/ir.d.ts +3 -0
- package/dist/embedded-prompts.generated.cjs +2 -1
- package/dist/embedded-prompts.generated.js +2 -1
- package/dist/embedded-tools.generated.cjs +5 -5
- package/dist/embedded-tools.generated.js +5 -5
- package/dist/generated/client.d.ts +1001 -6
- package/dist/generated/contract.cjs +1 -1
- package/dist/generated/contract.js +1 -1
- package/dist/presets/core.cjs +2 -1
- package/dist/presets/core.d.ts +1 -0
- package/dist/presets/core.js +2 -2
- package/dist/presets/full.cjs +1804 -0
- package/dist/presets/full.d.ts +39 -0
- package/dist/presets/full.js +1795 -0
- package/dist/presets.cjs +2 -0
- package/dist/presets.js +2 -0
- package/dist/prompts/full-addendum.md +40 -0
- package/dist/prompts/system-prompt.md +3 -2
- package/dist/runtime/document-rpc.cjs +3 -8
- package/dist/runtime/document-rpc.js +4 -9
- package/dist/runtime/host.cjs +1 -0
- package/dist/runtime/host.js +2 -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/dist/runtime/tracked-support.cjs +11 -0
- package/dist/runtime/tracked-support.d.ts +6 -0
- package/dist/runtime/tracked-support.js +7 -0
- package/dist/runtime/transport-common.cjs +24 -0
- package/dist/runtime/transport-common.d.ts +1 -0
- package/dist/runtime/transport-common.js +23 -0
- package/package.json +6 -6
- package/tools/catalog.json +23 -0
- package/tools/generation-receipt.json +20 -19
- package/tools/tools-policy.json +1 -1
- package/tools/tools.anthropic.json +23 -0
- package/tools/tools.generic.json +23 -0
- package/tools/tools.openai.json +23 -0
- package/tools/tools.vercel.json +23 -0
package/dist/agent/actions.cjs
CHANGED
|
@@ -5,6 +5,7 @@ var executionContext = require('./execution-context.cjs');
|
|
|
5
5
|
var actionChangeMode = require('./action-change-mode.cjs');
|
|
6
6
|
var contract = require('../generated/contract.cjs');
|
|
7
7
|
var errors = require('../runtime/errors.cjs');
|
|
8
|
+
var trackedSupport = require('../runtime/tracked-support.cjs');
|
|
8
9
|
var listTransform = require('../action-primitives/tools/list-transform.cjs');
|
|
9
10
|
var structureInsert = require('../action-primitives/tools/structure-insert.cjs');
|
|
10
11
|
var textTransform = require('../action-primitives/tools/text-transform.cjs');
|
|
@@ -16,11 +17,7 @@ function projectActionDefinitions(project) {
|
|
|
16
17
|
return Object.fromEntries(Object.entries(ACTION_DEFINITIONS).map(([name, definition]) => [name, project(definition)]));
|
|
17
18
|
}
|
|
18
19
|
function contractSupportsTrackedOperation(operationId) {
|
|
19
|
-
|
|
20
|
-
return (operation?.trackedSupport === 'always' ||
|
|
21
|
-
operation?.trackedSupport === 'conditional' ||
|
|
22
|
-
operation?.supportsTrackedMode === true ||
|
|
23
|
-
operation?.supportsConditionalTrackedMode === true);
|
|
20
|
+
return trackedSupport.operationAcceptsTrackedMode(contract.CONTRACT.operations[`doc.${operationId}`]);
|
|
24
21
|
}
|
|
25
22
|
function deriveActionDisposition(definition) {
|
|
26
23
|
if (definition.trackedOverrideReason)
|
|
@@ -175,6 +172,13 @@ function createdBlockTarget(result) {
|
|
|
175
172
|
}
|
|
176
173
|
return null;
|
|
177
174
|
}
|
|
175
|
+
function placementMissMessage(selector, matchCount) {
|
|
176
|
+
if (selector.kind === 'textSearch' && matchCount === 0 && (selector.occurrence ?? 1) === 1) {
|
|
177
|
+
const text = selector.terms.map((term) => `"${term}"`).join(selector.match === 'any' ? ' or ' : ' and ');
|
|
178
|
+
return `no block contains ${text}; re-inspect and quote the exact text`;
|
|
179
|
+
}
|
|
180
|
+
return 'placement selector did not resolve to a unique body block';
|
|
181
|
+
}
|
|
178
182
|
function resolvePlacement(placement, snapshot) {
|
|
179
183
|
if (!placement || placement.at === 'document_end')
|
|
180
184
|
return { kind: 'documentEnd' };
|
|
@@ -182,10 +186,7 @@ function resolvePlacement(placement, snapshot) {
|
|
|
182
186
|
return { kind: 'documentStart' };
|
|
183
187
|
const target = selectorToBlockTarget(placement.selector, snapshot);
|
|
184
188
|
if (!target) {
|
|
185
|
-
throw new errors.SuperDocCliError(
|
|
186
|
-
code: 'INVALID_ARGUMENT',
|
|
187
|
-
details: { placement },
|
|
188
|
-
});
|
|
189
|
+
throw new errors.SuperDocCliError(placementMissMessage(placement.selector, docSnapshot.resolveSnapshotSelector(snapshot, placement.selector).length), { code: 'INVALID_ARGUMENT', details: { placement } });
|
|
189
190
|
}
|
|
190
191
|
return {
|
|
191
192
|
kind: placement.at === 'before' ? 'before' : 'after',
|
|
@@ -647,14 +648,20 @@ async function executeMutations(doc, steps, changeMode, expectedRevision) {
|
|
|
647
648
|
...(expectedRevision ? { expectedRevision } : {}),
|
|
648
649
|
});
|
|
649
650
|
}
|
|
650
|
-
async function previewMutations(doc, steps, changeMode, expectedRevision) {
|
|
651
|
+
async function previewMutations(doc, steps, changeMode, expectedRevision, detail) {
|
|
651
652
|
const fn = maybeMethod(doc, ['mutations', 'preview']);
|
|
652
653
|
if (!fn) {
|
|
653
654
|
throw new errors.SuperDocCliError('doc.mutations.preview is not available on the document handle.', {
|
|
654
655
|
code: 'TOOL_DISPATCH_NOT_FOUND',
|
|
655
656
|
});
|
|
656
657
|
}
|
|
657
|
-
const result = await fn({
|
|
658
|
+
const result = await fn({
|
|
659
|
+
atomic: true,
|
|
660
|
+
changeMode: changeMode ?? 'direct',
|
|
661
|
+
expectedRevision,
|
|
662
|
+
steps,
|
|
663
|
+
...(detail ? { detail } : {}),
|
|
664
|
+
});
|
|
658
665
|
return guards.isRecord(result) ? result : {};
|
|
659
666
|
}
|
|
660
667
|
async function executeCreateTable(doc, args, placement) {
|
|
@@ -976,185 +983,229 @@ async function runInsertHeading(doc, args) {
|
|
|
976
983
|
return failedReceipt('insert_heading', err, pre);
|
|
977
984
|
}
|
|
978
985
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
let applyExpectedRevision = args.expectedRevision != null ? pre.revision : undefined;
|
|
997
|
-
if (args.selector) {
|
|
998
|
-
const target = selectorToBlockTarget(args.selector, pre);
|
|
999
|
-
if (!target) {
|
|
1000
|
-
return failedReceipt('replace_text', new Error('selector did not resolve to a unique body block'), pre);
|
|
1001
|
-
}
|
|
1002
|
-
let rewrittenText = target.text;
|
|
1003
|
-
for (const edit of args.edits) {
|
|
1004
|
-
if (!textIncludes(rewrittenText, edit.find, caseSensitive)) {
|
|
1005
|
-
return {
|
|
1006
|
-
status: 'failed',
|
|
1007
|
-
intent: 'replace_text',
|
|
1008
|
-
preSnapshot: { revision: pre.revision, counts: pre.counts },
|
|
1009
|
-
selectedTargets: [{ selector: args.selector, matched: [target.nodeId] }],
|
|
1010
|
-
executedOperations: [],
|
|
1011
|
-
verification: [],
|
|
1012
|
-
errors: [
|
|
1013
|
-
{
|
|
1014
|
-
code: 'ACTION_FAILED',
|
|
1015
|
-
message: `selected block does not contain ${JSON.stringify(edit.find)}`,
|
|
1016
|
-
},
|
|
1017
|
-
],
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
rewrittenText = replaceAllText(rewrittenText, edit.find, edit.replace, caseSensitive);
|
|
1021
|
-
}
|
|
1022
|
-
steps = [
|
|
1023
|
-
{
|
|
1024
|
-
id: 'replace-text-in-block-1',
|
|
1025
|
-
op: 'text.rewrite',
|
|
1026
|
-
where: { by: 'block', nodeType: target.nodeType, nodeId: target.nodeId },
|
|
1027
|
-
args: {
|
|
1028
|
-
replacement: { text: rewrittenText },
|
|
1029
|
-
style: preserveRewriteStyle(),
|
|
1030
|
-
},
|
|
1031
|
-
},
|
|
1032
|
-
];
|
|
1033
|
-
applyExpectedRevision = pre.revision;
|
|
1034
|
-
selectedTargets.push({ selector: args.selector, matched: [target.nodeId] });
|
|
1035
|
-
}
|
|
1036
|
-
else {
|
|
1037
|
-
let candidates = args.edits.map((edit, editIndex) => ({
|
|
1038
|
-
edit,
|
|
1039
|
-
step: {
|
|
1040
|
-
id: `replace-${editIndex + 1}`,
|
|
1041
|
-
op: 'text.rewrite',
|
|
1042
|
-
where: {
|
|
1043
|
-
by: 'select',
|
|
1044
|
-
select: {
|
|
1045
|
-
type: 'text',
|
|
1046
|
-
pattern: edit.find,
|
|
1047
|
-
mode: 'contains',
|
|
1048
|
-
caseSensitive,
|
|
1049
|
-
},
|
|
1050
|
-
require: 'all',
|
|
1051
|
-
},
|
|
1052
|
-
args: {
|
|
1053
|
-
replacement: { text: edit.replace },
|
|
1054
|
-
style: preserveRewriteStyle(),
|
|
1055
|
-
},
|
|
1056
|
-
},
|
|
1057
|
-
}));
|
|
1058
|
-
if (candidates.length > 1) {
|
|
1059
|
-
applyExpectedRevision = pre.revision;
|
|
1060
|
-
while (candidates.length > 0) {
|
|
1061
|
-
const preview = await previewMutations(doc, candidates.map((candidate) => candidate.step), args.changeMode, pre.revision);
|
|
1062
|
-
if (preview.valid === true)
|
|
1063
|
-
break;
|
|
1064
|
-
const failures = Array.isArray(preview.failures) ? preview.failures.filter(guards.isRecord) : [];
|
|
1065
|
-
const absent = failures.find((failure) => failure.code === 'MATCH_NOT_FOUND' && typeof failure.stepId === 'string');
|
|
1066
|
-
if (!absent) {
|
|
1067
|
-
throw new Error(typeof failures[0]?.message === 'string' ? failures[0].message : 'replacement plan could not be prepared');
|
|
1068
|
-
}
|
|
1069
|
-
const absentIndex = candidates.findIndex((candidate) => candidate.step.id === absent.stepId);
|
|
1070
|
-
if (absentIndex < 0)
|
|
1071
|
-
throw new Error('replacement preview returned an unknown step id');
|
|
1072
|
-
skippedEdits.push({ find: candidates[absentIndex].edit.find });
|
|
1073
|
-
candidates = candidates.filter((_candidate, index) => index !== absentIndex);
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
if (candidates.length === 0) {
|
|
1077
|
-
return {
|
|
1078
|
-
status: 'failed',
|
|
1079
|
-
intent: 'replace_text',
|
|
1080
|
-
preSnapshot: { revision: pre.revision, counts: pre.counts },
|
|
1081
|
-
selectedTargets: [],
|
|
1082
|
-
executedOperations: [],
|
|
1083
|
-
verification: [],
|
|
1084
|
-
errors: [
|
|
1085
|
-
{ code: 'ACTION_FAILED', message: 'none of the requested text replacements matched the current document' },
|
|
1086
|
-
],
|
|
1087
|
-
};
|
|
1088
|
-
}
|
|
1089
|
-
steps = candidates.map((candidate) => candidate.step);
|
|
1090
|
-
}
|
|
1091
|
-
const result = await executeMutations(doc, steps, args.changeMode, applyExpectedRevision);
|
|
1092
|
-
if (args.selector && selectedTargets[0]) {
|
|
1093
|
-
const post = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] });
|
|
1094
|
-
const blockId = selectedTargets[0].matched[0];
|
|
1095
|
-
const postTarget = findSnapshotTextByNodeId(post, blockId);
|
|
1096
|
-
const preTarget = findSnapshotTextByNodeId(pre, blockId);
|
|
1097
|
-
const finalText = postTarget?.text ?? '';
|
|
1098
|
-
const expectedText = args.edits.reduce((current, edit) => replaceAllText(current, edit.find, edit.replace, caseSensitive), preTarget?.text ?? '');
|
|
1099
|
-
const verification = [
|
|
1100
|
-
{
|
|
1101
|
-
check: { kind: 'revision-changed' },
|
|
1102
|
-
passed: pre.revision !== post.revision,
|
|
1103
|
-
detail: `pre=${pre.revision} post=${post.revision}`,
|
|
1104
|
-
},
|
|
1105
|
-
{
|
|
1106
|
-
check: {
|
|
1107
|
-
kind: 'block-text-contains',
|
|
1108
|
-
nodeId: blockId,
|
|
1109
|
-
text: expectedText,
|
|
1110
|
-
},
|
|
1111
|
-
passed: !!postTarget && verifyRewrittenBlockText(finalText, expectedText, args.changeMode),
|
|
986
|
+
function replacementGranularityArg(granularity) {
|
|
987
|
+
return granularity === undefined ? {} : { replacementGranularity: granularity };
|
|
988
|
+
}
|
|
989
|
+
function globalReplaceCandidates(args) {
|
|
990
|
+
const caseSensitive = args.caseSensitive === true;
|
|
991
|
+
return args.edits.map((edit, editIndex) => ({
|
|
992
|
+
edit,
|
|
993
|
+
step: {
|
|
994
|
+
id: `replace-${editIndex + 1}`,
|
|
995
|
+
op: 'text.rewrite',
|
|
996
|
+
where: {
|
|
997
|
+
by: 'select',
|
|
998
|
+
select: {
|
|
999
|
+
type: 'text',
|
|
1000
|
+
pattern: edit.find,
|
|
1001
|
+
mode: 'contains',
|
|
1002
|
+
caseSensitive,
|
|
1112
1003
|
},
|
|
1113
|
-
|
|
1004
|
+
require: 'all',
|
|
1005
|
+
},
|
|
1006
|
+
args: {
|
|
1007
|
+
replacement: { text: edit.replace },
|
|
1008
|
+
style: preserveRewriteStyle(),
|
|
1009
|
+
...replacementGranularityArg(args.granularity),
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
}));
|
|
1013
|
+
}
|
|
1014
|
+
function previewFailuresOf(preview) {
|
|
1015
|
+
return Array.isArray(preview.failures) ? preview.failures.filter(guards.isRecord) : [];
|
|
1016
|
+
}
|
|
1017
|
+
function previewRefusal(failure, find) {
|
|
1018
|
+
const code = asString(failure?.code) ?? 'ACTION_FAILED';
|
|
1019
|
+
const message = asString(failure?.message) ?? 'replacement plan could not be prepared';
|
|
1020
|
+
const namesFind = find !== undefined && code !== 'PLAN_LIMIT_EXCEEDED';
|
|
1021
|
+
return new errors.SuperDocCliError(namesFind ? `${JSON.stringify(find)}: ${message}` : message, { code });
|
|
1022
|
+
}
|
|
1023
|
+
const PREVIEW_DETAIL_LIMIT = 1000;
|
|
1024
|
+
async function completeResolutionTexts(doc, steps, changeMode, revision, firstPage) {
|
|
1025
|
+
const texts = new Map();
|
|
1026
|
+
const counts = new Map();
|
|
1027
|
+
let page = firstPage;
|
|
1028
|
+
let offset = 0;
|
|
1029
|
+
while (page.valid === true) {
|
|
1030
|
+
let returned = 0;
|
|
1031
|
+
for (const entry of Array.isArray(page.steps) ? page.steps.filter(guards.isRecord) : []) {
|
|
1032
|
+
const stepId = asString(entry.stepId);
|
|
1033
|
+
if (stepId === undefined)
|
|
1034
|
+
continue;
|
|
1035
|
+
const resolutions = Array.isArray(entry.resolutions) ? entry.resolutions : [];
|
|
1036
|
+
texts.set(stepId, [...(texts.get(stepId) ?? []), ...resolutions.map((resolution) => asRecord(resolution)?.text)]);
|
|
1037
|
+
if (typeof entry.matchCount === 'number')
|
|
1038
|
+
counts.set(stepId, entry.matchCount);
|
|
1039
|
+
returned = Math.max(returned, resolutions.length);
|
|
1040
|
+
}
|
|
1041
|
+
const missing = [...texts].some(([stepId, list]) => (counts.get(stepId) ?? list.length) > list.length);
|
|
1042
|
+
if (!missing || returned === 0)
|
|
1043
|
+
break;
|
|
1044
|
+
offset += returned;
|
|
1045
|
+
page = await previewMutations(doc, steps, changeMode, revision, { offset, limit: PREVIEW_DETAIL_LIMIT });
|
|
1046
|
+
}
|
|
1047
|
+
return new Map([...texts].map(([stepId, list]) => [stepId, counts.get(stepId) === list.length ? list : null]));
|
|
1048
|
+
}
|
|
1049
|
+
async function previewGlobalCandidates(doc, initial, changeMode, revision, skippedEdits) {
|
|
1050
|
+
let candidates = [...initial];
|
|
1051
|
+
while (candidates.length > 0) {
|
|
1052
|
+
const steps = candidates.map((candidate) => candidate.step);
|
|
1053
|
+
const preview = await previewMutations(doc, steps, changeMode, revision, {
|
|
1054
|
+
offset: 0,
|
|
1055
|
+
limit: PREVIEW_DETAIL_LIMIT,
|
|
1056
|
+
});
|
|
1057
|
+
const failures = previewFailuresOf(preview);
|
|
1058
|
+
if (preview.valid === true) {
|
|
1059
|
+
const texts = await completeResolutionTexts(doc, steps, changeMode, revision, preview);
|
|
1060
|
+
const resolvedTexts = ({ step }) => texts.get(String(step.id)) ?? null;
|
|
1061
|
+
const changedCount = (candidate) => (resolvedTexts(candidate) ?? []).filter((text) => text !== candidate.edit.replace).length;
|
|
1062
|
+
const complete = candidates.filter((candidate) => resolvedTexts(candidate) !== null);
|
|
1114
1063
|
return {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
verification,
|
|
1064
|
+
candidates,
|
|
1065
|
+
unchanged: complete.filter((candidate) => resolvedTexts(candidate).length > 0 && changedCount(candidate) === 0),
|
|
1066
|
+
applied: complete
|
|
1067
|
+
.map((candidate) => ({ find: candidate.edit.find, occurrences: changedCount(candidate) }))
|
|
1068
|
+
.filter((edit) => edit.occurrences > 0),
|
|
1069
|
+
incomplete: candidates.filter((candidate) => resolvedTexts(candidate) === null),
|
|
1122
1070
|
};
|
|
1123
1071
|
}
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1072
|
+
const absent = failures.find((failure) => failure.code === 'MATCH_NOT_FOUND' && typeof failure.stepId === 'string');
|
|
1073
|
+
const failedCandidate = (failure) => candidates.find((candidate) => candidate.step.id === failure?.stepId);
|
|
1074
|
+
if (!absent)
|
|
1075
|
+
throw previewRefusal(failures[0], failedCandidate(failures[0])?.edit.find);
|
|
1076
|
+
const absentCandidate = failedCandidate(absent);
|
|
1077
|
+
if (!absentCandidate)
|
|
1078
|
+
throw new Error('replacement preview returned an unknown step id');
|
|
1079
|
+
skippedEdits.push({ find: absentCandidate.edit.find });
|
|
1080
|
+
candidates = candidates.filter((candidate) => candidate !== absentCandidate);
|
|
1081
|
+
}
|
|
1082
|
+
return { candidates, unchanged: [], applied: [], incomplete: [] };
|
|
1083
|
+
}
|
|
1084
|
+
function readBackFailure(error) {
|
|
1085
|
+
return { check: { kind: 'revision-changed' }, passed: false, detail: `read back failed: ${reasonOf(error)}` };
|
|
1086
|
+
}
|
|
1087
|
+
function readBackError(error) {
|
|
1088
|
+
return {
|
|
1089
|
+
code: 'ACTION_FAILED',
|
|
1090
|
+
message: `replace_text: the edit was applied but the document could not be read back (${reasonOf(error)}). Inspect before retrying; do not repeat the edit.`,
|
|
1091
|
+
recovery: { kind: 'reinspect' },
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
function unchangedEditsEvidence(unchanged) {
|
|
1095
|
+
const finds = [...new Set(unchanged.map((span) => span.find))];
|
|
1096
|
+
return finds.length > 0
|
|
1097
|
+
? { editsUnchanged: finds.map((find) => ({ find, reason: 'already reads as the replacement' })) }
|
|
1098
|
+
: {};
|
|
1099
|
+
}
|
|
1100
|
+
function granularGlobalEvidence({ applied }, skippedEdits, requested) {
|
|
1101
|
+
return {
|
|
1102
|
+
editsApplied: applied,
|
|
1103
|
+
...(skippedEdits.length > 0
|
|
1104
|
+
? {
|
|
1105
|
+
nextStep: `${skippedEdits.length} of ${requested} edits were NOT applied — the find text is not in the document. ` +
|
|
1106
|
+
'Do not stop: inspect, then call replace_text again for the remaining edits with the text as it appears.',
|
|
1107
|
+
recovery: { kind: 'retry' },
|
|
1108
|
+
editsSkipped: skippedEdits.map((edit) => ({ find: edit.find, reason: 'not present in the document' })),
|
|
1109
|
+
}
|
|
1110
|
+
: {}),
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
async function runGlobalReplace(doc, args, pre) {
|
|
1114
|
+
assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
|
|
1115
|
+
assertReplacementTextPayloads(args.edits);
|
|
1116
|
+
if (args.edits.length === 0) {
|
|
1117
|
+
return failedReceipt('replace_text', new Error('edits must be non-empty'), pre);
|
|
1118
|
+
}
|
|
1119
|
+
const initial = globalReplaceCandidates(args);
|
|
1120
|
+
const skippedEdits = [];
|
|
1121
|
+
const granular = args.granularity !== undefined;
|
|
1122
|
+
const multiple = initial.length > 1;
|
|
1123
|
+
const preview = multiple || granular
|
|
1124
|
+
? await previewGlobalCandidates(doc, initial, args.changeMode, pre.revision, skippedEdits)
|
|
1125
|
+
: { candidates: initial, unchanged: [], applied: [], incomplete: [] };
|
|
1126
|
+
const { candidates, unchanged } = preview;
|
|
1127
|
+
if (granular && preview.incomplete.length > 0) {
|
|
1128
|
+
throw new errors.SuperDocCliError(`replace_text: the engine did not list every match of ${preview.incomplete.map((candidate) => JSON.stringify(candidate.edit.find)).join(', ')}, so the granular replacement cannot be verified; nothing was written. Scope the edit with a selector or a longer find.`, { code: 'INVALID_ARGUMENT' });
|
|
1129
|
+
}
|
|
1130
|
+
if (candidates.length === 0) {
|
|
1132
1131
|
return {
|
|
1133
|
-
status:
|
|
1132
|
+
status: 'failed',
|
|
1134
1133
|
intent: 'replace_text',
|
|
1135
1134
|
preSnapshot: { revision: pre.revision, counts: pre.counts },
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
...(skippedEditRationale ? { rationale: skippedEditRationale } : {}),
|
|
1142
|
-
result,
|
|
1143
|
-
},
|
|
1135
|
+
selectedTargets: [],
|
|
1136
|
+
executedOperations: [],
|
|
1137
|
+
verification: [],
|
|
1138
|
+
errors: [
|
|
1139
|
+
{ code: 'ACTION_FAILED', message: 'none of the requested text replacements matched the current document' },
|
|
1144
1140
|
],
|
|
1145
|
-
verification,
|
|
1146
1141
|
};
|
|
1147
1142
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1143
|
+
const skippedEditRationale = args.edits.length > 1 ? summarizeSkippedReplaceEdits(skippedEdits) : undefined;
|
|
1144
|
+
const evidence = {
|
|
1145
|
+
...(granular ? granularGlobalEvidence(preview, skippedEdits, args.edits.length) : {}),
|
|
1146
|
+
...unchangedEditsEvidence(unchanged.map((candidate) => candidate.edit)),
|
|
1147
|
+
};
|
|
1148
|
+
const partial = granular && skippedEdits.length > 0;
|
|
1149
|
+
if (unchanged.length === candidates.length) {
|
|
1150
|
+
return {
|
|
1151
|
+
status: partial ? 'partial' : 'ok',
|
|
1152
|
+
intent: 'replace_text',
|
|
1153
|
+
note: ['The text already reads as requested; nothing was written.', skippedEditRationale]
|
|
1154
|
+
.filter(Boolean)
|
|
1155
|
+
.join(' '),
|
|
1156
|
+
preSnapshot: { revision: pre.revision, counts: pre.counts },
|
|
1157
|
+
selectedTargets: [],
|
|
1158
|
+
executedOperations: [],
|
|
1159
|
+
verification: [],
|
|
1160
|
+
...evidence,
|
|
1161
|
+
};
|
|
1150
1162
|
}
|
|
1163
|
+
const applyExpectedRevision = args.expectedRevision != null || multiple || granular ? pre.revision : undefined;
|
|
1164
|
+
const result = await executeMutations(doc, candidates.map((candidate) => candidate.step), args.changeMode, applyExpectedRevision);
|
|
1165
|
+
let postIdentity;
|
|
1166
|
+
let readError;
|
|
1167
|
+
try {
|
|
1168
|
+
postIdentity =
|
|
1169
|
+
args.changeMode === 'tracked'
|
|
1170
|
+
? await readDocumentIdentity(doc)
|
|
1171
|
+
: { revision: revisionAfterOperation(result, pre.revision), counts: pre.counts };
|
|
1172
|
+
}
|
|
1173
|
+
catch (error) {
|
|
1174
|
+
readError = error;
|
|
1175
|
+
}
|
|
1176
|
+
const verification = postIdentity
|
|
1177
|
+
? [revisionVerification(pre.revision, postIdentity.revision, true)]
|
|
1178
|
+
: [readBackFailure(readError)];
|
|
1179
|
+
const passed = verification.every((v) => v.passed);
|
|
1180
|
+
return {
|
|
1181
|
+
status: !passed ? 'failed' : partial ? 'partial' : 'ok',
|
|
1182
|
+
intent: 'replace_text',
|
|
1183
|
+
...(readError !== undefined ? { errors: [readBackError(readError)] } : {}),
|
|
1184
|
+
preSnapshot: { revision: pre.revision, counts: pre.counts },
|
|
1185
|
+
...(postIdentity ? { postSnapshot: postIdentity } : {}),
|
|
1186
|
+
selectedTargets: [],
|
|
1187
|
+
executedOperations: [
|
|
1188
|
+
{
|
|
1189
|
+
operationId: 'doc.mutations.apply',
|
|
1190
|
+
...(skippedEditRationale ? { rationale: skippedEditRationale } : {}),
|
|
1191
|
+
result,
|
|
1192
|
+
},
|
|
1193
|
+
],
|
|
1194
|
+
verification,
|
|
1195
|
+
...evidence,
|
|
1196
|
+
};
|
|
1151
1197
|
}
|
|
1152
1198
|
async function runReplaceText(doc, args) {
|
|
1153
1199
|
const changeMode = args.changeMode ?? 'direct';
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1200
|
+
const pre = snapshotFromIdentity(await readDocumentIdentity(doc));
|
|
1201
|
+
let receipt;
|
|
1202
|
+
try {
|
|
1203
|
+
receipt = await runGlobalReplace(doc, { ...args, changeMode }, pre);
|
|
1204
|
+
}
|
|
1205
|
+
catch (err) {
|
|
1206
|
+
receipt = failedReceipt('replace_text', err, pre);
|
|
1207
|
+
}
|
|
1208
|
+
return { ...receipt, changeMode };
|
|
1158
1209
|
}
|
|
1159
1210
|
async function runDeleteText(doc, args) {
|
|
1160
1211
|
const caseSensitive = args.caseSensitive === true;
|
|
@@ -1976,14 +2027,14 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1976
2027
|
const sibling = formattingSourceForLevel(ci.targetLevel);
|
|
1977
2028
|
if (!sibling?.nodeId)
|
|
1978
2029
|
continue;
|
|
1979
|
-
const node = (await getNodeFn({ id: sibling.nodeId }));
|
|
2030
|
+
const node = (await getNodeFn({ id: sibling.nodeId, story: { kind: 'story', storyType: 'body' } }));
|
|
1980
2031
|
const indent = paragraphIndentOf(node);
|
|
1981
2032
|
if (!indent)
|
|
1982
2033
|
continue;
|
|
1983
2034
|
// COMPARE rather than assume: a same-level item usually inherits the
|
|
1984
2035
|
// anchor's indentation on creation, but not always — one real document
|
|
1985
2036
|
// produced items with no `w:ind` at all beside siblings indented 851.
|
|
1986
|
-
const created = paragraphIndentOf(await getNodeFn({ id: ci.nodeId }));
|
|
2037
|
+
const created = paragraphIndentOf(await getNodeFn({ id: ci.nodeId, story: { kind: 'story', storyType: 'body' } }));
|
|
1987
2038
|
if (created && ['left', 'right', 'firstLine', 'hanging'].every((k) => created[k] === indent[k])) {
|
|
1988
2039
|
continue;
|
|
1989
2040
|
}
|
|
@@ -2031,14 +2082,13 @@ async function runAddListItems(doc, args, opts) {
|
|
|
2031
2082
|
}
|
|
2032
2083
|
// Format each new item in four layers, later layers overriding earlier on
|
|
2033
2084
|
// overlapping keys (successive format.apply calls):
|
|
2034
|
-
// 1. BASE — the sampled
|
|
2035
|
-
// the whole item,
|
|
2036
|
-
// matches its siblings' font/size/colour (fixes the plain-insert
|
|
2085
|
+
// 1. BASE — the sampled font and size of the existing item at the same
|
|
2086
|
+
// level, over the whole item, never its emphasis or colour (fixes the plain-insert
|
|
2037
2087
|
// mismatch where the new item fell back to style defaults instead of
|
|
2038
2088
|
// the list's direct run formatting).
|
|
2039
2089
|
// 2. LEAD — the siblings' "bold label: plain sentence" convention over
|
|
2040
2090
|
// the new item's own label span, when the item requested no styling of
|
|
2041
|
-
// its own. Without it a plain add reads as the odd one out even though
|
|
2091
|
+
// its own and the sibling has a real emphasised label. Without it a plain add reads as the odd one out even though
|
|
2042
2092
|
// every other property matches: the agent is only told the item TEXT of
|
|
2043
2093
|
// its siblings, never that they open with a bold lead-in.
|
|
2044
2094
|
// 3. ITEM — whole-item `marks` the agent supplied (e.g. Arial 16pt on the
|
|
@@ -2059,7 +2109,7 @@ async function runAddListItems(doc, args, opts) {
|
|
|
2059
2109
|
// `formattingSourceForLevel(targetLevel)` already picks the destination sibling for
|
|
2060
2110
|
// style and indent, so the look follows the same source. Falls back to
|
|
2061
2111
|
// the anchor only when the destination level has no sibling to sample.
|
|
2062
|
-
const anchorBase =
|
|
2112
|
+
const anchorBase = baseRunLookOf(anchor);
|
|
2063
2113
|
const formatFn = maybeMethod(doc, ['format', 'apply']);
|
|
2064
2114
|
if (formatFn) {
|
|
2065
2115
|
const applied = [];
|
|
@@ -2099,7 +2149,7 @@ async function runAddListItems(doc, args, opts) {
|
|
|
2099
2149
|
const pattern = source?.nodeId && sourceText ? await listItemRunPattern(doc, sourceText, source.nodeId) : null;
|
|
2100
2150
|
// 1. sampled base over the whole item (row fields first, run-level
|
|
2101
2151
|
// sample second: the row read omits run styling entirely).
|
|
2102
|
-
const destinationBase = source ?
|
|
2152
|
+
const destinationBase = source ? baseRunLookOf(source) : anchorBase;
|
|
2103
2153
|
const merged = { ...destinationBase, ...pattern?.body };
|
|
2104
2154
|
reportedBase = reportedBase ?? merged;
|
|
2105
2155
|
await applyRange(ci.nodeId, 0, len, merged);
|
|
@@ -2146,47 +2196,6 @@ async function runAddListItems(doc, args, opts) {
|
|
|
2146
2196
|
catch (err) {
|
|
2147
2197
|
formattingMatched = { skipped: reasonOf(err) };
|
|
2148
2198
|
}
|
|
2149
|
-
// Word paints the LIST NUMBER from the paragraph mark's run properties
|
|
2150
|
-
// (w:pPr/w:rPr), not from the item's text runs — so an item whose text is
|
|
2151
|
-
// styled correctly still shows a default-black "4." next to bold blue
|
|
2152
|
-
// "1./2./3." siblings. Mirror the item's leading run onto the mark, which
|
|
2153
|
-
// is the relationship those siblings already encode. Best-effort and never
|
|
2154
|
-
// fatal: the item and its text styling have already landed.
|
|
2155
|
-
let markStyledItems = 0;
|
|
2156
|
-
const markerSkipped = await bestEffort(async () => {
|
|
2157
|
-
const setMarkFn = maybeMethod(doc, ['format', 'paragraph', 'setMarkRunProps']);
|
|
2158
|
-
const getNodeFn = maybeMethod(doc, ['getNodeById']);
|
|
2159
|
-
if (!setMarkFn)
|
|
2160
|
-
return;
|
|
2161
|
-
for (const ci of createdItems) {
|
|
2162
|
-
// Explicit marks describe the intent — the whole-item ones AND the
|
|
2163
|
-
// leading run's, merged in that order, so a marker never loses the
|
|
2164
|
-
// item's colour just because the first run also asked for bold.
|
|
2165
|
-
//
|
|
2166
|
-
// With no marks at all, COPY the sibling's own paragraph mark instead of
|
|
2167
|
-
// deriving one from the text runs: a marker must not gain bold its
|
|
2168
|
-
// siblings' markers don't have (bullet lists routinely carry a bare mark
|
|
2169
|
-
// next to bold-lead-in text), and must gain it where they do. A
|
|
2170
|
-
// same-level add already inherits the anchor's mark on creation; this
|
|
2171
|
-
// covers the level-CHANGING add, whose inherited mark came from the
|
|
2172
|
-
// wrong level. Reads nothing today: `getNodeById` reports
|
|
2173
|
-
// indent/alignment/spacing but not `markRunProps`, so an unstyled
|
|
2174
|
-
// promotion keeps the plain marker until that read lands.
|
|
2175
|
-
let markProps = markRunPropsFromMarks({ ...ci.itemMarks, ...ci.runs[0]?.marks });
|
|
2176
|
-
if (!markProps && getNodeFn) {
|
|
2177
|
-
const source = formattingSourceForLevel(ci.targetLevel);
|
|
2178
|
-
if (source?.nodeId)
|
|
2179
|
-
markProps = paragraphMarkRunPropsOf(await getNodeFn({ id: source.nodeId }));
|
|
2180
|
-
}
|
|
2181
|
-
if (!markProps)
|
|
2182
|
-
continue;
|
|
2183
|
-
await setMarkFn({
|
|
2184
|
-
target: { kind: 'block', nodeType: 'paragraph', nodeId: ci.nodeId },
|
|
2185
|
-
markRunProps: markProps,
|
|
2186
|
-
});
|
|
2187
|
-
markStyledItems += 1;
|
|
2188
|
-
}
|
|
2189
|
-
});
|
|
2190
2199
|
const postIdentity = await readDocumentIdentity(doc);
|
|
2191
2200
|
const verification = [revisionVerification(pre.revision, postIdentity.revision, true)];
|
|
2192
2201
|
return {
|
|
@@ -2196,8 +2205,6 @@ async function runAddListItems(doc, args, opts) {
|
|
|
2196
2205
|
...(styleSkipped ? { styleSkipped } : {}),
|
|
2197
2206
|
...(indentedItems.length ? { indentMatchedFrom: indentedItems.map((i) => i.from) } : {}),
|
|
2198
2207
|
...(indentSkipped ? { indentSkipped } : {}),
|
|
2199
|
-
...(markStyledItems > 0 ? { markerStyled: markStyledItems } : {}),
|
|
2200
|
-
...(markerSkipped ? { markerSkipped } : {}),
|
|
2201
2208
|
...(landed.length
|
|
2202
2209
|
? {
|
|
2203
2210
|
note: `landed at ${landed
|
|
@@ -2290,9 +2297,7 @@ async function resolveFactPlacement(placement, facts) {
|
|
|
2290
2297
|
return { kind: 'documentStart' };
|
|
2291
2298
|
const target = await facts.target(placement.selector);
|
|
2292
2299
|
if (!target)
|
|
2293
|
-
throw new errors.SuperDocCliError(
|
|
2294
|
-
code: 'INVALID_ARGUMENT',
|
|
2295
|
-
});
|
|
2300
|
+
throw new errors.SuperDocCliError(placementMissMessage(placement.selector, (await facts.select(placement.selector)).length), { code: 'INVALID_ARGUMENT' });
|
|
2296
2301
|
return { kind: placement.at, target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId } };
|
|
2297
2302
|
}
|
|
2298
2303
|
function placementSelectors(placement) {
|
|
@@ -2668,18 +2673,11 @@ async function runRewriteBlock(doc, args) {
|
|
|
2668
2673
|
}
|
|
2669
2674
|
}
|
|
2670
2675
|
const RUN_BOOL_MARKS = ['bold', 'italic', 'underline', 'strike'];
|
|
2671
|
-
|
|
2672
|
-
* The format.apply inline bag reproducing a SnapshotRun's marks. Boolean marks
|
|
2673
|
-
* listed in `normalize` are always emitted (true OR explicit false) so ranges
|
|
2674
|
-
* that were plain actively CLEAR a mark the engine smeared across the block.
|
|
2675
|
-
*/
|
|
2676
|
-
function inlineFromSnapshotRun(run, normalize) {
|
|
2676
|
+
function inlineFromSnapshotRun(run) {
|
|
2677
2677
|
const inline = {};
|
|
2678
2678
|
for (const key of RUN_BOOL_MARKS) {
|
|
2679
2679
|
if (run[key])
|
|
2680
2680
|
inline[key] = true;
|
|
2681
|
-
else if (normalize.has(key))
|
|
2682
|
-
inline[key] = false;
|
|
2683
2681
|
}
|
|
2684
2682
|
if (run.fontFamily)
|
|
2685
2683
|
inline.fontFamily = run.fontFamily;
|
|
@@ -2771,24 +2769,27 @@ async function runTrackedChangeDecision(doc, intentLabel, decision, exactIds, au
|
|
|
2771
2769
|
const sideForType = changeType === 'delete' ? 'deleted' : changeType === 'insert' ? 'inserted' : undefined;
|
|
2772
2770
|
const isReplacement = (item) => asString(asRecord(item)?.type) === 'replacement';
|
|
2773
2771
|
if (changeType) {
|
|
2774
|
-
scoped = scoped.filter((item) => asString(item?.type) === changeType || (sideForType != null && isReplacement(item)));
|
|
2772
|
+
scoped = scoped.filter((item) => trackedChangeKindOf(asString(item?.type)) === changeType || (sideForType != null && isReplacement(item)));
|
|
2775
2773
|
}
|
|
2776
2774
|
const executedOperations = [];
|
|
2777
2775
|
if (scoped.length === 0) {
|
|
2778
2776
|
// Nothing to do is not a failure: report it honestly.
|
|
2779
2777
|
const post = await docSnapshot.buildDocumentSnapshot(doc);
|
|
2778
|
+
const pendingByType = countTrackedChangesByKind(items);
|
|
2779
|
+
const filters = [author ? `author=${author}` : '', changeType ? `changeType=${changeType}` : ''].filter(Boolean);
|
|
2780
|
+
const rationale = items.length === 0
|
|
2781
|
+
? `no tracked changes${author ? ` for author=${author}` : ''}${changeType ? ` of type=${changeType}` : ''} to ${decision}`
|
|
2782
|
+
: `0 of ${items.length} pending tracked changes matched ${filters.join(', ')}; nothing was ${decision}ed. Pending by type: ${Object.entries(pendingByType)
|
|
2783
|
+
.map(([kind, count]) => `${kind}: ${count}`)
|
|
2784
|
+
.join(', ')}`;
|
|
2780
2785
|
return {
|
|
2781
2786
|
status: 'ok',
|
|
2782
2787
|
intent: intentLabel,
|
|
2783
2788
|
preSnapshot: { revision: pre.revision, counts: pre.counts },
|
|
2784
2789
|
postSnapshot: { revision: post.revision, counts: post.counts },
|
|
2785
2790
|
selectedTargets: [],
|
|
2786
|
-
executedOperations: [
|
|
2787
|
-
|
|
2788
|
-
operationId: 'doc.trackChanges.list',
|
|
2789
|
-
rationale: `no tracked changes${author ? ` for author=${author}` : ''}${changeType ? ` of type=${changeType}` : ''} to ${decision}`,
|
|
2790
|
-
},
|
|
2791
|
-
],
|
|
2791
|
+
executedOperations: [{ operationId: 'doc.trackChanges.list', rationale }],
|
|
2792
|
+
pendingTrackedChanges: { total: items.length, byType: pendingByType },
|
|
2792
2793
|
verification: [{ check: { kind: 'tracked-change-count-delta', delta: 0 }, passed: true, detail: 'no-op' }],
|
|
2793
2794
|
};
|
|
2794
2795
|
}
|
|
@@ -3849,47 +3850,204 @@ async function runInsertToc(doc, args) {
|
|
|
3849
3850
|
});
|
|
3850
3851
|
}
|
|
3851
3852
|
const resolvedPlacement = resolvePlacement(placement, pre);
|
|
3853
|
+
const intent = `insert_toc${args.title ? `: ${args.title}` : ''}`;
|
|
3854
|
+
const preTocIds = await readTocIds(doc);
|
|
3855
|
+
const failure = (err, heading) => insertTocFailureReceipt({ doc, intent, err, pre, preTocIds, title: args.title, heading });
|
|
3856
|
+
const createToc = async (dryRun) => {
|
|
3857
|
+
const options = {
|
|
3858
|
+
...(args.changeMode ? { changeMode: args.changeMode } : {}),
|
|
3859
|
+
...(dryRun ? { dryRun: true } : {}),
|
|
3860
|
+
};
|
|
3861
|
+
return requireMutationSuccess(await tocFn({ at: resolvedPlacement, ...options }, Object.keys(options).length > 0 ? options : undefined));
|
|
3862
|
+
};
|
|
3863
|
+
const createTitle = async () => requireMutationSuccess(await executeCreateHeading(doc, args.title ?? '', 1, resolvedPlacement, args.changeMode));
|
|
3852
3864
|
const executed = [];
|
|
3853
|
-
let
|
|
3854
|
-
if (args.title) {
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3865
|
+
let headingError = null;
|
|
3866
|
+
if (!args.title || resolvedPlacement.kind === 'after' || resolvedPlacement.kind === 'documentStart') {
|
|
3867
|
+
try {
|
|
3868
|
+
executed.push({ operationId: 'doc.create.tableOfContents', result: await createToc(false) });
|
|
3869
|
+
}
|
|
3870
|
+
catch (err) {
|
|
3871
|
+
return failure(err);
|
|
3872
|
+
}
|
|
3873
|
+
if (args.title) {
|
|
3874
|
+
try {
|
|
3875
|
+
executed.push({ operationId: 'doc.create.heading', result: await createTitle() });
|
|
3876
|
+
}
|
|
3877
|
+
catch (err) {
|
|
3878
|
+
headingError = err;
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
else {
|
|
3883
|
+
let headingResult;
|
|
3884
|
+
try {
|
|
3885
|
+
await createToc(true);
|
|
3886
|
+
headingResult = await createTitle();
|
|
3887
|
+
}
|
|
3888
|
+
catch (err) {
|
|
3889
|
+
return failure(err);
|
|
3890
|
+
}
|
|
3891
|
+
try {
|
|
3892
|
+
const tocResult = await createToc(false);
|
|
3893
|
+
executed.push({ operationId: 'doc.create.heading', result: headingResult }, { operationId: 'doc.create.tableOfContents', result: tocResult });
|
|
3894
|
+
}
|
|
3895
|
+
catch (err) {
|
|
3896
|
+
return failure(err, { result: headingResult, removedBy: await rollBackInsertedHeading(doc, headingResult) });
|
|
3863
3897
|
}
|
|
3864
3898
|
}
|
|
3865
|
-
const tocParams = { at: tocPlacement };
|
|
3866
|
-
if (args.changeMode)
|
|
3867
|
-
tocParams.changeMode = args.changeMode;
|
|
3868
|
-
// Dual dialect — see executeCreateParagraph.
|
|
3869
|
-
const result = await tocFn(tocParams, args.changeMode ? { changeMode: args.changeMode } : undefined);
|
|
3870
|
-
executed.push({ operationId: 'doc.create.tableOfContents', result });
|
|
3871
3899
|
const post = await docSnapshot.buildDocumentSnapshot(doc);
|
|
3872
3900
|
const verification = evaluateChecks(pre, post, [{ kind: 'revision-changed' }]);
|
|
3873
|
-
|
|
3901
|
+
const receipt = {
|
|
3874
3902
|
status: verification.every((v) => v.passed) ? 'ok' : 'failed',
|
|
3875
|
-
intent
|
|
3903
|
+
intent,
|
|
3876
3904
|
preSnapshot: { revision: pre.revision, counts: pre.counts },
|
|
3877
3905
|
postSnapshot: { revision: post.revision, counts: post.counts },
|
|
3878
3906
|
selectedTargets: [],
|
|
3879
3907
|
executedOperations: executed,
|
|
3880
3908
|
verification,
|
|
3881
3909
|
};
|
|
3910
|
+
if (!headingError)
|
|
3911
|
+
return receipt;
|
|
3912
|
+
return {
|
|
3913
|
+
...receipt,
|
|
3914
|
+
status: 'partial',
|
|
3915
|
+
errors: [{ code: asString(asRecord(headingError)?.code) ?? 'ACTION_FAILED', message: reasonOf(headingError) }],
|
|
3916
|
+
note: `The table of contents was inserted without its ${JSON.stringify(args.title)} heading.`,
|
|
3917
|
+
nextStep: `Add the ${JSON.stringify(args.title)} heading directly above the table of contents, or leave it untitled.`,
|
|
3918
|
+
};
|
|
3882
3919
|
}
|
|
3883
3920
|
catch (err) {
|
|
3884
3921
|
return failedReceipt('insert_toc', err, pre);
|
|
3885
3922
|
}
|
|
3886
3923
|
}
|
|
3924
|
+
async function rollBackInsertedHeading(doc, headingResult) {
|
|
3925
|
+
const unbound = actionChangeMode.withoutActionChangeMode(doc);
|
|
3926
|
+
const heading = asRecord(headingResult);
|
|
3927
|
+
const trackedIds = (Array.isArray(heading?.trackedChangeRefs) ? heading.trackedChangeRefs : [])
|
|
3928
|
+
.map((ref) => asString(asRecord(ref)?.entityId))
|
|
3929
|
+
.filter((id) => typeof id === 'string');
|
|
3930
|
+
try {
|
|
3931
|
+
if (trackedIds.length > 0) {
|
|
3932
|
+
const decideFn = maybeMethod(unbound, ['trackChanges', 'decide']);
|
|
3933
|
+
if (!decideFn)
|
|
3934
|
+
return null;
|
|
3935
|
+
const target = trackedIds.length === 1 ? { kind: 'id', id: trackedIds[0] } : { kind: 'ids', ids: trackedIds };
|
|
3936
|
+
requireMutationSuccess(await decideFn({ decision: 'reject', target }));
|
|
3937
|
+
return 'doc.trackChanges.decide';
|
|
3938
|
+
}
|
|
3939
|
+
const nodeId = asString(asRecord(heading?.heading)?.nodeId);
|
|
3940
|
+
const deleteFn = maybeMethod(unbound, ['blocks', 'delete']);
|
|
3941
|
+
if (!nodeId || !deleteFn)
|
|
3942
|
+
return null;
|
|
3943
|
+
requireMutationSuccess(await deleteFn({ target: { kind: 'block', nodeType: 'heading', nodeId } }, { changeMode: 'direct' }));
|
|
3944
|
+
return 'doc.blocks.delete';
|
|
3945
|
+
}
|
|
3946
|
+
catch {
|
|
3947
|
+
return null;
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
async function readTocIds(doc) {
|
|
3951
|
+
const listFn = maybeMethod(doc, ['toc', 'list']);
|
|
3952
|
+
if (!listFn)
|
|
3953
|
+
return null;
|
|
3954
|
+
try {
|
|
3955
|
+
const ids = new Set();
|
|
3956
|
+
let offset = 0;
|
|
3957
|
+
for (;;) {
|
|
3958
|
+
const page = asRecord(await listFn({ offset, limit: 500 }));
|
|
3959
|
+
const items = Array.isArray(page?.items) ? page.items : [];
|
|
3960
|
+
for (const item of items) {
|
|
3961
|
+
const id = asString(asRecord(asRecord(item)?.address)?.nodeId) ?? asString(asRecord(item)?.id);
|
|
3962
|
+
if (id)
|
|
3963
|
+
ids.add(id);
|
|
3964
|
+
}
|
|
3965
|
+
offset += items.length;
|
|
3966
|
+
if (items.length === 0 || offset >= (asNumber(page?.total) ?? 0))
|
|
3967
|
+
return ids;
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
catch {
|
|
3971
|
+
return null;
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
function requireMutationSuccess(result) {
|
|
3975
|
+
const record = asRecord(result);
|
|
3976
|
+
if (record?.success !== false)
|
|
3977
|
+
return result;
|
|
3978
|
+
const failure = asRecord(record.failure);
|
|
3979
|
+
throw new errors.SuperDocCliError(asString(failure?.message) ?? 'The operation was refused.', {
|
|
3980
|
+
code: asString(failure?.code) ?? 'COMMAND_FAILED',
|
|
3981
|
+
});
|
|
3982
|
+
}
|
|
3983
|
+
async function insertTocFailureReceipt(input) {
|
|
3984
|
+
const { pre, heading } = input;
|
|
3985
|
+
const failed = failedReceipt(input.intent, input.err, pre);
|
|
3986
|
+
const post = await docSnapshot.buildDocumentSnapshot(input.doc).catch(() => null);
|
|
3987
|
+
const postTocIds = await readTocIds(input.doc);
|
|
3988
|
+
const preTocIds = input.preTocIds;
|
|
3989
|
+
const survivingTocs = postTocIds && preTocIds ? [...postTocIds].filter((id) => !preTocIds.has(id)) : null;
|
|
3990
|
+
const headingLeft = heading !== undefined && heading.removedBy === null;
|
|
3991
|
+
const title = JSON.stringify(input.title ?? '');
|
|
3992
|
+
const executed = [];
|
|
3993
|
+
const notes = [];
|
|
3994
|
+
if (heading) {
|
|
3995
|
+
executed.push({ operationId: 'doc.create.heading', result: heading.result });
|
|
3996
|
+
if (heading.removedBy) {
|
|
3997
|
+
executed.push({
|
|
3998
|
+
operationId: heading.removedBy,
|
|
3999
|
+
rationale: 'remove the heading written for the refused table of contents',
|
|
4000
|
+
});
|
|
4001
|
+
notes.push(`The ${title} heading was written and removed again.`);
|
|
4002
|
+
}
|
|
4003
|
+
else {
|
|
4004
|
+
notes.push(`The ${title} heading is still in the document; remove it or add the table of contents below it.`);
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
if (survivingTocs && survivingTocs.length > 0) {
|
|
4008
|
+
executed.push({
|
|
4009
|
+
operationId: 'doc.create.tableOfContents',
|
|
4010
|
+
rationale: 'reported a failure after committing',
|
|
4011
|
+
result: { tocs: survivingTocs },
|
|
4012
|
+
});
|
|
4013
|
+
notes.push(`A table of contents is in the document despite the failure (${survivingTocs.join(', ')}); do not retry without removing it.`);
|
|
4014
|
+
}
|
|
4015
|
+
const partial = headingLeft || (survivingTocs?.length ?? 0) > 0;
|
|
4016
|
+
const revisionsKnown = post !== null && pre.revision !== 'unknown' && post.revision !== 'unknown';
|
|
4017
|
+
const unchanged = !partial && revisionsKnown && post.revision === pre.revision;
|
|
4018
|
+
const nothingAdded = !partial && survivingTocs !== null && post !== null && insertTocCountsMatch(pre.counts, post.counts);
|
|
4019
|
+
if (unchanged)
|
|
4020
|
+
notes.push('Nothing was inserted: the request was refused and the document is unchanged.');
|
|
4021
|
+
else if (nothingAdded) {
|
|
4022
|
+
notes.push('No content was added: no new table of contents remains and block counts match the pre-action snapshot.');
|
|
4023
|
+
}
|
|
4024
|
+
else if (post === null || survivingTocs === null) {
|
|
4025
|
+
notes.push('What survived could not be confirmed; re-inspect before retrying.');
|
|
4026
|
+
}
|
|
4027
|
+
else if (!partial) {
|
|
4028
|
+
notes.push('The document changed during the failed request; re-inspect before retrying.');
|
|
4029
|
+
}
|
|
4030
|
+
return {
|
|
4031
|
+
...failed,
|
|
4032
|
+
status: partial ? 'partial' : 'failed',
|
|
4033
|
+
executedOperations: executed,
|
|
4034
|
+
...(post ? { postSnapshot: { revision: post.revision, counts: post.counts } } : {}),
|
|
4035
|
+
...(revisionsKnown ? { verification: [revisionVerification(pre.revision, post.revision, false)] } : {}),
|
|
4036
|
+
...(unchanged || nothingAdded ? {} : { recovery: { kind: 'reinspect' } }),
|
|
4037
|
+
note: notes.join(' '),
|
|
4038
|
+
};
|
|
4039
|
+
}
|
|
4040
|
+
function insertTocCountsMatch(before, after) {
|
|
4041
|
+
return ['blocks', 'paragraphs', 'headings', 'tables'].every((key) => before[key] === after[key]);
|
|
4042
|
+
}
|
|
3887
4043
|
/**
|
|
3888
4044
|
* style_table — make a table look professional in one call: a filled accent
|
|
3889
|
-
* header row (navy by default) with
|
|
3890
|
-
* labels
|
|
3891
|
-
*
|
|
3892
|
-
*
|
|
4045
|
+
* header row (navy by default) with contrasting bold header text, bold
|
|
4046
|
+
* first-column labels and accent top/bottom borders. tables.applyPreset("accent")
|
|
4047
|
+
* paints the header fill and header text atomically; the first column (body
|
|
4048
|
+
* rows) is then formatted bold + accent through paragraph-targeted
|
|
4049
|
+
* format.apply steps. The receipt reads the header back and reports any step
|
|
4050
|
+
* that did not land, so a half-styled table never reports ok.
|
|
3893
4051
|
*/
|
|
3894
4052
|
async function runStyleTable(doc, args) {
|
|
3895
4053
|
const preIdentity = await readDocumentIdentity(doc);
|
|
@@ -3903,61 +4061,61 @@ async function runStyleTable(doc, args) {
|
|
|
3903
4061
|
const accent = normalizeTableColor(args.accentColor ?? '#1F3864') ?? '#1F3864';
|
|
3904
4062
|
const target = { kind: 'block', nodeType: 'table', nodeId: table.nodeId };
|
|
3905
4063
|
const executed = [];
|
|
3906
|
-
|
|
4064
|
+
const errors$1 = [];
|
|
3907
4065
|
const presetFn = maybeMethod(doc, ['tables', 'applyPreset']);
|
|
3908
4066
|
if (!presetFn) {
|
|
3909
4067
|
throw new errors.SuperDocCliError('doc.tables.applyPreset is not available on the document handle.', {
|
|
3910
4068
|
code: 'TOOL_DISPATCH_NOT_FOUND',
|
|
3911
4069
|
});
|
|
3912
4070
|
}
|
|
3913
|
-
const presetResult = await presetFn({ target, preset: 'accent', accentColor: accent });
|
|
4071
|
+
const presetResult = requireMutationSuccess(await presetFn({ target, preset: 'accent', accentColor: accent }));
|
|
3914
4072
|
executed.push({ operationId: 'doc.tables.applyPreset', result: compactOpResult(presetResult) });
|
|
3915
|
-
|
|
3916
|
-
// Best-effort: a failure to read/format cells must not fail the action,
|
|
3917
|
-
// the header fill + borders already landed.
|
|
4073
|
+
let cellParagraphs = [];
|
|
3918
4074
|
try {
|
|
3919
|
-
|
|
3920
|
-
if (getCellsFn) {
|
|
3921
|
-
const cellsRes = asRecord(await getCellsFn({ target }));
|
|
3922
|
-
const cells = Array.isArray(cellsRes?.cells) ? cellsRes.cells : [];
|
|
3923
|
-
const steps = [];
|
|
3924
|
-
for (const cell of cells) {
|
|
3925
|
-
const nodeId = asString(cell.nodeId);
|
|
3926
|
-
const rowIndex = asNumber(cell.rowIndex);
|
|
3927
|
-
const columnIndex = asNumber(cell.columnIndex);
|
|
3928
|
-
if (!nodeId || rowIndex == null)
|
|
3929
|
-
continue;
|
|
3930
|
-
const where = { by: 'block', nodeType: 'tableCell', nodeId };
|
|
3931
|
-
if (rowIndex === 0) {
|
|
3932
|
-
steps.push({
|
|
3933
|
-
id: `hdr-${nodeId}`,
|
|
3934
|
-
op: 'format.apply',
|
|
3935
|
-
where,
|
|
3936
|
-
args: { inline: { bold: true, color: '#FFFFFF' }, scope: 'block' },
|
|
3937
|
-
});
|
|
3938
|
-
}
|
|
3939
|
-
else if (columnIndex === 0) {
|
|
3940
|
-
steps.push({
|
|
3941
|
-
id: `col-${nodeId}`,
|
|
3942
|
-
op: 'format.apply',
|
|
3943
|
-
where,
|
|
3944
|
-
args: { inline: { bold: true, color: accent }, scope: 'block' },
|
|
3945
|
-
});
|
|
3946
|
-
}
|
|
3947
|
-
}
|
|
3948
|
-
if (steps.length > 0) {
|
|
3949
|
-
const fmtResult = await executeMutations(doc, steps, 'direct');
|
|
3950
|
-
executed.push({ operationId: 'doc.mutations.apply', result: compactOpResult(fmtResult) });
|
|
3951
|
-
}
|
|
3952
|
-
}
|
|
4075
|
+
cellParagraphs = await readStyleTableParagraphs(doc, target);
|
|
3953
4076
|
}
|
|
3954
|
-
catch {
|
|
3955
|
-
|
|
4077
|
+
catch (err) {
|
|
4078
|
+
errors$1.push({ code: 'TABLE_READBACK_FAILED', message: `could not read the styled table back: ${reasonOf(err)}` });
|
|
4079
|
+
}
|
|
4080
|
+
const labelParagraphs = cellParagraphs.filter((paragraph) => paragraph.rowIndex > 0 && paragraph.columnIndex === 0 && paragraph.text.trim().length > 0);
|
|
4081
|
+
const firstColumnSteps = [];
|
|
4082
|
+
for (const paragraph of labelParagraphs) {
|
|
4083
|
+
const color = (await accentReadsOnLabel(doc, paragraph, accent)) ? { color: accent } : {};
|
|
4084
|
+
firstColumnSteps.push({
|
|
4085
|
+
id: `col-${paragraph.nodeId}`,
|
|
4086
|
+
op: 'format.apply',
|
|
4087
|
+
where: { by: 'block', nodeType: 'paragraph', nodeId: paragraph.nodeId },
|
|
4088
|
+
args: { inline: { bold: true, ...color }, scope: 'block' },
|
|
4089
|
+
});
|
|
4090
|
+
}
|
|
4091
|
+
if (firstColumnSteps.length > 0) {
|
|
4092
|
+
try {
|
|
4093
|
+
const fmtResult = await executeMutations(doc, firstColumnSteps, args.changeMode);
|
|
4094
|
+
executed.push({ operationId: 'doc.mutations.apply', result: compactOpResult(fmtResult) });
|
|
4095
|
+
}
|
|
4096
|
+
catch (err) {
|
|
4097
|
+
errors$1.push({
|
|
4098
|
+
code: 'FIRST_COLUMN_FORMAT_FAILED',
|
|
4099
|
+
message: `first-column formatting did not apply: ${reasonOf(err)}`,
|
|
4100
|
+
});
|
|
4101
|
+
}
|
|
3956
4102
|
}
|
|
3957
4103
|
const postIdentity = await readDocumentIdentity(doc);
|
|
3958
|
-
const
|
|
4104
|
+
const headerParagraphs = cellParagraphs.filter((paragraph) => paragraph.rowIndex === 0);
|
|
4105
|
+
let headerVerification;
|
|
4106
|
+
try {
|
|
4107
|
+
headerVerification = await verifyStyledTableHeader(doc, headerParagraphs, accent);
|
|
4108
|
+
}
|
|
4109
|
+
catch (err) {
|
|
4110
|
+
const message = `could not read the header back: ${reasonOf(err)}`;
|
|
4111
|
+
errors$1.push({ code: 'HEADER_VERIFICATION_FAILED', message });
|
|
4112
|
+
headerVerification = { check: { kind: 'table-header-styled', fill: accent }, passed: false, detail: message };
|
|
4113
|
+
}
|
|
4114
|
+
const verification = [revisionVerification(preIdentity.revision, postIdentity.revision, true), headerVerification];
|
|
4115
|
+
const allPassed = verification.every((entry) => entry.passed) && errors$1.length === 0;
|
|
4116
|
+
const anyApplied = preIdentity.revision !== postIdentity.revision;
|
|
3959
4117
|
return {
|
|
3960
|
-
status:
|
|
4118
|
+
status: allPassed ? 'ok' : anyApplied ? 'partial' : 'failed',
|
|
3961
4119
|
intent: `style_table: accent ${accent}`,
|
|
3962
4120
|
preSnapshot: preIdentity,
|
|
3963
4121
|
postSnapshot: postIdentity,
|
|
@@ -3966,12 +4124,121 @@ async function runStyleTable(doc, args) {
|
|
|
3966
4124
|
],
|
|
3967
4125
|
executedOperations: executed,
|
|
3968
4126
|
verification,
|
|
4127
|
+
...(errors$1.length > 0 ? { errors: errors$1 } : {}),
|
|
3969
4128
|
};
|
|
3970
4129
|
}
|
|
3971
4130
|
catch (err) {
|
|
3972
4131
|
return failedReceipt('style_table', err, pre);
|
|
3973
4132
|
}
|
|
3974
4133
|
}
|
|
4134
|
+
async function readStyleTableParagraphs(doc, target) {
|
|
4135
|
+
const extractFn = maybeMethod(doc, ['extract']);
|
|
4136
|
+
const getCellsFn = maybeMethod(doc, ['tables', 'getCells']);
|
|
4137
|
+
if (!extractFn || !getCellsFn)
|
|
4138
|
+
return [];
|
|
4139
|
+
const cellsResult = asRecord(await getCellsFn({ target }));
|
|
4140
|
+
const cells = Array.isArray(cellsResult?.cells) ? cellsResult.cells : [];
|
|
4141
|
+
const ownParagraphIds = new Map(cells.flatMap((cell) => {
|
|
4142
|
+
const record = asRecord(cell);
|
|
4143
|
+
const id = asString(record?.firstParagraphNodeId);
|
|
4144
|
+
return id ? [[id, `${asNumber(record?.rowIndex)}:${asNumber(record?.columnIndex)}`]] : [];
|
|
4145
|
+
}));
|
|
4146
|
+
const extracted = asRecord(await extractFn({}));
|
|
4147
|
+
const tableBlocks = (Array.isArray(extracted?.blocks) ? extracted.blocks : []).flatMap((entry) => {
|
|
4148
|
+
const block = asRecord(entry);
|
|
4149
|
+
const context = asRecord(block?.tableContext);
|
|
4150
|
+
const nodeId = asString(block?.nodeId);
|
|
4151
|
+
const extractOrdinal = asNumber(context?.tableOrdinal);
|
|
4152
|
+
if (!nodeId || extractOrdinal == null)
|
|
4153
|
+
return [];
|
|
4154
|
+
return [
|
|
4155
|
+
{
|
|
4156
|
+
extractOrdinal,
|
|
4157
|
+
paragraph: {
|
|
4158
|
+
nodeId,
|
|
4159
|
+
text: asString(block?.text) ?? '',
|
|
4160
|
+
rowIndex: asNumber(context?.rowIndex) ?? -1,
|
|
4161
|
+
columnIndex: asNumber(context?.columnIndex) ?? -1,
|
|
4162
|
+
},
|
|
4163
|
+
},
|
|
4164
|
+
];
|
|
4165
|
+
});
|
|
4166
|
+
const ownOrdinal = tableBlocks.find((entry) => ownParagraphIds.get(entry.paragraph.nodeId) === `${entry.paragraph.rowIndex}:${entry.paragraph.columnIndex}`)?.extractOrdinal;
|
|
4167
|
+
if (ownOrdinal == null)
|
|
4168
|
+
return [];
|
|
4169
|
+
return tableBlocks.filter((entry) => entry.extractOrdinal === ownOrdinal).map((entry) => entry.paragraph);
|
|
4170
|
+
}
|
|
4171
|
+
async function verifyStyledTableHeader(doc, headerParagraphs, accent) {
|
|
4172
|
+
const check = { kind: 'table-header-styled', fill: accent };
|
|
4173
|
+
const withText = headerParagraphs.filter((paragraph) => paragraph.text.trim().length > 0);
|
|
4174
|
+
if (withText.length === 0) {
|
|
4175
|
+
return { check, passed: headerParagraphs.length > 0, detail: 'header row has no text to read back' };
|
|
4176
|
+
}
|
|
4177
|
+
const queryFn = maybeMethod(doc, ['query', 'match']);
|
|
4178
|
+
if (!queryFn)
|
|
4179
|
+
return { check, passed: false, detail: 'doc.query.match is unavailable; header styling unverified' };
|
|
4180
|
+
const unreadable = [];
|
|
4181
|
+
for (const paragraph of withText) {
|
|
4182
|
+
const runs = await readParagraphRunStyles(queryFn, paragraph);
|
|
4183
|
+
const readable = runs.length > 0 && runs.every((run) => run.bold && !!run.color && contrastRatio(run.color, accent) >= 4.5);
|
|
4184
|
+
if (!readable)
|
|
4185
|
+
unreadable.push(paragraph.text.trim());
|
|
4186
|
+
}
|
|
4187
|
+
return {
|
|
4188
|
+
check,
|
|
4189
|
+
passed: unreadable.length === 0,
|
|
4190
|
+
detail: unreadable.length === 0
|
|
4191
|
+
? `${withText.length} header cell(s) bold with text contrasting ${accent}`
|
|
4192
|
+
: `header text not bold or not contrasting ${accent}: ${unreadable.join(', ')}`,
|
|
4193
|
+
};
|
|
4194
|
+
}
|
|
4195
|
+
async function readParagraphRunStyles(queryFn, paragraph) {
|
|
4196
|
+
const result = asRecord(await queryFn({ select: { type: 'text', pattern: paragraph.text.trim() } }));
|
|
4197
|
+
const items = Array.isArray(result?.items) ? result.items : [];
|
|
4198
|
+
return items.flatMap((item) => {
|
|
4199
|
+
const blocks = Array.isArray(asRecord(item)?.blocks) ? asRecord(item).blocks : [];
|
|
4200
|
+
const block = blocks.map((entry) => asRecord(entry)).find((entry) => asString(entry?.blockId) === paragraph.nodeId);
|
|
4201
|
+
const runs = Array.isArray(block?.runs) ? block.runs : [];
|
|
4202
|
+
return runs.map((run) => {
|
|
4203
|
+
const styles = asRecord(asRecord(run)?.styles);
|
|
4204
|
+
const effective = asRecord(styles?.effective);
|
|
4205
|
+
return { bold: effective?.bold === true, color: asString(effective?.color) ?? asString(styles?.color) };
|
|
4206
|
+
});
|
|
4207
|
+
});
|
|
4208
|
+
}
|
|
4209
|
+
const PAGE_WHITE = '#FFFFFF';
|
|
4210
|
+
const READABLE_CONTRAST = 4.5;
|
|
4211
|
+
async function accentReadsOnLabel(doc, paragraph, accent) {
|
|
4212
|
+
if (contrastRatio(accent, PAGE_WHITE) < READABLE_CONTRAST)
|
|
4213
|
+
return false;
|
|
4214
|
+
const queryFn = maybeMethod(doc, ['query', 'match']);
|
|
4215
|
+
if (!queryFn)
|
|
4216
|
+
return false;
|
|
4217
|
+
try {
|
|
4218
|
+
const runs = await readParagraphRunStyles(queryFn, paragraph);
|
|
4219
|
+
return runs.every((run) => !run.color || contrastRatio(run.color, PAGE_WHITE) >= READABLE_CONTRAST);
|
|
4220
|
+
}
|
|
4221
|
+
catch {
|
|
4222
|
+
return false;
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
function contrastRatio(first, second) {
|
|
4226
|
+
const luminance = (hex) => {
|
|
4227
|
+
const raw = hex.replace(/^#/, '');
|
|
4228
|
+
if (!/^[0-9a-f]{6}$/i.test(raw))
|
|
4229
|
+
return null;
|
|
4230
|
+
const [red, green, blue] = [0, 2, 4].map((offset) => {
|
|
4231
|
+
const channel = Number.parseInt(raw.slice(offset, offset + 2), 16) / 255;
|
|
4232
|
+
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
4233
|
+
});
|
|
4234
|
+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
4235
|
+
};
|
|
4236
|
+
const left = luminance(first);
|
|
4237
|
+
const right = luminance(second);
|
|
4238
|
+
if (left == null || right == null)
|
|
4239
|
+
return 0;
|
|
4240
|
+
return (Math.max(left, right) + 0.05) / (Math.min(left, right) + 0.05);
|
|
4241
|
+
}
|
|
3975
4242
|
/**
|
|
3976
4243
|
* move_table — relocate a WHOLE table (with all its content) in ONE call.
|
|
3977
4244
|
* Wraps doc.tables.move; `resolvePlacement` returns exactly the destination
|
|
@@ -4488,7 +4755,7 @@ function trackedChangeRefIds(result) {
|
|
|
4488
4755
|
return entityId ? [entityId] : [];
|
|
4489
4756
|
}));
|
|
4490
4757
|
}
|
|
4491
|
-
function parseScopedReplaceArgs(args, edits, changeMode, expectedRevision) {
|
|
4758
|
+
function parseScopedReplaceArgs(args, edits, changeMode, expectedRevision, granularity) {
|
|
4492
4759
|
if (!guards.isRecord(args.selector))
|
|
4493
4760
|
return null;
|
|
4494
4761
|
return {
|
|
@@ -4497,8 +4764,21 @@ function parseScopedReplaceArgs(args, edits, changeMode, expectedRevision) {
|
|
|
4497
4764
|
caseSensitive: args.caseSensitive === true,
|
|
4498
4765
|
expectedRevision,
|
|
4499
4766
|
changeMode,
|
|
4767
|
+
...(granularity !== undefined ? { granularity } : {}),
|
|
4500
4768
|
};
|
|
4501
4769
|
}
|
|
4770
|
+
const REPLACEMENT_GRANULARITIES = ['word', 'phrase', 'range'];
|
|
4771
|
+
function parseReplacementGranularity(value) {
|
|
4772
|
+
if (value === undefined)
|
|
4773
|
+
return undefined;
|
|
4774
|
+
const granularity = REPLACEMENT_GRANULARITIES.find((candidate) => candidate === value);
|
|
4775
|
+
if (granularity === undefined) {
|
|
4776
|
+
throw new errors.SuperDocCliError('replace_text: granularity must be "word", "phrase" or "range"', {
|
|
4777
|
+
code: 'INVALID_ARGUMENT',
|
|
4778
|
+
});
|
|
4779
|
+
}
|
|
4780
|
+
return granularity;
|
|
4781
|
+
}
|
|
4502
4782
|
function assertExpectedReplacementRevision(expected, actual) {
|
|
4503
4783
|
if (expected != null && expected !== actual) {
|
|
4504
4784
|
throw new errors.SuperDocCliError('replace_text: expectedRevision is stale. Inspect this live document again before editing.', {
|
|
@@ -4542,7 +4822,7 @@ async function fieldResultRanges(doc, nodeId, blockText) {
|
|
|
4542
4822
|
}
|
|
4543
4823
|
return ranges;
|
|
4544
4824
|
}
|
|
4545
|
-
async function
|
|
4825
|
+
async function resolveScopedReplaceTarget(doc, args) {
|
|
4546
4826
|
assertReplacementTextPayloads(args.edits);
|
|
4547
4827
|
const pre = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
|
|
4548
4828
|
assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
|
|
@@ -4550,20 +4830,28 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4550
4830
|
if (matched.length !== 1) {
|
|
4551
4831
|
const failure = describeSelectorFailure(args.selector, matched, pre, args.edits.map((e) => e.find));
|
|
4552
4832
|
return {
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4833
|
+
receipt: {
|
|
4834
|
+
status: 'failed',
|
|
4835
|
+
intent: 'replace_text',
|
|
4836
|
+
errors: [{ code: 'SELECTOR_NOT_UNIQUE', message: failure.message, recovery: failure.recovery }],
|
|
4837
|
+
},
|
|
4556
4838
|
};
|
|
4557
4839
|
}
|
|
4558
4840
|
const nodeId = matched[0];
|
|
4559
4841
|
const block = findTextTarget(pre, nodeId);
|
|
4560
4842
|
if (!block) {
|
|
4561
4843
|
return {
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4844
|
+
receipt: {
|
|
4845
|
+
status: 'failed',
|
|
4846
|
+
intent: 'replace_text',
|
|
4847
|
+
errors: [{ code: 'TARGET_NOT_FOUND', message: `resolved block ${nodeId} missing from snapshot` }],
|
|
4848
|
+
},
|
|
4565
4849
|
};
|
|
4566
4850
|
}
|
|
4851
|
+
return { pre, nodeId, block };
|
|
4852
|
+
}
|
|
4853
|
+
const ENGINE_MAX_PLAN_STEPS = 200;
|
|
4854
|
+
function planScopedReplaceSpans(args, { pre, nodeId, block }) {
|
|
4567
4855
|
const caseSensitive = args.caseSensitive === true;
|
|
4568
4856
|
const hasExplicitOccurrence = args.edits.some((edit) => edit.occurrence != null);
|
|
4569
4857
|
const perEdit = [];
|
|
@@ -4572,9 +4860,11 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4572
4860
|
const matches = findRanges(block.text, edit.find, caseSensitive);
|
|
4573
4861
|
const spans = edit.occurrence == null ? matches : matches.slice(edit.occurrence - 1, edit.occurrence);
|
|
4574
4862
|
if (hasExplicitOccurrence && spans.length === 0) {
|
|
4575
|
-
return
|
|
4576
|
-
|
|
4577
|
-
|
|
4863
|
+
return {
|
|
4864
|
+
receipt: failedReceipt('replace_text', new errors.SuperDocCliError(edit.occurrence == null
|
|
4865
|
+
? `replace_text: ${JSON.stringify(edit.find)} not found in the selected block. Inspect and choose a valid target.`
|
|
4866
|
+
: `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),
|
|
4867
|
+
};
|
|
4578
4868
|
}
|
|
4579
4869
|
perEdit.push({
|
|
4580
4870
|
find: edit.find,
|
|
@@ -4585,22 +4875,23 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4585
4875
|
allSpans.push({ ...span, replace: edit.replace, find: edit.find, editIndex: perEdit.length - 1 });
|
|
4586
4876
|
}
|
|
4587
4877
|
}
|
|
4588
|
-
const appliedEdits = perEdit.filter((e) => e.occurrences > 0);
|
|
4589
4878
|
const skippedEdits = perEdit.filter((e) => e.occurrences === 0);
|
|
4590
4879
|
if (allSpans.length === 0) {
|
|
4591
4880
|
const cellHint = suggestTableCell(pre, args.edits.map((e) => e.find));
|
|
4592
4881
|
return {
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4882
|
+
receipt: {
|
|
4883
|
+
status: 'failed',
|
|
4884
|
+
intent: 'replace_text',
|
|
4885
|
+
selectedBlock: { nodeId, nodeType: block.nodeType, text: snippetAround(block.text) },
|
|
4886
|
+
errors: [
|
|
4887
|
+
{
|
|
4888
|
+
code: 'MATCH_NOT_FOUND',
|
|
4889
|
+
message: `none of the finds occur in the selected block. Block text: ${JSON.stringify(snippetAround(block.text, 160))}` +
|
|
4890
|
+
(cellHint != null ? ` ${cellHint.message}` : ''),
|
|
4891
|
+
...(cellHint != null ? { recovery: { kind: 'retry', selector: cellHint.selector } } : {}),
|
|
4892
|
+
},
|
|
4893
|
+
],
|
|
4894
|
+
},
|
|
4604
4895
|
};
|
|
4605
4896
|
}
|
|
4606
4897
|
// Overlapping spans (e.g. finds that contain each other) cannot be applied
|
|
@@ -4611,8 +4902,16 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4611
4902
|
let lastEnd = -1;
|
|
4612
4903
|
for (const span of allSpans) {
|
|
4613
4904
|
if (span.start < lastEnd) {
|
|
4905
|
+
const covering = planned[planned.length - 1];
|
|
4906
|
+
if (args.granularity !== undefined && !hasExplicitOccurrence && covering) {
|
|
4907
|
+
return {
|
|
4908
|
+
receipt: failedReceipt('replace_text', new errors.SuperDocCliError(`replace_text: the finds ${JSON.stringify(covering.find)} and ${JSON.stringify(span.find)} overlap in the same block; nothing was changed. Send non-overlapping finds, or one find covering both.`, { code: 'INVALID_ARGUMENT' }), pre),
|
|
4909
|
+
};
|
|
4910
|
+
}
|
|
4614
4911
|
if (hasExplicitOccurrence) {
|
|
4615
|
-
return
|
|
4912
|
+
return {
|
|
4913
|
+
receipt: 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),
|
|
4914
|
+
};
|
|
4616
4915
|
}
|
|
4617
4916
|
droppedOverlaps.push(span);
|
|
4618
4917
|
continue;
|
|
@@ -4637,10 +4936,27 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4637
4936
|
span.end += 1;
|
|
4638
4937
|
perEdit[span.editIndex].collapsedSpace = true;
|
|
4639
4938
|
});
|
|
4939
|
+
const replacements = planned.filter((span) => block.text.slice(span.start, span.end) !== span.replace).length;
|
|
4940
|
+
if (args.granularity !== undefined && !hasExplicitOccurrence && replacements > ENGINE_MAX_PLAN_STEPS) {
|
|
4941
|
+
return {
|
|
4942
|
+
receipt: failedReceipt('replace_text', new errors.SuperDocCliError(`replace_text: ${replacements} granular replacements in one block exceed the ${ENGINE_MAX_PLAN_STEPS}-step plan limit; nothing was changed. Narrow the selector, or run the edit in parts, giving each part's edits explicit occurrence numbers.`, { code: 'INVALID_ARGUMENT' }), pre),
|
|
4943
|
+
};
|
|
4944
|
+
}
|
|
4640
4945
|
// The block text once every planned span is replaced: the whole-block
|
|
4641
4946
|
// rewrite writes it, and exact-target writes must produce it.
|
|
4642
4947
|
const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
|
|
4643
|
-
|
|
4948
|
+
return {
|
|
4949
|
+
perEdit,
|
|
4950
|
+
skippedEdits,
|
|
4951
|
+
planned,
|
|
4952
|
+
droppedOverlaps,
|
|
4953
|
+
expected,
|
|
4954
|
+
hasExplicitOccurrence,
|
|
4955
|
+
caseSensitive,
|
|
4956
|
+
};
|
|
4957
|
+
}
|
|
4958
|
+
async function writesExactScopedTargets(doc, { nodeId, block }, plan, tracked, granular) {
|
|
4959
|
+
const { planned, caseSensitive, hasExplicitOccurrence } = plan;
|
|
4644
4960
|
// Unique tracked finds can be resolved within the block. Repeated finds
|
|
4645
4961
|
// use one whole-block rewrite, whose engine diff preserves unchanged runs.
|
|
4646
4962
|
// Dedupe on the same basis the planner matched on: under a case-sensitive
|
|
@@ -4659,66 +4975,96 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4659
4975
|
const fieldRanges = deletions.length > 0 ? await fieldResultRanges(doc, nodeId, block.text) : [];
|
|
4660
4976
|
const fieldDeletionRisk = fieldRanges == null ||
|
|
4661
4977
|
deletions.some((span) => fieldRanges.some((range) => span.start < range.end && span.end > range.start));
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
start: { kind: 'text', blockId: nodeId, offset: span.start },
|
|
4679
|
-
end: { kind: 'text', blockId: nodeId, offset: span.end },
|
|
4680
|
-
},
|
|
4978
|
+
return !fieldDeletionRisk && (hasExplicitOccurrence || granular || (tracked && uniqueFinds.size === planned.length));
|
|
4979
|
+
}
|
|
4980
|
+
async function executeExactScopedTargets(doc, target, plan, { changeMode, granularity }) {
|
|
4981
|
+
const { pre, nodeId, block } = target;
|
|
4982
|
+
const targetedSpans = [...plan.planned]
|
|
4983
|
+
.reverse()
|
|
4984
|
+
.filter((span) => block.text.slice(span.start, span.end) !== span.replace);
|
|
4985
|
+
const steps = targetedSpans.map((span, index) => ({
|
|
4986
|
+
id: `scoped-replace-${index + 1}`,
|
|
4987
|
+
op: 'text.rewrite',
|
|
4988
|
+
where: {
|
|
4989
|
+
by: 'target',
|
|
4990
|
+
target: {
|
|
4991
|
+
kind: 'selection',
|
|
4992
|
+
start: { kind: 'text', blockId: nodeId, offset: span.start },
|
|
4993
|
+
end: { kind: 'text', blockId: nodeId, offset: span.end },
|
|
4681
4994
|
},
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
wroteExactTargets = true;
|
|
4703
|
-
}
|
|
4704
|
-
else if (hasExplicitOccurrence) {
|
|
4705
|
-
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);
|
|
4706
|
-
}
|
|
4707
|
-
}
|
|
4995
|
+
},
|
|
4996
|
+
args: {
|
|
4997
|
+
replacement: { text: span.replace },
|
|
4998
|
+
style: preserveRewriteStyle(),
|
|
4999
|
+
...replacementGranularityArg(granularity),
|
|
5000
|
+
},
|
|
5001
|
+
}));
|
|
5002
|
+
if (steps.length === 0)
|
|
5003
|
+
return { result: { noop: true }, wroteExactTargets: false };
|
|
5004
|
+
const preview = await previewMutations(doc, steps, changeMode, pre.revision);
|
|
5005
|
+
const previewSteps = Array.isArray(preview.steps) ? preview.steps.filter(guards.isRecord) : [];
|
|
5006
|
+
const aligned = preview.valid === true &&
|
|
5007
|
+
steps.every((step, index) => {
|
|
5008
|
+
const resolutions = previewSteps.find((candidate) => candidate.stepId === step.id)?.resolutions;
|
|
5009
|
+
const resolved = Array.isArray(resolutions) && guards.isRecord(resolutions[0]) ? resolutions[0].text : undefined;
|
|
5010
|
+
const span = targetedSpans[index];
|
|
5011
|
+
return resolved === block.text.slice(span.start, span.end);
|
|
5012
|
+
});
|
|
5013
|
+
if (aligned) {
|
|
5014
|
+
return { result: await executeMutations(doc, steps, changeMode, pre.revision), wroteExactTargets: true };
|
|
4708
5015
|
}
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
5016
|
+
const planLimit = previewFailuresOf(preview).find((failure) => failure.code === 'PLAN_LIMIT_EXCEEDED');
|
|
5017
|
+
if (planLimit && (plan.hasExplicitOccurrence || granularity !== undefined)) {
|
|
5018
|
+
return { receipt: failedReceipt('replace_text', previewRefusal(planLimit), pre) };
|
|
5019
|
+
}
|
|
5020
|
+
if (plan.hasExplicitOccurrence) {
|
|
5021
|
+
return {
|
|
5022
|
+
receipt: 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),
|
|
5023
|
+
};
|
|
5024
|
+
}
|
|
5025
|
+
if (granularity !== undefined) {
|
|
5026
|
+
const refusal = paragraphCrossingError(target, plan) ??
|
|
5027
|
+
(preview.valid !== true
|
|
5028
|
+
? previewRefusal(previewFailuresOf(preview)[0])
|
|
5029
|
+
: new errors.SuperDocCliError('replace_text: a find does not resolve to the same text in the document, so nothing was written. Inspect again before editing.', { code: 'TARGET_NOT_FOUND' }));
|
|
5030
|
+
return { receipt: failedReceipt('replace_text', refusal, pre) };
|
|
5031
|
+
}
|
|
5032
|
+
return { result: null, wroteExactTargets: false };
|
|
5033
|
+
}
|
|
5034
|
+
function paragraphCrossingError({ pre, nodeId }, plan) {
|
|
5035
|
+
const cell = (pre.tables ?? []).flatMap((table) => table.cells).find((candidate) => candidate.nodeId === nodeId);
|
|
5036
|
+
const firstParagraphEnd = cell ? cell.text.indexOf('\n') : -1;
|
|
5037
|
+
if (firstParagraphEnd < 0 || plan.planned.every((span) => span.end <= firstParagraphEnd))
|
|
5038
|
+
return null;
|
|
5039
|
+
return new errors.SuperDocCliError('replace_text: granularity writes inside one paragraph, and the selected table cell holds several. Call replace_text without a selector so each find resolves to its own paragraph.', { code: 'INVALID_ARGUMENT' });
|
|
5040
|
+
}
|
|
5041
|
+
async function executeScopedRewrite(doc, target, plan, args, tracked) {
|
|
5042
|
+
if (await writesExactScopedTargets(doc, target, plan, tracked, args.granularity !== undefined)) {
|
|
5043
|
+
const exact = await executeExactScopedTargets(doc, target, plan, args);
|
|
5044
|
+
if ('receipt' in exact)
|
|
5045
|
+
return exact;
|
|
5046
|
+
if (exact.result != null)
|
|
5047
|
+
return { result: exact.result, wroteExactTargets: exact.wroteExactTargets };
|
|
5048
|
+
}
|
|
5049
|
+
const steps = [
|
|
5050
|
+
{
|
|
5051
|
+
id: 'scoped-replace-1',
|
|
5052
|
+
op: 'text.rewrite',
|
|
5053
|
+
where: { by: 'block', nodeType: target.block.nodeType, nodeId: target.nodeId },
|
|
5054
|
+
args: { replacement: { text: plan.expected }, style: preserveRewriteStyle() },
|
|
5055
|
+
},
|
|
5056
|
+
];
|
|
5057
|
+
return { result: await executeMutations(doc, steps, args.changeMode, target.pre.revision), wroteExactTargets: false };
|
|
5058
|
+
}
|
|
5059
|
+
async function readScopedReplaceBack(doc, target, plan, execution) {
|
|
5060
|
+
try {
|
|
5061
|
+
return await readScopedReplaceBackUnchecked(doc, target, plan, execution);
|
|
4721
5062
|
}
|
|
5063
|
+
catch (readError) {
|
|
5064
|
+
return { post: null, postBlock: null, rolledBack: false, readError };
|
|
5065
|
+
}
|
|
5066
|
+
}
|
|
5067
|
+
async function readScopedReplaceBackUnchecked(doc, { nodeId, block }, plan, execution) {
|
|
4722
5068
|
let post = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
|
|
4723
5069
|
let postBlock = findTextTarget(post, nodeId);
|
|
4724
5070
|
// Exact-target edits must leave exactly the planned visible text. If the
|
|
@@ -4726,10 +5072,11 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4726
5072
|
// atomic history step. Only do so while the document is still at the
|
|
4727
5073
|
// revision this plan produced, so the undo cannot reach any other change.
|
|
4728
5074
|
let rolledBack = false;
|
|
5075
|
+
const { result } = execution;
|
|
4729
5076
|
const appliedRevision = guards.isRecord(result) && guards.isRecord(result.revision) ? result.revision.after : undefined;
|
|
4730
|
-
if (wroteExactTargets &&
|
|
5077
|
+
if (execution.wroteExactTargets &&
|
|
4731
5078
|
postBlock != null &&
|
|
4732
|
-
postBlock.text !== expected &&
|
|
5079
|
+
postBlock.text !== plan.expected &&
|
|
4733
5080
|
postBlock.text !== block.text &&
|
|
4734
5081
|
typeof appliedRevision === 'string' &&
|
|
4735
5082
|
appliedRevision === post.revision) {
|
|
@@ -4741,14 +5088,23 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4741
5088
|
rolledBack = postBlock?.text === block.text;
|
|
4742
5089
|
}
|
|
4743
5090
|
}
|
|
5091
|
+
return { post, postBlock, rolledBack };
|
|
5092
|
+
}
|
|
5093
|
+
function buildScopedReplaceReceipt({ pre, nodeId, block }, plan, execution, { post, postBlock, rolledBack, readError }, tracked) {
|
|
5094
|
+
const { perEdit, skippedEdits, planned, droppedOverlaps, expected, hasExplicitOccurrence } = plan;
|
|
4744
5095
|
// Block text is the visible (final) text. Exact-offset edits must produce
|
|
4745
5096
|
// exactly the planned text; tracked edits that fell back to a whole-block
|
|
4746
5097
|
// rewrite only need each inserted text to be visible.
|
|
4747
5098
|
const passed = postBlock != null &&
|
|
4748
5099
|
!rolledBack &&
|
|
4749
|
-
(tracked && !hasExplicitOccurrence && !wroteExactTargets
|
|
5100
|
+
(tracked && !hasExplicitOccurrence && !execution.wroteExactTargets
|
|
4750
5101
|
? planned.every((span) => span.replace.length === 0 || postBlock.text.includes(span.replace))
|
|
4751
5102
|
: postBlock.text === expected);
|
|
5103
|
+
const readsAsReplacement = (span) => block.text.slice(span.start, span.end) === span.replace;
|
|
5104
|
+
const replaced = planned.filter((span) => !readsAsReplacement(span));
|
|
5105
|
+
const editsApplied = perEdit
|
|
5106
|
+
.map((edit, index) => ({ ...edit, occurrences: replaced.filter((span) => span.editIndex === index).length }))
|
|
5107
|
+
.filter((edit) => edit.occurrences > 0);
|
|
4752
5108
|
// Partially-applied requests must NOT read as success: a model that asked
|
|
4753
5109
|
// for two edits and got one will stop if the receipt says "ok".
|
|
4754
5110
|
const status = !passed ? 'failed' : skippedEdits.length > 0 ? 'partial' : 'ok';
|
|
@@ -4772,24 +5128,42 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4772
5128
|
],
|
|
4773
5129
|
}
|
|
4774
5130
|
: {}),
|
|
5131
|
+
...(post === null ? { errors: [readBackError(readError)] } : {}),
|
|
4775
5132
|
preSnapshot: { revision: pre.revision },
|
|
4776
|
-
postSnapshot: { revision: post.revision },
|
|
5133
|
+
...(post ? { postSnapshot: { revision: post.revision } } : {}),
|
|
4777
5134
|
selectedBlock: { nodeId, nodeType: block.nodeType },
|
|
4778
5135
|
editsRequested: perEdit.length,
|
|
4779
|
-
editsApplied
|
|
5136
|
+
editsApplied,
|
|
5137
|
+
...unchangedEditsEvidence(planned.filter(readsAsReplacement)),
|
|
4780
5138
|
...(skippedEdits.length > 0
|
|
4781
5139
|
? { editsSkipped: skippedEdits.map((e) => ({ find: e.find, reason: 'not present in selected block' })) }
|
|
4782
5140
|
: {}),
|
|
4783
5141
|
...(droppedOverlaps.length > 0 ? { overlapsDropped: droppedOverlaps.map((s) => s.find) } : {}),
|
|
4784
|
-
replacedSpans:
|
|
5142
|
+
replacedSpans: replaced.length,
|
|
4785
5143
|
postText: snippetAround(postBlock?.text ?? ''),
|
|
4786
|
-
executedOperations: [{ operationId: 'doc.mutations.apply', result: compactOpResult(result) }],
|
|
5144
|
+
executedOperations: [{ operationId: 'doc.mutations.apply', result: compactOpResult(execution.result) }],
|
|
4787
5145
|
verification: [
|
|
4788
5146
|
{ check: { kind: 'block-text-matches-expectation' }, passed },
|
|
4789
|
-
|
|
5147
|
+
post
|
|
5148
|
+
? { check: { kind: 'revision-changed' }, passed: pre.revision !== post.revision }
|
|
5149
|
+
: readBackFailure(readError),
|
|
4790
5150
|
],
|
|
4791
5151
|
};
|
|
4792
5152
|
}
|
|
5153
|
+
async function runScopedReplaceUnchecked(doc, args) {
|
|
5154
|
+
const target = await resolveScopedReplaceTarget(doc, args);
|
|
5155
|
+
if ('receipt' in target)
|
|
5156
|
+
return target.receipt;
|
|
5157
|
+
const plan = planScopedReplaceSpans(args, target);
|
|
5158
|
+
if ('receipt' in plan)
|
|
5159
|
+
return plan.receipt;
|
|
5160
|
+
const tracked = (args.changeMode ?? 'direct') === 'tracked';
|
|
5161
|
+
const execution = await executeScopedRewrite(doc, target, plan, args, tracked);
|
|
5162
|
+
if ('receipt' in execution)
|
|
5163
|
+
return execution.receipt;
|
|
5164
|
+
const readBack = await readScopedReplaceBack(doc, target, plan, execution);
|
|
5165
|
+
return buildScopedReplaceReceipt(target, plan, execution, readBack, tracked);
|
|
5166
|
+
}
|
|
4793
5167
|
async function runScopedReplace(doc, args) {
|
|
4794
5168
|
const changeMode = args.changeMode ?? 'direct';
|
|
4795
5169
|
try {
|
|
@@ -4810,48 +5184,6 @@ async function runScopedReplace(doc, args) {
|
|
|
4810
5184
|
*/
|
|
4811
5185
|
/** Points → twips: the node read side reports points, mutations take twips. */
|
|
4812
5186
|
const TWIPS_PER_POINT = 20;
|
|
4813
|
-
/**
|
|
4814
|
-
* Build `format.paragraph.setMarkRunProps` input from agent run marks.
|
|
4815
|
-
*
|
|
4816
|
-
* The mark's colour is a discriminated model object, not the plain hex string
|
|
4817
|
-
* `format.apply` takes, and the hex is stored without the leading `#`.
|
|
4818
|
-
* Returns null when the marks say nothing about the mark's appearance.
|
|
4819
|
-
*/
|
|
4820
|
-
function markRunPropsFromMarks(marks) {
|
|
4821
|
-
if (!marks)
|
|
4822
|
-
return null;
|
|
4823
|
-
const props = {};
|
|
4824
|
-
if (typeof marks.bold === 'boolean')
|
|
4825
|
-
props.bold = marks.bold;
|
|
4826
|
-
if (typeof marks.italic === 'boolean')
|
|
4827
|
-
props.italic = marks.italic;
|
|
4828
|
-
if (typeof marks.fontFamily === 'string' && marks.fontFamily)
|
|
4829
|
-
props.fontFamily = marks.fontFamily;
|
|
4830
|
-
if (typeof marks.fontSize === 'number' && Number.isFinite(marks.fontSize))
|
|
4831
|
-
props.fontSize = marks.fontSize;
|
|
4832
|
-
if (typeof marks.color === 'string' && marks.color) {
|
|
4833
|
-
props.color = { model: 'rgb', value: marks.color.replace(/^#/, '') };
|
|
4834
|
-
}
|
|
4835
|
-
return Object.keys(props).length > 0 ? props : null;
|
|
4836
|
-
}
|
|
4837
|
-
/**
|
|
4838
|
-
* Pull a paragraph's MARK run properties (`w:pPr/w:rPr`) out of a `getNodeById`
|
|
4839
|
-
* payload, ready to hand straight back to `format.paragraph.setMarkRunProps`
|
|
4840
|
-
* (same internal shape, so the values round-trip). Node-shape tolerant like
|
|
4841
|
-
* paragraphIndentOf. Returns null when the paragraph mark carries nothing.
|
|
4842
|
-
*/
|
|
4843
|
-
function paragraphMarkRunPropsOf(payload) {
|
|
4844
|
-
const node = guards.isRecord(payload) ? payload.node : null;
|
|
4845
|
-
if (!guards.isRecord(node))
|
|
4846
|
-
return null;
|
|
4847
|
-
const candidates = [guards.isRecord(node.paragraph) ? node.paragraph.props : null, node.props];
|
|
4848
|
-
for (const props of candidates) {
|
|
4849
|
-
const mark = guards.isRecord(props) ? props.markRunProps : null;
|
|
4850
|
-
if (guards.isRecord(mark) && Object.keys(mark).length > 0)
|
|
4851
|
-
return mark;
|
|
4852
|
-
}
|
|
4853
|
-
return null;
|
|
4854
|
-
}
|
|
4855
5187
|
/**
|
|
4856
5188
|
* Delimiters that separate a list item's lead-in label from its body text.
|
|
4857
5189
|
* Spaced dashes are listed separately from bare ones because the label run
|
|
@@ -5055,6 +5387,7 @@ function matchRunStyle(run) {
|
|
|
5055
5387
|
return {
|
|
5056
5388
|
bold: effective?.bold === true,
|
|
5057
5389
|
italic: effective?.italic === true,
|
|
5390
|
+
underline: effective?.underline === true,
|
|
5058
5391
|
...(sizePt != null && Number.isFinite(sizePt) ? { fontSize: sizePt } : {}),
|
|
5059
5392
|
...(fontFamily ? { fontFamily } : {}),
|
|
5060
5393
|
...(color ? { color } : {}),
|
|
@@ -5066,15 +5399,15 @@ function matchRunStyle(run) {
|
|
|
5066
5399
|
* A toggle that is OFF is dropped rather than sent as an explicit `false`: new
|
|
5067
5400
|
* text starts unformatted, so "not bold" needs no write — and writing it would
|
|
5068
5401
|
* stamp an explicit `w:b w:val="0"` the sampled siblings themselves don't carry.
|
|
5069
|
-
* The exception is a toggle that must be CLEARED because an earlier layer
|
|
5070
|
-
* (`against`) turned it on.
|
|
5071
5402
|
*/
|
|
5072
|
-
function inlineFromMatchStyle(style
|
|
5403
|
+
function inlineFromMatchStyle(style) {
|
|
5073
5404
|
const inline = {};
|
|
5074
|
-
if (style.bold
|
|
5075
|
-
inline.bold =
|
|
5076
|
-
if (style.italic
|
|
5077
|
-
inline.italic =
|
|
5405
|
+
if (style.bold)
|
|
5406
|
+
inline.bold = true;
|
|
5407
|
+
if (style.italic)
|
|
5408
|
+
inline.italic = true;
|
|
5409
|
+
if (style.underline)
|
|
5410
|
+
inline.underline = true;
|
|
5078
5411
|
if (style.fontSize != null)
|
|
5079
5412
|
inline.fontSize = style.fontSize;
|
|
5080
5413
|
if (style.fontFamily)
|
|
@@ -5083,6 +5416,10 @@ function inlineFromMatchStyle(style, against) {
|
|
|
5083
5416
|
inline.color = style.color;
|
|
5084
5417
|
return inline;
|
|
5085
5418
|
}
|
|
5419
|
+
function runPatternOf(bodyStyle, label) {
|
|
5420
|
+
const look = label ? emphasisedLookOf(inlineFromMatchStyle(label.style)) : null;
|
|
5421
|
+
return { body: baseRunLookOf(bodyStyle), ...(label && look ? { lead: { look, shape: label.shape } } : {}) };
|
|
5422
|
+
}
|
|
5086
5423
|
/**
|
|
5087
5424
|
* Read an existing list item's RUN PATTERN — the body look plus the "bold
|
|
5088
5425
|
* label: plain sentence" lead-in convention its siblings share — through
|
|
@@ -5140,27 +5477,16 @@ async function listItemRunPattern(doc, text, blockId) {
|
|
|
5140
5477
|
const label = runs[labelIndex];
|
|
5141
5478
|
const labelText = runTextOf(label);
|
|
5142
5479
|
const leadStyle = matchRunStyle(label);
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
// copying its label look over a whole new block would emphasise the
|
|
5146
|
-
// entire thing. Treat it as a lead-in whose span the new text decides.
|
|
5147
|
-
if (runs.length === 1) {
|
|
5148
|
-
const labelOnly = labelText.length <= LEAD_IN_MAX_CHARS && (leadStyle.bold || leadStyle.italic);
|
|
5149
|
-
return labelOnly
|
|
5150
|
-
? { body: {}, lead: { look: inlineFromMatchStyle(leadStyle), shape: { kind: 'before', delimiter: null } } }
|
|
5151
|
-
: { body: inlineFromMatchStyle(leadStyle) };
|
|
5152
|
-
}
|
|
5480
|
+
if (runs.length === 1)
|
|
5481
|
+
return runPatternOf(leadStyle);
|
|
5153
5482
|
// The body is the run the label INTRODUCES, not the longest run in the
|
|
5154
5483
|
// block: a later emphasised phrase is part of the prose, and sampling it
|
|
5155
5484
|
// would stamp its emphasis across the whole new block.
|
|
5156
5485
|
const bodyStyle = matchRunStyle(runs[labelIndex + 1]);
|
|
5157
|
-
const differs = ['bold', 'italic', 'fontSize', 'fontFamily', 'color'].some((k) => leadStyle[k] !== bodyStyle[k]);
|
|
5486
|
+
const differs = ['bold', 'italic', 'underline', 'fontSize', 'fontFamily', 'color'].some((k) => leadStyle[k] !== bodyStyle[k]);
|
|
5158
5487
|
const shape = leadInShapeOf(blockText, labelText, labelIndex > 0 ? quote : undefined);
|
|
5159
5488
|
const isLabel = differs && shape != null && labelText.length > 0 && labelText.length <= LEAD_IN_MAX_CHARS;
|
|
5160
|
-
return {
|
|
5161
|
-
body: inlineFromMatchStyle(bodyStyle),
|
|
5162
|
-
...(isLabel ? { lead: { look: inlineFromMatchStyle(leadStyle, bodyStyle), shape: shape } } : {}),
|
|
5163
|
-
};
|
|
5489
|
+
return runPatternOf(bodyStyle, isLabel && shape ? { style: leadStyle, shape } : undefined);
|
|
5164
5490
|
}
|
|
5165
5491
|
}
|
|
5166
5492
|
return null;
|
|
@@ -5178,10 +5504,9 @@ function typedPrefixLabelOf(runs, runTextOf) {
|
|
|
5178
5504
|
}
|
|
5179
5505
|
if (covered < markerEnd || labelIndex + 1 >= runs.length)
|
|
5180
5506
|
return null;
|
|
5181
|
-
const underlined = (run) => asRecord(asRecord(asRecord(run)?.styles)?.effective)?.underline === true;
|
|
5182
5507
|
const emphasisOf = (run) => {
|
|
5183
5508
|
const style = matchRunStyle(run);
|
|
5184
|
-
return `${style.bold}/${style.italic}/${
|
|
5509
|
+
return `${style.bold}/${style.italic}/${style.underline}`;
|
|
5185
5510
|
};
|
|
5186
5511
|
const label = runs[labelIndex];
|
|
5187
5512
|
if (emphasisOf(label) === 'false/false/false')
|
|
@@ -5208,15 +5533,10 @@ function typedPrefixLabelOf(runs, runTextOf) {
|
|
|
5208
5533
|
const delimiter = ['.', ...LEAD_IN_DELIMITERS.map((d) => d.trim())].find((d) => labelText.endsWith(d) && labelText.length > d.length);
|
|
5209
5534
|
if (!delimiter)
|
|
5210
5535
|
return null;
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
const bodyUnderline = underlined(body);
|
|
5216
|
-
const look = inlineFromMatchStyle(leadStyle, bodyStyle);
|
|
5217
|
-
if (leadUnderline || bodyUnderline)
|
|
5218
|
-
look.underline = leadUnderline;
|
|
5219
|
-
return { body: inlineFromMatchStyle(bodyStyle), lead: { look, shape: { kind: 'typed', delimiter } } };
|
|
5536
|
+
return runPatternOf(matchRunStyle(runs[bodyIndex]), {
|
|
5537
|
+
style: matchRunStyle(label),
|
|
5538
|
+
shape: { kind: 'typed', delimiter },
|
|
5539
|
+
});
|
|
5220
5540
|
}
|
|
5221
5541
|
/**
|
|
5222
5542
|
* Pull a paragraph's direct indentation out of a `getNodeById` payload and
|
|
@@ -5247,12 +5567,32 @@ function paragraphIndentOf(payload) {
|
|
|
5247
5567
|
}
|
|
5248
5568
|
return null;
|
|
5249
5569
|
}
|
|
5250
|
-
function
|
|
5570
|
+
function emphasisedLookOf(look) {
|
|
5571
|
+
return RUN_BOOL_MARKS.some((key) => look[key] === true) ? look : null;
|
|
5572
|
+
}
|
|
5573
|
+
async function effectiveBaseLookOf(doc, text, block) {
|
|
5574
|
+
const queryFn = maybeMethod(doc, ['query', 'match']);
|
|
5575
|
+
if (!queryFn || !text.trim())
|
|
5576
|
+
return null;
|
|
5577
|
+
const runs = await docSnapshot.matchRunsForBlock(queryFn, text, block.nodeId, { nodeType: block.nodeType, fonts: 'effective' });
|
|
5578
|
+
const looks = runs.filter((run) => run.text).map(baseRunLookOf);
|
|
5579
|
+
if (looks.length === 0)
|
|
5580
|
+
return null;
|
|
5581
|
+
const [first, ...rest] = looks;
|
|
5582
|
+
return rest.every((look) => look.fontFamily === first.fontFamily && look.fontSize === first.fontSize)
|
|
5583
|
+
? first
|
|
5584
|
+
: 'mixed';
|
|
5585
|
+
}
|
|
5586
|
+
function baseRunLookOf(look) {
|
|
5251
5587
|
const inline = {};
|
|
5252
|
-
if (
|
|
5253
|
-
inline.fontFamily =
|
|
5254
|
-
if (typeof
|
|
5255
|
-
inline.fontSize =
|
|
5588
|
+
if (typeof look.fontFamily === 'string' && look.fontFamily)
|
|
5589
|
+
inline.fontFamily = look.fontFamily;
|
|
5590
|
+
if (typeof look.fontSize === 'number' && Number.isFinite(look.fontSize))
|
|
5591
|
+
inline.fontSize = look.fontSize;
|
|
5592
|
+
return inline;
|
|
5593
|
+
}
|
|
5594
|
+
function inlineLookFromRow(row) {
|
|
5595
|
+
const inline = baseRunLookOf(row);
|
|
5256
5596
|
if (row.color)
|
|
5257
5597
|
inline.color = row.color;
|
|
5258
5598
|
if (row.bold === true)
|
|
@@ -6147,17 +6487,11 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
|
|
|
6147
6487
|
}
|
|
6148
6488
|
}
|
|
6149
6489
|
}
|
|
6150
|
-
|
|
6151
|
-
// create already produced — contextual matching only applies the DELTA.
|
|
6152
|
-
const inline = inlineLookFromRow(reference);
|
|
6490
|
+
const inline = baseRunLookOf(reference);
|
|
6153
6491
|
if (inline.fontFamily === created.fontFamily)
|
|
6154
6492
|
delete inline.fontFamily;
|
|
6155
6493
|
if (inline.fontSize === created.fontSize)
|
|
6156
6494
|
delete inline.fontSize;
|
|
6157
|
-
if (inline.color === created.color)
|
|
6158
|
-
delete inline.color;
|
|
6159
|
-
if (created.bold === true)
|
|
6160
|
-
delete inline.bold;
|
|
6161
6495
|
if (Object.keys(inline).length > 0) {
|
|
6162
6496
|
const formatApplyFn = maybeMethod(doc, ['format', 'apply']);
|
|
6163
6497
|
if (formatApplyFn) {
|
|
@@ -6198,15 +6532,14 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
|
|
|
6198
6532
|
// created block. Skipped when the caller supplied explicit runs/marks.
|
|
6199
6533
|
if (allowLeadPattern) {
|
|
6200
6534
|
const lead = await matchLeadMarkerPattern(doc, reference, created, createdText);
|
|
6201
|
-
if (lead)
|
|
6535
|
+
if (lead?.changed)
|
|
6202
6536
|
applied.leadMarker = lead;
|
|
6203
6537
|
// matchLeadMarkerPattern only knows a shared literal lead ("WHEREAS") and
|
|
6204
6538
|
// numbered tokens ("1.", "(m)"), and it needs the reference split across
|
|
6205
6539
|
// two runs. Sample the reference's RUNS for everything else: a quoted
|
|
6206
6540
|
// defined term (`"Deliverables" means …`), a "1.7. Records. " clause whose
|
|
6207
|
-
// marker and label share a run
|
|
6208
|
-
//
|
|
6209
|
-
// name beside which the new one landed plain).
|
|
6541
|
+
// marker and label share a run. A UNIFORM reference is not a split, so
|
|
6542
|
+
// its emphasis never transfers.
|
|
6210
6543
|
else {
|
|
6211
6544
|
const runPattern = await matchBlockRunPattern(doc, reference, created, createdText);
|
|
6212
6545
|
// Assigning `undefined` would still create the key, and an `applied` with
|
|
@@ -6271,10 +6604,9 @@ const LEAD_MARKER_PATTERN = /^\(?(?:[a-zA-Z]{1,3}|\d{1,3})[).:\]]/;
|
|
|
6271
6604
|
* created text starts with the reference's exact lead text;
|
|
6272
6605
|
* - marker tokens ("(m)", "1.", "a)"): the token text differs per clause, so
|
|
6273
6606
|
* the created lead is its OWN token matched by shape.
|
|
6274
|
-
* Applies the reference lead's marks to the created lead span and the
|
|
6275
|
-
* reference body's
|
|
6276
|
-
*
|
|
6277
|
-
* corrected, not compounded.
|
|
6607
|
+
* Applies the reference lead's marks to the created lead span and only the
|
|
6608
|
+
* reference body's font and size to the rest: the body never gains emphasis or
|
|
6609
|
+
* an explicit mark-off it did not already carry.
|
|
6278
6610
|
*/
|
|
6279
6611
|
async function matchLeadMarkerPattern(doc, reference, created, createdText) {
|
|
6280
6612
|
const createdPreview = created.textPreview ?? '';
|
|
@@ -6334,21 +6666,16 @@ async function matchLeadMarkerPattern(doc, reference, created, createdText) {
|
|
|
6334
6666
|
}
|
|
6335
6667
|
if (createdLeadLen === 0)
|
|
6336
6668
|
return null;
|
|
6337
|
-
const
|
|
6338
|
-
|
|
6339
|
-
const bodyInline = inlineFromSnapshotRun(bodyRun, normalize);
|
|
6340
|
-
if (Object.keys(markerInline).length === 0 && Object.keys(bodyInline).length === 0)
|
|
6669
|
+
const markerInline = emphasisedLookOf(inlineFromSnapshotRun(leadRun));
|
|
6670
|
+
if (!markerInline)
|
|
6341
6671
|
return null;
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
inline: markerInline,
|
|
6350
|
-
})) || changed;
|
|
6351
|
-
}
|
|
6672
|
+
const bodyInline = baseRunLookOf(bodyRun);
|
|
6673
|
+
let changed = await applyInlineQuiet(formatFn, {
|
|
6674
|
+
blockId: created.nodeId,
|
|
6675
|
+
start: 0,
|
|
6676
|
+
end: createdLeadLen,
|
|
6677
|
+
inline: markerInline,
|
|
6678
|
+
});
|
|
6352
6679
|
if (Object.keys(bodyInline).length > 0 && fullText.length > createdLeadLen) {
|
|
6353
6680
|
changed =
|
|
6354
6681
|
(await applyInlineQuiet(formatFn, {
|
|
@@ -6361,14 +6688,15 @@ async function matchLeadMarkerPattern(doc, reference, created, createdText) {
|
|
|
6361
6688
|
return { marker: fullText.slice(0, createdLeadLen), markerInline, bodyInline, changed };
|
|
6362
6689
|
}
|
|
6363
6690
|
/**
|
|
6364
|
-
* Copy a reference block's RUN pattern onto a created block: the body
|
|
6365
|
-
* the whole block, then the label look over the new block's own
|
|
6691
|
+
* Copy a reference block's RUN pattern onto a created block: the body's font
|
|
6692
|
+
* and size over the whole block, then the label look over the new block's own
|
|
6693
|
+
* label span.
|
|
6366
6694
|
*
|
|
6367
6695
|
* Complements matchLeadMarkerPattern, which requires either a shared literal
|
|
6368
6696
|
* lead word or a marker token AND a two-run reference. This one reads the
|
|
6369
6697
|
* reference's runs (`query.match`, the only read that reports run styling) and
|
|
6370
|
-
* handles the shapes that leaves out
|
|
6371
|
-
*
|
|
6698
|
+
* handles the shapes that leaves out. A single uniform run has no label, so
|
|
6699
|
+
* nothing but its font transfers.
|
|
6372
6700
|
*/
|
|
6373
6701
|
async function matchBlockRunPattern(doc, reference, created, createdText) {
|
|
6374
6702
|
const formatFn = maybeMethod(doc, ['format', 'apply']);
|
|
@@ -6381,19 +6709,25 @@ async function matchBlockRunPattern(doc, reference, created, createdText) {
|
|
|
6381
6709
|
return null;
|
|
6382
6710
|
const end = Math.max(fullText.length, 1);
|
|
6383
6711
|
const applied = {};
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6712
|
+
const sampled = pattern.body;
|
|
6713
|
+
const own = Object.keys(sampled).length > 0
|
|
6714
|
+
? await effectiveBaseLookOf(doc, fullText, { nodeId: created.nodeId, nodeType: created.nodeType })
|
|
6715
|
+
: null;
|
|
6716
|
+
const body = own === 'mixed'
|
|
6717
|
+
? {}
|
|
6718
|
+
: own
|
|
6719
|
+
? Object.fromEntries(Object.entries(sampled).filter(([key, value]) => own[key] !== value))
|
|
6720
|
+
: sampled;
|
|
6721
|
+
if (Object.keys(body).length > 0) {
|
|
6722
|
+
if (await applyInlineQuiet(formatFn, { blockId: created.nodeId, start: 0, end, inline: body })) {
|
|
6723
|
+
applied.body = body;
|
|
6387
6724
|
}
|
|
6388
6725
|
}
|
|
6389
6726
|
if (pattern.lead) {
|
|
6390
|
-
const
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
(Object.keys(pattern.body).length === 0 && pattern.lead.shape.kind !== 'typed' ? { start: 0, end } : null);
|
|
6395
|
-
if (range && (await applyInlineQuiet(formatFn, { blockId: created.nodeId, ...range, inline: pattern.lead.look }))) {
|
|
6396
|
-
applied.lead = { ...pattern.lead.look, range: [range.start, range.end] };
|
|
6727
|
+
const { look, shape } = pattern.lead;
|
|
6728
|
+
const span = leadInSpanOf(fullText, shape);
|
|
6729
|
+
if (span && (await applyInlineQuiet(formatFn, { blockId: created.nodeId, ...span, inline: look }))) {
|
|
6730
|
+
applied.lead = { ...look, range: [span.start, span.end] };
|
|
6397
6731
|
}
|
|
6398
6732
|
}
|
|
6399
6733
|
return Object.keys(applied).length > 0 ? { matchedSibling: reference.nodeId, ...applied } : null;
|
|
@@ -6748,25 +7082,26 @@ async function dispatchSuperdocPerformAction(doc, args) {
|
|
|
6748
7082
|
});
|
|
6749
7083
|
}
|
|
6750
7084
|
const changeMode = parseReplaceTextChangeMode(args.changeMode);
|
|
7085
|
+
const parsedGranularity = parseReplacementGranularity(args.granularity);
|
|
7086
|
+
const granularity = changeMode === 'tracked' ? parsedGranularity : undefined;
|
|
6751
7087
|
// Selector-scoped replaces take the span-targeted path: per-find
|
|
6752
7088
|
// text.rewrite spans inside the one selected block, so tabs/breaks/
|
|
6753
7089
|
// images outside the spans survive and the receipt reports per-edit
|
|
6754
7090
|
// applied/skipped truth.
|
|
6755
|
-
const scoped = parseScopedReplaceArgs(args, edits, changeMode, expectedRevision);
|
|
7091
|
+
const scoped = parseScopedReplaceArgs(args, edits, changeMode, expectedRevision, granularity);
|
|
6756
7092
|
if (scoped) {
|
|
6757
7093
|
return runScopedReplace(doc, scoped);
|
|
6758
7094
|
}
|
|
6759
|
-
|
|
6760
|
-
if (args.selector != null && selector == null) {
|
|
7095
|
+
if (args.selector != null) {
|
|
6761
7096
|
throw new errors.SuperDocCliError('replace_text: invalid "selector"', { code: 'INVALID_ARGUMENT' });
|
|
6762
7097
|
}
|
|
6763
7098
|
return runReplaceText(doc, {
|
|
6764
7099
|
action,
|
|
6765
7100
|
edits,
|
|
6766
|
-
selector,
|
|
6767
7101
|
caseSensitive: args.caseSensitive === true,
|
|
6768
7102
|
expectedRevision,
|
|
6769
7103
|
changeMode,
|
|
7104
|
+
...(granularity !== undefined ? { granularity } : {}),
|
|
6770
7105
|
});
|
|
6771
7106
|
}
|
|
6772
7107
|
case 'delete_text': {
|
|
@@ -7408,9 +7743,30 @@ function parseReplaceTextChangeMode(value) {
|
|
|
7408
7743
|
});
|
|
7409
7744
|
}
|
|
7410
7745
|
function parseTrackedChangeKind(value) {
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7746
|
+
return typeof value === 'string' ? trackedChangeKindOf(value) : undefined;
|
|
7747
|
+
}
|
|
7748
|
+
const TRACKED_CHANGE_KIND_BY_LIST_TYPE = {
|
|
7749
|
+
insert: 'insert',
|
|
7750
|
+
insertion: 'insert',
|
|
7751
|
+
delete: 'delete',
|
|
7752
|
+
deletion: 'delete',
|
|
7753
|
+
replacement: 'replacement',
|
|
7754
|
+
format: 'format',
|
|
7755
|
+
formatting: 'format',
|
|
7756
|
+
};
|
|
7757
|
+
function trackedChangeKindOf(type) {
|
|
7758
|
+
return type !== undefined && Object.hasOwn(TRACKED_CHANGE_KIND_BY_LIST_TYPE, type)
|
|
7759
|
+
? TRACKED_CHANGE_KIND_BY_LIST_TYPE[type]
|
|
7760
|
+
: undefined;
|
|
7761
|
+
}
|
|
7762
|
+
function countTrackedChangesByKind(items) {
|
|
7763
|
+
const counts = {};
|
|
7764
|
+
for (const item of items) {
|
|
7765
|
+
const type = asString(item?.type);
|
|
7766
|
+
const kind = trackedChangeKindOf(type) ?? (type || 'unknown');
|
|
7767
|
+
counts[kind] = (counts[kind] ?? 0) + 1;
|
|
7768
|
+
}
|
|
7769
|
+
return counts;
|
|
7414
7770
|
}
|
|
7415
7771
|
function parseStringArray(value) {
|
|
7416
7772
|
if (!Array.isArray(value))
|