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