@case-framework/survey-assistant 0.1.0

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.
Files changed (98) hide show
  1. package/README.md +212 -0
  2. package/dist/authoring-references-Cy8J-DWt.d.mts +48 -0
  3. package/dist/authoring-references-Cy8J-DWt.d.mts.map +1 -0
  4. package/dist/authoring-references-X6uRaWuf.mjs +68 -0
  5. package/dist/authoring-references-X6uRaWuf.mjs.map +1 -0
  6. package/dist/capabilities-B6tvE7fK.mjs +66 -0
  7. package/dist/capabilities-B6tvE7fK.mjs.map +1 -0
  8. package/dist/capabilities-DvSKtjAw.d.mts +182 -0
  9. package/dist/capabilities-DvSKtjAw.d.mts.map +1 -0
  10. package/dist/capabilities-default.d.mts +15 -0
  11. package/dist/capabilities-default.d.mts.map +1 -0
  12. package/dist/capabilities-default.mjs +347 -0
  13. package/dist/capabilities-default.mjs.map +1 -0
  14. package/dist/constants-CFUWrRtl.mjs +7 -0
  15. package/dist/constants-CFUWrRtl.mjs.map +1 -0
  16. package/dist/constants-Jw0k8ojS.d.mts +67 -0
  17. package/dist/constants-Jw0k8ojS.d.mts.map +1 -0
  18. package/dist/controller-proxy-BRDusFiO.d.mts +57 -0
  19. package/dist/controller-proxy-BRDusFiO.d.mts.map +1 -0
  20. package/dist/controller-proxy-Duxo2IFE.mjs +92 -0
  21. package/dist/controller-proxy-Duxo2IFE.mjs.map +1 -0
  22. package/dist/digest-BsoVq49o.d.mts +9 -0
  23. package/dist/digest-BsoVq49o.d.mts.map +1 -0
  24. package/dist/digest.d.mts +2 -0
  25. package/dist/digest.mjs +20 -0
  26. package/dist/digest.mjs.map +1 -0
  27. package/dist/engine-sPvuixWg.mjs +4857 -0
  28. package/dist/engine-sPvuixWg.mjs.map +1 -0
  29. package/dist/engine.d.mts +3 -0
  30. package/dist/engine.mjs +4 -0
  31. package/dist/index-BWNHkpkh.d.mts +187 -0
  32. package/dist/index-BWNHkpkh.d.mts.map +1 -0
  33. package/dist/index-CUqbBjkc.d.mts +641 -0
  34. package/dist/index-CUqbBjkc.d.mts.map +1 -0
  35. package/dist/index-MpEMQzTm.d.mts +123 -0
  36. package/dist/index-MpEMQzTm.d.mts.map +1 -0
  37. package/dist/index-TFtvBiSQ.d.mts +1149 -0
  38. package/dist/index-TFtvBiSQ.d.mts.map +1 -0
  39. package/dist/memory-thread-repository-CJkkVE2-.mjs +718 -0
  40. package/dist/memory-thread-repository-CJkkVE2-.mjs.map +1 -0
  41. package/dist/protocol-DV0ka9WT.mjs +1032 -0
  42. package/dist/protocol-DV0ka9WT.mjs.map +1 -0
  43. package/dist/protocol.d.mts +3 -0
  44. package/dist/protocol.mjs +2 -0
  45. package/dist/react-VuYe-cPh.mjs +521 -0
  46. package/dist/react-VuYe-cPh.mjs.map +1 -0
  47. package/dist/react-integration.d.mts +2 -0
  48. package/dist/react-integration.mjs +3 -0
  49. package/dist/react.d.mts +3 -0
  50. package/dist/react.mjs +4 -0
  51. package/dist/references/assistant-operations.md +287 -0
  52. package/dist/references/core-rules.md +11 -0
  53. package/dist/references/embedded-forms.md +80 -0
  54. package/dist/references/expressions.md +301 -0
  55. package/dist/references/item-types.md +364 -0
  56. package/dist/references/localization.md +25 -0
  57. package/dist/references/rich-text-content.md +193 -0
  58. package/dist/references/source-material-surveys.md +60 -0
  59. package/dist/references/survey-data-model.md +109 -0
  60. package/dist/references/survey-quality.md +23 -0
  61. package/dist/request-context-jcmXT_Q2.mjs +44 -0
  62. package/dist/request-context-jcmXT_Q2.mjs.map +1 -0
  63. package/dist/request-guardrails-C59IWnH8.mjs +73 -0
  64. package/dist/request-guardrails-C59IWnH8.mjs.map +1 -0
  65. package/dist/server-agent.d.mts +131 -0
  66. package/dist/server-agent.d.mts.map +1 -0
  67. package/dist/server-agent.mjs +229 -0
  68. package/dist/server-agent.mjs.map +1 -0
  69. package/dist/server-runtime.d.mts +123 -0
  70. package/dist/server-runtime.d.mts.map +1 -0
  71. package/dist/server-runtime.mjs +296 -0
  72. package/dist/server-runtime.mjs.map +1 -0
  73. package/dist/server-tasks.d.mts +78 -0
  74. package/dist/server-tasks.d.mts.map +1 -0
  75. package/dist/server-tasks.mjs +253 -0
  76. package/dist/server-tasks.mjs.map +1 -0
  77. package/dist/server-tools.d.mts +2 -0
  78. package/dist/server-tools.mjs +2 -0
  79. package/dist/server.d.mts +52 -0
  80. package/dist/server.d.mts.map +1 -0
  81. package/dist/server.mjs +6 -0
  82. package/dist/storage-postgres.d.mts +37 -0
  83. package/dist/storage-postgres.d.mts.map +1 -0
  84. package/dist/storage-postgres.mjs +167 -0
  85. package/dist/storage-postgres.mjs.map +1 -0
  86. package/dist/thread-documents-DamIq9Od.d.mts +91 -0
  87. package/dist/thread-documents-DamIq9Od.d.mts.map +1 -0
  88. package/dist/thread-handlers-DpynUc64.d.mts +112 -0
  89. package/dist/thread-handlers-DpynUc64.d.mts.map +1 -0
  90. package/dist/tools-Hz2p7vyv.mjs +1879 -0
  91. package/dist/tools-Hz2p7vyv.mjs.map +1 -0
  92. package/dist/ui.css +2 -0
  93. package/dist/ui.d.mts +63 -0
  94. package/dist/ui.d.mts.map +1 -0
  95. package/dist/ui.mjs +2238 -0
  96. package/dist/ui.mjs.map +1 -0
  97. package/package.json +166 -0
  98. package/ui.css.d.ts +3 -0
@@ -0,0 +1,4857 @@
1
+ import { N as rawSurveyDocumentSchema, c as surveyAssistantTurnFinalSchema, u as surveyChangeSetSchema } from "./protocol-DV0ka9WT.mjs";
2
+ import { n as assertSurveyAssistantCapabilities } from "./capabilities-B6tvE7fK.mjs";
3
+ import { z } from "zod";
4
+ import { ContentType, Expression, ExpressionType, FunctionExpressionNames, JsonPatchError, ReservedSurveyItemTypes, SurveyTranslations, ValueReferenceMethod, ValueType, applyJsonPatch, generateId, getContentPlainText, isContentEmpty, parseJsonPointer, structuredCloneMethod } from "@case-framework/survey-core";
5
+ //#region src/engine/context.ts
6
+ const isRecord$6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7
+ const asString$1 = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
8
+ const asArray$1 = (value) => Array.isArray(value) ? value : [];
9
+ const getChildIds = (item) => {
10
+ return asArray$1((isRecord$6(item.config) ? item.config : void 0)?.items).filter((entry) => typeof entry === "string");
11
+ };
12
+ const getFieldValueType = (field) => {
13
+ switch (field.type) {
14
+ case "number":
15
+ case "slider": return ValueType.number;
16
+ case "datepicker": return ValueType.date;
17
+ case "switch": return ValueType.boolean;
18
+ default: return ValueType.string;
19
+ }
20
+ };
21
+ const addResponseSlot = (lookup, itemId, slotId, valueType) => {
22
+ lookup[`${itemId}...${ValueReferenceMethod.get}...${slotId}`] = valueType;
23
+ lookup[`${itemId}...${ValueReferenceMethod.isDefined}...${slotId}`] = ValueType.boolean;
24
+ };
25
+ const addFormResponseSlots = ({ config, itemId, lookup, slotPrefix }) => {
26
+ if (!isRecord$6(config)) return;
27
+ for (const group of asArray$1(config.fieldGroups).filter(isRecord$6)) for (const field of asArray$1(group.fields).filter(isRecord$6)) {
28
+ const key = asString$1(field.key);
29
+ if (!key) continue;
30
+ addResponseSlot(lookup, itemId, slotPrefix ? `${slotPrefix}.${key}` : key, getFieldValueType(field));
31
+ }
32
+ };
33
+ const deriveResponseSlotTypes = (survey, trustedCapabilities) => {
34
+ const lookup = {};
35
+ for (const item of survey.surveyItems) {
36
+ const deriveResponseSlots = trustedCapabilities?.byItemType.get(item.itemType)?.deriveResponseSlots;
37
+ if (deriveResponseSlots) {
38
+ for (const [slotId, valueType] of Object.entries(deriveResponseSlots(item))) addResponseSlot(lookup, item.id, slotId, valueType);
39
+ continue;
40
+ }
41
+ if (item.itemType === "choiceItem") {
42
+ const config = isRecord$6(item.config) ? item.config : {};
43
+ const slotId = asString$1(config.id) ?? item.id;
44
+ addResponseSlot(lookup, item.id, slotId, config.maxSelection === 1 ? ValueType.reference : ValueType.referenceArray);
45
+ for (const option of asArray$1(config.options).filter(isRecord$6)) {
46
+ const optionId = asString$1(option.id);
47
+ if (optionId && option.embeddedForm) addFormResponseSlots({
48
+ config: option.embeddedForm,
49
+ itemId: item.id,
50
+ lookup,
51
+ slotPrefix: `embedded.${optionId}`
52
+ });
53
+ }
54
+ } else if (item.itemType === "formItem") addFormResponseSlots({
55
+ config: item.config,
56
+ itemId: item.id,
57
+ lookup
58
+ });
59
+ }
60
+ return lookup;
61
+ };
62
+ const buildDerivedContext = (snapshot, trustedCapabilities, requestedFocusItemId) => {
63
+ const rawItems = snapshot.survey.surveyItems;
64
+ const itemsById = new Map(rawItems.map((item) => [item.id, item]));
65
+ const parentById = /* @__PURE__ */ new Map();
66
+ for (const item of rawItems) for (const childId of getChildIds(item)) if (itemsById.has(childId) && !parentById.has(childId)) parentById.set(childId, item.id);
67
+ const fullKeyCache = /* @__PURE__ */ new Map();
68
+ const getFullKey = (itemId, stack = /* @__PURE__ */ new Set()) => {
69
+ const cached = fullKeyCache.get(itemId);
70
+ if (cached) return cached;
71
+ const item = itemsById.get(itemId);
72
+ if (!item) return itemId;
73
+ if (stack.has(itemId)) return item.key;
74
+ const parentId = parentById.get(itemId);
75
+ if (!parentId) return item.key;
76
+ stack.add(itemId);
77
+ const fullKey = `${getFullKey(parentId, stack)}.${item.key}`;
78
+ stack.delete(itemId);
79
+ fullKeyCache.set(itemId, fullKey);
80
+ return fullKey;
81
+ };
82
+ const outline = [];
83
+ const visited = /* @__PURE__ */ new Set();
84
+ const visit = (itemId, depth) => {
85
+ if (visited.has(itemId)) return;
86
+ const item = itemsById.get(itemId);
87
+ if (!item) return;
88
+ visited.add(itemId);
89
+ outline.push({
90
+ itemId,
91
+ parentId: parentById.get(itemId),
92
+ depth,
93
+ itemType: item.itemType,
94
+ key: item.key,
95
+ fullKey: getFullKey(itemId),
96
+ itemLabel: item.metadata?.itemLabel,
97
+ childCount: getChildIds(item).length
98
+ });
99
+ for (const childId of getChildIds(item)) visit(childId, depth + 1);
100
+ };
101
+ const explicitRoot = rawItems.find((item) => isRecord$6(item.config) && item.config.isRoot === true);
102
+ if (explicitRoot) visit(explicitRoot.id, 0);
103
+ for (const item of [...rawItems].sort((left, right) => left.id.localeCompare(right.id))) if (!visited.has(item.id)) visit(item.id, 0);
104
+ const keyIndex = outline.map((node) => ({
105
+ itemId: node.itemId,
106
+ itemType: node.itemType,
107
+ key: node.key,
108
+ fullKey: node.fullKey,
109
+ itemLabel: node.itemLabel,
110
+ path: node.fullKey.split(".")
111
+ }));
112
+ const translations = new SurveyTranslations(snapshot.survey.translations);
113
+ const buildFocusNode = (itemId, budget) => {
114
+ const item = itemsById.get(itemId);
115
+ budget.remaining -= 1;
116
+ const itemTranslations = translations.getItemTranslations(itemId);
117
+ const translationSnippets = (itemTranslations?.locales ?? []).flatMap((locale) => Object.entries(itemTranslations?.getAllForLocale(locale) ?? {}).slice(0, 8).map(([contentKey, content]) => ({
118
+ locale,
119
+ contentKey,
120
+ text: getContentPlainText(content).slice(0, 120)
121
+ })));
122
+ return {
123
+ itemId,
124
+ itemType: item.itemType,
125
+ itemKey: item.key,
126
+ fullKey: getFullKey(itemId),
127
+ itemLabel: item.metadata?.itemLabel,
128
+ siblingKeys: rawItems.filter((candidate) => candidate.id !== itemId && parentById.get(candidate.id) === parentById.get(itemId)).map((candidate) => candidate.key),
129
+ translations: translationSnippets,
130
+ descendants: getChildIds(item).filter((childId) => itemsById.has(childId) && budget.remaining > 0).map((childId) => buildFocusNode(childId, budget))
131
+ };
132
+ };
133
+ const focusItemId = requestedFocusItemId ?? snapshot.ui.focusItemId ?? snapshot.ui.selectedItemId;
134
+ let focus;
135
+ let focusItem;
136
+ let focusItemTreeTruncated = false;
137
+ if (focusItemId && itemsById.has(focusItemId)) {
138
+ const pathItemIds = [];
139
+ let currentId = focusItemId;
140
+ const pathSeen = /* @__PURE__ */ new Set();
141
+ while (currentId && !pathSeen.has(currentId)) {
142
+ pathSeen.add(currentId);
143
+ pathItemIds.unshift(currentId);
144
+ currentId = parentById.get(currentId);
145
+ }
146
+ const parentItemId = parentById.get(focusItemId);
147
+ focus = {
148
+ focusItemId,
149
+ parentItemId,
150
+ pathItemIds,
151
+ siblingItemIds: rawItems.filter((candidate) => candidate.id !== focusItemId && parentById.get(candidate.id) === parentItemId).map((candidate) => candidate.id),
152
+ childItemIds: getChildIds(itemsById.get(focusItemId))
153
+ };
154
+ const budget = { remaining: 120 };
155
+ focusItem = buildFocusNode(focusItemId, budget);
156
+ focusItemTreeTruncated = budget.remaining === 0;
157
+ }
158
+ const responseSlotTypes = deriveResponseSlotTypes(snapshot.survey, trustedCapabilities);
159
+ const locales = [snapshot.ui.selectedLocale, ...Object.keys(snapshot.survey.translations ?? {}).filter((locale) => locale !== snapshot.ui.selectedLocale)].filter((locale, index, values) => locale.length > 0 && values.indexOf(locale) === index);
160
+ return {
161
+ inspection: {
162
+ surveyKey: explicitRoot?.key,
163
+ locales,
164
+ itemCount: rawItems.length,
165
+ outline,
166
+ focus,
167
+ focusItem,
168
+ keyIndex,
169
+ responseSlots: Object.keys(responseSlotTypes),
170
+ focusItemTreeTruncated
171
+ },
172
+ responseSlotTypes
173
+ };
174
+ };
175
+ const createSurveyAssistantEngineContext = (document, trustedCapabilities, options) => {
176
+ assertSurveyAssistantCapabilities({
177
+ manifest: document.capabilities,
178
+ trustedCapabilities,
179
+ versionDigest: document.version.capabilityDigest
180
+ });
181
+ const { inspection, responseSlotTypes } = buildDerivedContext(document, trustedCapabilities, options?.focusItemId);
182
+ return {
183
+ document,
184
+ version: document.version,
185
+ survey: document.survey,
186
+ ui: document.ui,
187
+ capabilities: document.capabilities,
188
+ responseSlotTypes,
189
+ inspection,
190
+ trustedCapabilities
191
+ };
192
+ };
193
+ //#endregion
194
+ //#region src/engine/expression-validation.ts
195
+ const createIssue$2 = ({ severity = "error", ...input }) => ({
196
+ severity,
197
+ ...input
198
+ });
199
+ const formatValueType = (valueType) => valueType ?? "unknown";
200
+ const addFunctionSignatureIssue = ({ issues, message, operationIndex, scope }) => {
201
+ issues.push(createIssue$2({
202
+ code: "expression-function-signature-mismatch",
203
+ message: `${scope} has an invalid function expression: ${message}`,
204
+ operationIndex
205
+ }));
206
+ };
207
+ const validateArgumentCount = ({ actual, expected, functionName, issues, operationIndex, scope }) => {
208
+ if (actual === expected) return;
209
+ addFunctionSignatureIssue({
210
+ issues,
211
+ operationIndex,
212
+ scope,
213
+ message: `${functionName} expects ${expected} argument${expected === 1 ? "" : "s"}, got ${actual}.`
214
+ });
215
+ };
216
+ const inferExpressionReturnType = (expression, responseSlotTypes) => {
217
+ if (!expression) return;
218
+ if (expression.type === ExpressionType.Const) return expression.value?.type;
219
+ if (expression.type === ExpressionType.ResponseVariable) return responseSlotTypes[expression.variableRef];
220
+ if (expression.type === ExpressionType.ContextVariable) {
221
+ if (expression.contextType === "locale") return ValueType.string;
222
+ return expression.asType;
223
+ }
224
+ switch (expression.functionName) {
225
+ case FunctionExpressionNames.and:
226
+ case FunctionExpressionNames.or:
227
+ case FunctionExpressionNames.not:
228
+ case FunctionExpressionNames.eq:
229
+ case FunctionExpressionNames.gt:
230
+ case FunctionExpressionNames.gte:
231
+ case FunctionExpressionNames.lt:
232
+ case FunctionExpressionNames.lte:
233
+ case FunctionExpressionNames.in_range:
234
+ case FunctionExpressionNames.list_contains:
235
+ case FunctionExpressionNames.str_eq:
236
+ case FunctionExpressionNames.date_eq: return ValueType.boolean;
237
+ case FunctionExpressionNames.sum:
238
+ case FunctionExpressionNames.min:
239
+ case FunctionExpressionNames.max: return ValueType.number;
240
+ }
241
+ };
242
+ const validateExpressionFunctionSignatures = ({ expression, issues, operationIndex, responseSlotTypes, scope }) => {
243
+ if (!expression || expression.type !== ExpressionType.Function) return;
244
+ expression.arguments.forEach((argument, argumentIndex) => {
245
+ validateExpressionFunctionSignatures({
246
+ expression: argument,
247
+ issues,
248
+ operationIndex,
249
+ responseSlotTypes,
250
+ scope: `${scope} argument ${argumentIndex + 1}`
251
+ });
252
+ });
253
+ const argumentTypes = expression.arguments.map((argument) => inferExpressionReturnType(argument, responseSlotTypes));
254
+ const requireBooleanArguments = () => {
255
+ argumentTypes.forEach((argumentType, argumentIndex) => {
256
+ if (argumentType && argumentType !== ValueType.boolean) addFunctionSignatureIssue({
257
+ issues,
258
+ operationIndex,
259
+ scope,
260
+ message: `${expression.functionName} argument ${argumentIndex + 1} must be boolean, got ${formatValueType(argumentType)}.`
261
+ });
262
+ });
263
+ };
264
+ const requireNumericArguments = () => {
265
+ argumentTypes.forEach((argumentType, argumentIndex) => {
266
+ if (argumentType && argumentType !== ValueType.number) addFunctionSignatureIssue({
267
+ issues,
268
+ operationIndex,
269
+ scope,
270
+ message: `${expression.functionName} argument ${argumentIndex + 1} must be number, got ${formatValueType(argumentType)}.`
271
+ });
272
+ });
273
+ };
274
+ const requireSameKnownType = () => {
275
+ if (argumentTypes.length < 2 || !argumentTypes[0] || !argumentTypes[1]) return;
276
+ if (argumentTypes[0] !== argumentTypes[1]) addFunctionSignatureIssue({
277
+ issues,
278
+ operationIndex,
279
+ scope,
280
+ message: `${expression.functionName} arguments must have the same type, got ${formatValueType(argumentTypes[0])} and ${formatValueType(argumentTypes[1])}.`
281
+ });
282
+ };
283
+ switch (expression.functionName) {
284
+ case FunctionExpressionNames.and:
285
+ case FunctionExpressionNames.or:
286
+ requireBooleanArguments();
287
+ break;
288
+ case FunctionExpressionNames.not:
289
+ validateArgumentCount({
290
+ actual: expression.arguments.length,
291
+ expected: 1,
292
+ functionName: expression.functionName,
293
+ issues,
294
+ operationIndex,
295
+ scope
296
+ });
297
+ requireBooleanArguments();
298
+ break;
299
+ case FunctionExpressionNames.str_eq:
300
+ validateArgumentCount({
301
+ actual: expression.arguments.length,
302
+ expected: 2,
303
+ functionName: expression.functionName,
304
+ issues,
305
+ operationIndex,
306
+ scope
307
+ });
308
+ argumentTypes.forEach((argumentType, argumentIndex) => {
309
+ if (argumentType && argumentType !== ValueType.string) addFunctionSignatureIssue({
310
+ issues,
311
+ operationIndex,
312
+ scope,
313
+ message: `str_eq argument ${argumentIndex + 1} must be string, got ${formatValueType(argumentType)}.`
314
+ });
315
+ });
316
+ break;
317
+ case FunctionExpressionNames.date_eq:
318
+ validateArgumentCount({
319
+ actual: expression.arguments.length,
320
+ expected: 2,
321
+ functionName: expression.functionName,
322
+ issues,
323
+ operationIndex,
324
+ scope
325
+ });
326
+ argumentTypes.forEach((argumentType, argumentIndex) => {
327
+ if (argumentType && argumentType !== ValueType.date) addFunctionSignatureIssue({
328
+ issues,
329
+ operationIndex,
330
+ scope,
331
+ message: `date_eq argument ${argumentIndex + 1} must be date, got ${formatValueType(argumentType)}.`
332
+ });
333
+ });
334
+ break;
335
+ case FunctionExpressionNames.eq:
336
+ case FunctionExpressionNames.gt:
337
+ case FunctionExpressionNames.gte:
338
+ case FunctionExpressionNames.lt:
339
+ case FunctionExpressionNames.lte:
340
+ validateArgumentCount({
341
+ actual: expression.arguments.length,
342
+ expected: 2,
343
+ functionName: expression.functionName,
344
+ issues,
345
+ operationIndex,
346
+ scope
347
+ });
348
+ requireSameKnownType();
349
+ break;
350
+ case FunctionExpressionNames.in_range:
351
+ validateArgumentCount({
352
+ actual: expression.arguments.length,
353
+ expected: 4,
354
+ functionName: expression.functionName,
355
+ issues,
356
+ operationIndex,
357
+ scope
358
+ });
359
+ argumentTypes.slice(0, 3).forEach((argumentType, argumentIndex) => {
360
+ if (argumentType && argumentType !== ValueType.number) addFunctionSignatureIssue({
361
+ issues,
362
+ operationIndex,
363
+ scope,
364
+ message: `in_range argument ${argumentIndex + 1} must be number, got ${formatValueType(argumentType)}.`
365
+ });
366
+ });
367
+ if (argumentTypes[3] && argumentTypes[3] !== ValueType.boolean) addFunctionSignatureIssue({
368
+ issues,
369
+ operationIndex,
370
+ scope,
371
+ message: `in_range argument 4 must be boolean, got ${formatValueType(argumentTypes[3])}.`
372
+ });
373
+ break;
374
+ case FunctionExpressionNames.sum:
375
+ case FunctionExpressionNames.min:
376
+ case FunctionExpressionNames.max:
377
+ if (expression.arguments.length < 1) addFunctionSignatureIssue({
378
+ issues,
379
+ operationIndex,
380
+ scope,
381
+ message: `${expression.functionName} expects at least 1 argument, got 0.`
382
+ });
383
+ requireNumericArguments();
384
+ break;
385
+ case FunctionExpressionNames.list_contains: {
386
+ validateArgumentCount({
387
+ actual: expression.arguments.length,
388
+ expected: 2,
389
+ functionName: expression.functionName,
390
+ issues,
391
+ operationIndex,
392
+ scope
393
+ });
394
+ const listType = argumentTypes[0];
395
+ const itemType = argumentTypes[1];
396
+ if (listType && listType !== ValueType.stringArray && listType !== ValueType.referenceArray) addFunctionSignatureIssue({
397
+ issues,
398
+ operationIndex,
399
+ scope,
400
+ message: `list_contains argument 1 must be string[] or reference[], got ${formatValueType(listType)}. Use eq for single-choice reference responses.`
401
+ });
402
+ if (listType === ValueType.stringArray && itemType && itemType !== ValueType.string) addFunctionSignatureIssue({
403
+ issues,
404
+ operationIndex,
405
+ scope,
406
+ message: `list_contains with string[] needs a string item, got ${formatValueType(itemType)}.`
407
+ });
408
+ if (listType === ValueType.referenceArray && itemType && itemType !== ValueType.reference) addFunctionSignatureIssue({
409
+ issues,
410
+ operationIndex,
411
+ scope,
412
+ message: `list_contains with reference[] needs a reference item, got ${formatValueType(itemType)}.`
413
+ });
414
+ break;
415
+ }
416
+ }
417
+ };
418
+ const validateExpression = ({ expression, expectedReturnType, issues, operationIndex, responseSlotTypes, scope }) => {
419
+ if (!expression) {
420
+ issues.push(createIssue$2({
421
+ code: "missing-expression",
422
+ message: `${scope} needs an expression.`,
423
+ operationIndex
424
+ }));
425
+ return;
426
+ }
427
+ let parsed;
428
+ try {
429
+ parsed = Expression.deserialize(expression);
430
+ } catch (error) {
431
+ issues.push(createIssue$2({
432
+ code: "invalid-expression",
433
+ message: `${scope} contains invalid expression JSON: ${error instanceof Error ? error.message : String(error)}`,
434
+ operationIndex
435
+ }));
436
+ return;
437
+ }
438
+ for (const ref of parsed?.responseVariableRefs ?? []) if (!(ref.toString() in responseSlotTypes)) issues.push(createIssue$2({
439
+ code: "unknown-response-ref",
440
+ message: `${scope} references unknown response slot "${ref.toString()}". Inspect response slots before using it.`,
441
+ operationIndex
442
+ }));
443
+ validateExpressionFunctionSignatures({
444
+ expression,
445
+ issues,
446
+ operationIndex,
447
+ responseSlotTypes,
448
+ scope
449
+ });
450
+ const inferred = inferExpressionReturnType(expression, responseSlotTypes);
451
+ if (expectedReturnType && inferred && inferred !== expectedReturnType) issues.push(createIssue$2({
452
+ code: "expression-return-type-mismatch",
453
+ message: `${scope} must return ${expectedReturnType}, but this expression appears to return ${inferred}.`,
454
+ operationIndex
455
+ }));
456
+ };
457
+ //#endregion
458
+ //#region src/engine/inspection.ts
459
+ const DEFAULT_LIMIT = 12;
460
+ const MAX_LIMIT = 50;
461
+ const itemSummarySchema = z.object({
462
+ itemId: z.string(),
463
+ itemType: z.string(),
464
+ key: z.string(),
465
+ fullKey: z.string(),
466
+ itemLabel: z.string().optional(),
467
+ parentId: z.string().optional(),
468
+ depth: z.number().int().nonnegative().optional(),
469
+ childCount: z.number().int().nonnegative().optional(),
470
+ path: z.array(z.string()).optional(),
471
+ siblingKeys: z.array(z.string()).optional()
472
+ });
473
+ const translationEntrySchema = z.object({
474
+ locale: z.string(),
475
+ contentKey: z.string(),
476
+ text: z.string()
477
+ });
478
+ const choiceOptionDetailsSchema = z.object({
479
+ id: z.string(),
480
+ key: z.string().optional(),
481
+ labelContentKey: z.string(),
482
+ hasDisplayCondition: z.boolean().optional(),
483
+ embeddedFormItemId: z.string().optional(),
484
+ embeddedFormFieldGroupCount: z.number().int().nonnegative().optional(),
485
+ embeddedFormTranslationPrefix: z.string().optional(),
486
+ embeddedFormResponseSlotPrefix: z.string().optional()
487
+ });
488
+ const formSelectOptionDetailsSchema = z.object({
489
+ id: z.string(),
490
+ value: z.string().optional(),
491
+ labelContentKey: z.string()
492
+ });
493
+ const formFieldGroupLayoutSchema = z.object({
494
+ base: z.number().int().positive(),
495
+ xs: z.number().int().positive(),
496
+ sm: z.number().int().positive(),
497
+ md: z.number().int().positive(),
498
+ xl: z.number().int().positive()
499
+ });
500
+ const formFieldDetailsSchema = z.object({
501
+ id: z.string(),
502
+ key: z.string(),
503
+ type: z.string(),
504
+ responseSlotId: z.string(),
505
+ labelContentKey: z.string(),
506
+ descriptionContentKey: z.string(),
507
+ errorContentKey: z.string(),
508
+ placeholderContentKey: z.string().optional(),
509
+ unitContentKey: z.string().optional(),
510
+ checkedLabelContentKey: z.string().optional(),
511
+ uncheckedLabelContentKey: z.string().optional(),
512
+ required: z.boolean().optional(),
513
+ controllerType: z.string().optional(),
514
+ validationTypes: z.array(z.string()).optional(),
515
+ validationMessageContentKeys: z.array(z.string()).optional(),
516
+ selectOptions: z.array(formSelectOptionDetailsSchema).optional()
517
+ });
518
+ const formFieldGroupDetailsSchema = z.object({
519
+ id: z.string(),
520
+ titleContentKey: z.string(),
521
+ descriptionContentKey: z.string(),
522
+ hasDisplayCondition: z.boolean().optional(),
523
+ layout: formFieldGroupLayoutSchema,
524
+ fieldCount: z.number().int().nonnegative(),
525
+ fields: z.array(formFieldDetailsSchema)
526
+ });
527
+ const itemConfigDetailsSchema = z.object({
528
+ kind: z.enum([
529
+ "choice",
530
+ "form",
531
+ "generic"
532
+ ]),
533
+ summary: z.array(z.string()),
534
+ mainResponseSlotId: z.string().optional(),
535
+ choiceOptions: z.array(choiceOptionDetailsSchema).optional(),
536
+ formFieldGroups: z.array(formFieldGroupDetailsSchema).optional()
537
+ });
538
+ const normalizeText = (value) => value?.trim().toLowerCase() ?? "";
539
+ const clampLimit = (limit) => Math.min(50, Math.max(1, limit ?? 12));
540
+ const parseCursor = (cursor) => {
541
+ if (!cursor) return 0;
542
+ const parsed = Number.parseInt(cursor, 10);
543
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
544
+ };
545
+ const unique = (values) => Array.from(new Set(values));
546
+ const isRecord$5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
547
+ const asString = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
548
+ const asBoolean = (value) => typeof value === "boolean" ? value : void 0;
549
+ const asArray = (value) => Array.isArray(value) ? value : [];
550
+ const createContentKey = (...segments) => segments.filter(Boolean).join(".");
551
+ const getChoiceOptionLabelContentKey = (optionId) => createContentKey("options", optionId, "label");
552
+ const getChoiceEmbeddedFormTranslationPrefix = (optionId) => createContentKey("embeddedForms", optionId);
553
+ const getChoiceEmbeddedFormResponseSlotPrefix = (optionId) => createContentKey("embedded", optionId);
554
+ const withFormTranslationNamespace = (contentKey, translationNamespace) => translationNamespace ? createContentKey(translationNamespace, contentKey) : contentKey;
555
+ const getFormFieldGroupContentKey = (groupId, contentKey, translationNamespace) => withFormTranslationNamespace(createContentKey("fieldGroups", groupId, contentKey), translationNamespace);
556
+ const getFormFieldContentKey = (fieldId, contentKey, translationNamespace) => withFormTranslationNamespace(createContentKey("fields", fieldId, contentKey), translationNamespace);
557
+ const getFormFieldOptionLabelContentKey = (fieldId, optionId, translationNamespace) => withFormTranslationNamespace(createContentKey("fields", fieldId, "options", optionId, "label"), translationNamespace);
558
+ const getFormFieldResponseSlotId = (fieldKey, responseSlotPrefix) => responseSlotPrefix ? createContentKey(responseSlotPrefix, fieldKey) : fieldKey;
559
+ const getRoundedPositiveInt = (value, fallback = 1) => {
560
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
561
+ return Math.max(1, Math.round(value));
562
+ };
563
+ const sliceWithCursor = (values, input) => {
564
+ const offset = parseCursor(input.cursor);
565
+ const nextOffset = offset + clampLimit(input.limit);
566
+ return {
567
+ items: values.slice(offset, nextOffset),
568
+ nextCursor: nextOffset < values.length ? String(nextOffset) : void 0,
569
+ truncated: nextOffset < values.length
570
+ };
571
+ };
572
+ const mapOutlineById = (snapshot) => new Map(snapshot.inspection.outline.map((item) => [item.itemId, item]));
573
+ const mapKeyIndexById = (snapshot) => new Map(snapshot.inspection.keyIndex.map((item) => [item.itemId, item]));
574
+ /** The full-survey key index, regardless of the (possibly focus-scoped) outline. */
575
+ const getKeyIndexEntries = (snapshot) => snapshot.inspection.keyIndex;
576
+ const getRawSurvey$1 = (snapshot) => snapshot.survey;
577
+ const getRawSurveyItems = (snapshot) => {
578
+ const rawItems = getRawSurvey$1(snapshot).surveyItems;
579
+ return Array.isArray(rawItems) ? rawItems : [];
580
+ };
581
+ const mapRawItemsById = (snapshot) => new Map(getRawSurveyItems(snapshot).map((item) => [item.id, item]));
582
+ const buildSurveyTranslations = (snapshot) => new SurveyTranslations(getRawSurvey$1(snapshot).translations);
583
+ const summarizeRawItem = (item) => ({
584
+ itemId: item.id,
585
+ itemType: item.itemType,
586
+ key: item.key,
587
+ fullKey: item.key,
588
+ itemLabel: item.metadata?.itemLabel
589
+ });
590
+ const buildItemTranslationEntries = (snapshot, itemId, input) => {
591
+ const itemTranslations = buildSurveyTranslations(snapshot).getItemTranslations(itemId);
592
+ if (!itemTranslations) return [];
593
+ const localeFilter = input.locales?.length ? new Set(input.locales) : void 0;
594
+ const textLimit = input.textLimit ?? 400;
595
+ const entries = [];
596
+ for (const locale of itemTranslations.locales) {
597
+ if (localeFilter && !localeFilter.has(locale)) continue;
598
+ const localeContent = itemTranslations.getAllForLocale(locale);
599
+ if (!localeContent) continue;
600
+ for (const [contentKey, content] of Object.entries(localeContent)) {
601
+ const text = getContentPlainText(content).trim();
602
+ if (!text) continue;
603
+ entries.push({
604
+ locale,
605
+ contentKey,
606
+ text: text.length > textLimit ? `${text.slice(0, textLimit)}...` : text
607
+ });
608
+ }
609
+ }
610
+ return entries;
611
+ };
612
+ const summarizeOutlineNode = (node) => ({
613
+ itemId: node.itemId,
614
+ itemType: node.itemType,
615
+ key: node.key,
616
+ fullKey: node.fullKey,
617
+ itemLabel: node.itemLabel,
618
+ parentId: node.parentId,
619
+ depth: node.depth,
620
+ childCount: node.childCount
621
+ });
622
+ const summarizeKeyIndexEntry = (entry, outlineById) => {
623
+ const outlineNode = outlineById.get(entry.itemId);
624
+ return {
625
+ itemId: entry.itemId,
626
+ itemType: entry.itemType,
627
+ key: entry.key,
628
+ fullKey: entry.fullKey,
629
+ itemLabel: entry.itemLabel,
630
+ parentId: outlineNode?.parentId,
631
+ depth: outlineNode?.depth,
632
+ childCount: outlineNode?.childCount,
633
+ path: entry.path
634
+ };
635
+ };
636
+ const getParentFullKey = (fullKey) => {
637
+ const segments = fullKey.split(".");
638
+ if (segments.length <= 1) return;
639
+ return segments.slice(0, -1).join(".");
640
+ };
641
+ const summarizeKeyIndexEntries = (entries, outlineById) => {
642
+ const byFullKey = new Map(entries.map((entry) => [entry.fullKey, entry]));
643
+ const childCountByFullKey = /* @__PURE__ */ new Map();
644
+ for (const entry of entries) {
645
+ const parentFullKey = getParentFullKey(entry.fullKey);
646
+ if (!parentFullKey) continue;
647
+ childCountByFullKey.set(parentFullKey, (childCountByFullKey.get(parentFullKey) ?? 0) + 1);
648
+ }
649
+ return entries.map((entry) => {
650
+ const outlineNode = outlineById.get(entry.itemId);
651
+ const parentFullKey = getParentFullKey(entry.fullKey);
652
+ const parentEntry = parentFullKey ? byFullKey.get(parentFullKey) : void 0;
653
+ return {
654
+ itemId: entry.itemId,
655
+ itemType: entry.itemType,
656
+ key: entry.key,
657
+ fullKey: entry.fullKey,
658
+ itemLabel: entry.itemLabel,
659
+ parentId: outlineNode?.parentId ?? parentEntry?.itemId,
660
+ depth: outlineNode?.depth ?? Math.max(0, entry.fullKey.split(".").length - 1),
661
+ childCount: outlineNode?.childCount ?? childCountByFullKey.get(entry.fullKey) ?? 0,
662
+ path: entry.path
663
+ };
664
+ });
665
+ };
666
+ const buildChoiceConfigDetails = (rawItem) => {
667
+ const config = isRecord$5(rawItem.config) ? rawItem.config : void 0;
668
+ const rawOptions = asArray(config?.options);
669
+ const mainResponseSlotId = asString(config?.id) ?? rawItem.id;
670
+ if (rawItem.itemType !== "choiceItem" && rawOptions.length === 0) return;
671
+ const choiceOptions = rawOptions.filter(isRecord$5).map((option) => {
672
+ const id = asString(option.id);
673
+ if (!id) return;
674
+ const embeddedFormFieldGroups = asArray((isRecord$5(option.embeddedForm) ? option.embeddedForm : void 0)?.fieldGroups);
675
+ const details = {
676
+ id,
677
+ labelContentKey: getChoiceOptionLabelContentKey(id)
678
+ };
679
+ const key = asString(option.key);
680
+ if (key) details.key = key;
681
+ const hasDisplayCondition = asBoolean(option.hasDisplayCondition);
682
+ if (hasDisplayCondition !== void 0) details.hasDisplayCondition = hasDisplayCondition;
683
+ const embeddedFormItemId = asString(option.embeddedFormItemId);
684
+ if (embeddedFormItemId) details.embeddedFormItemId = embeddedFormItemId;
685
+ if (embeddedFormFieldGroups.length > 0) {
686
+ details.embeddedFormFieldGroupCount = embeddedFormFieldGroups.length;
687
+ details.embeddedFormTranslationPrefix = getChoiceEmbeddedFormTranslationPrefix(id);
688
+ details.embeddedFormResponseSlotPrefix = getChoiceEmbeddedFormResponseSlotPrefix(id);
689
+ }
690
+ return details;
691
+ }).filter((option) => Boolean(option));
692
+ const maxSelection = config?.maxSelection === null ? "unlimited" : typeof config?.maxSelection === "number" ? String(config.maxSelection) : "default";
693
+ return {
694
+ kind: "choice",
695
+ summary: [
696
+ `choice item config: ${choiceOptions.length} option(s)`,
697
+ `maxSelection=${maxSelection}`,
698
+ `shuffleOptions=${config?.shuffleOptions === true ? "true" : "false"}`,
699
+ `responseSlot=${mainResponseSlotId}`
700
+ ],
701
+ mainResponseSlotId,
702
+ choiceOptions
703
+ };
704
+ };
705
+ const buildFormFieldDetails = (field, translationNamespace, responseSlotPrefix) => {
706
+ const id = asString(field.id);
707
+ const key = asString(field.key);
708
+ const type = asString(field.type);
709
+ if (!id || !key || !type) return;
710
+ const controller = isRecord$5(field.controller) ? field.controller : void 0;
711
+ const controllerType = asString(controller?.type);
712
+ const validations = asArray(field.validations).filter(isRecord$5);
713
+ const validationMessageContentKeys = validations.map((validation) => asString(validation.id)).filter((validationId) => Boolean(validationId)).map((validationId) => getFormFieldContentKey(id, createContentKey("validations", validationId, "message"), translationNamespace));
714
+ const selectOptions = controllerType === "select" ? asArray(controller?.options).filter(isRecord$5).map((option) => {
715
+ const optionId = asString(option.id);
716
+ if (!optionId) return;
717
+ const details = {
718
+ id: optionId,
719
+ labelContentKey: getFormFieldOptionLabelContentKey(id, optionId, translationNamespace)
720
+ };
721
+ const value = asString(option.value);
722
+ if (value) details.value = value;
723
+ return details;
724
+ }).filter((option) => Boolean(option)) : void 0;
725
+ return {
726
+ id,
727
+ key,
728
+ type,
729
+ responseSlotId: getFormFieldResponseSlotId(key, responseSlotPrefix),
730
+ labelContentKey: getFormFieldContentKey(id, "label", translationNamespace),
731
+ descriptionContentKey: getFormFieldContentKey(id, "description", translationNamespace),
732
+ errorContentKey: getFormFieldContentKey(id, "error", translationNamespace),
733
+ placeholderContentKey: type === "input" || type === "textarea" || type === "number" || type === "datepicker" || type === "select" ? getFormFieldContentKey(id, "placeholder", translationNamespace) : void 0,
734
+ unitContentKey: type === "number" ? getFormFieldContentKey(id, "unit", translationNamespace) : void 0,
735
+ checkedLabelContentKey: type === "switch" ? getFormFieldContentKey(id, "checkedLabel", translationNamespace) : void 0,
736
+ uncheckedLabelContentKey: type === "switch" ? getFormFieldContentKey(id, "uncheckedLabel", translationNamespace) : void 0,
737
+ required: asBoolean(field.required),
738
+ controllerType,
739
+ validationTypes: validations.map((validation) => asString(validation.type)).filter((type) => Boolean(type)),
740
+ validationMessageContentKeys: validationMessageContentKeys.length > 0 ? validationMessageContentKeys : void 0,
741
+ selectOptions
742
+ };
743
+ };
744
+ const buildFormConfigDetails = (rawItem, configOverride, translationNamespace, responseSlotPrefix) => {
745
+ const configCandidate = configOverride ?? rawItem.config;
746
+ const rawGroups = asArray((isRecord$5(configCandidate) ? configCandidate : void 0)?.fieldGroups);
747
+ if (rawItem.itemType !== "formItem" && rawGroups.length === 0) return;
748
+ const formFieldGroups = rawGroups.filter(isRecord$5).map((group) => {
749
+ const id = asString(group.id);
750
+ if (!id) return;
751
+ const fields = asArray(group.fields).filter(isRecord$5).map((field) => buildFormFieldDetails(field, translationNamespace, responseSlotPrefix)).filter((field) => Boolean(field));
752
+ const layout = isRecord$5(group.layout) ? group.layout : void 0;
753
+ return {
754
+ id,
755
+ titleContentKey: getFormFieldGroupContentKey(id, "title", translationNamespace),
756
+ descriptionContentKey: getFormFieldGroupContentKey(id, "description", translationNamespace),
757
+ hasDisplayCondition: asBoolean(group.hasDisplayCondition),
758
+ layout: {
759
+ base: getRoundedPositiveInt(layout?.base ?? layout?.xs ?? layout?.sm),
760
+ xs: getRoundedPositiveInt(layout?.xs ?? layout?.sm),
761
+ sm: getRoundedPositiveInt(layout?.sm),
762
+ md: getRoundedPositiveInt(layout?.md ?? layout?.lg),
763
+ xl: getRoundedPositiveInt(layout?.xl ?? layout?.lg)
764
+ },
765
+ fieldCount: fields.length,
766
+ fields
767
+ };
768
+ }).filter((group) => Boolean(group));
769
+ const fieldCount = formFieldGroups.reduce((sum, group) => sum + group.fieldCount, 0);
770
+ return {
771
+ kind: "form",
772
+ summary: [`form item config: ${formFieldGroups.length} group(s), ${fieldCount} field(s)`],
773
+ formFieldGroups
774
+ };
775
+ };
776
+ const buildItemConfigDetails = (rawItem) => {
777
+ const choiceDetails = buildChoiceConfigDetails(rawItem);
778
+ if (choiceDetails) return choiceDetails;
779
+ const formDetails = buildFormConfigDetails(rawItem);
780
+ if (formDetails) return formDetails;
781
+ if (!isRecord$5(rawItem.config)) return;
782
+ const configKeys = Object.keys(rawItem.config);
783
+ return {
784
+ kind: "generic",
785
+ summary: [configKeys.length > 0 ? `generic config keys: ${configKeys.join(", ")}` : "generic config has no enumerable keys"]
786
+ };
787
+ };
788
+ const buildEmbeddedFormDetails = (choiceOption, embeddedForm) => {
789
+ if (!isRecord$5(embeddedForm)) return;
790
+ return buildFormConfigDetails({
791
+ id: choiceOption.embeddedFormItemId ?? choiceOption.id,
792
+ key: choiceOption.key ?? choiceOption.id,
793
+ itemType: "formItem",
794
+ config: embeddedForm
795
+ }, embeddedForm, getChoiceEmbeddedFormTranslationPrefix(choiceOption.id), getChoiceEmbeddedFormResponseSlotPrefix(choiceOption.id));
796
+ };
797
+ const buildConfigSearchText = (rawItem) => {
798
+ const details = buildItemConfigDetails(rawItem);
799
+ if (!details) return [];
800
+ const values = [...details.summary];
801
+ for (const option of details.choiceOptions ?? []) values.push(option.id, option.key ?? "", option.labelContentKey, option.embeddedFormItemId ?? "", option.embeddedFormTranslationPrefix ?? "", option.embeddedFormResponseSlotPrefix ?? "");
802
+ for (const group of details.formFieldGroups ?? []) {
803
+ values.push(group.id, group.titleContentKey, group.descriptionContentKey, String(group.layout.base), String(group.layout.xs), String(group.layout.sm), String(group.layout.md), String(group.layout.xl), group.hasDisplayCondition === true ? "hasDisplayCondition" : "");
804
+ for (const field of group.fields) {
805
+ values.push(field.id, field.key, field.type, field.responseSlotId, field.labelContentKey, field.descriptionContentKey, field.errorContentKey, field.placeholderContentKey ?? "", field.unitContentKey ?? "", field.checkedLabelContentKey ?? "", field.uncheckedLabelContentKey ?? "", field.controllerType ?? "", ...field.validationTypes ?? [], ...field.validationMessageContentKeys ?? []);
806
+ for (const option of field.selectOptions ?? []) values.push(option.id, option.value ?? "", option.labelContentKey);
807
+ }
808
+ }
809
+ return values.filter((value) => value.trim().length > 0);
810
+ };
811
+ const summarizeFocusItemNode = (node, outlineById) => {
812
+ const outlineNode = outlineById.get(node.itemId);
813
+ return {
814
+ itemId: node.itemId,
815
+ itemType: node.itemType,
816
+ key: node.itemKey,
817
+ fullKey: node.fullKey,
818
+ itemLabel: node.itemLabel,
819
+ parentId: outlineNode?.parentId,
820
+ depth: outlineNode?.depth,
821
+ childCount: outlineNode?.childCount ?? node.descendants.length,
822
+ siblingKeys: node.siblingKeys
823
+ };
824
+ };
825
+ const summarizeItemById = (itemId, indexById, outlineById) => {
826
+ const outlineNode = outlineById.get(itemId);
827
+ if (outlineNode) return summarizeOutlineNode(outlineNode);
828
+ const indexEntry = indexById.get(itemId);
829
+ if (indexEntry) return summarizeKeyIndexEntry(indexEntry, outlineById);
830
+ };
831
+ /**
832
+ * Resolve an item by internal itemId, or fall back to matching on `key` /
833
+ * `fullKey`. Model-facing text (outline rows, fullKey paths) only ever shows
834
+ * key/fullKey, not the internal itemId, so models often pass a key like
835
+ * "info_1" where an itemId is expected — this keeps that usage working.
836
+ */
837
+ const resolveItemSummary = (identifier, indexById, outlineById) => {
838
+ const direct = summarizeItemById(identifier, indexById, outlineById);
839
+ if (direct) return direct;
840
+ const normalized = identifier.trim().toLowerCase();
841
+ if (!normalized) return;
842
+ for (const entry of indexById.values()) if (entry.key.toLowerCase() === normalized || entry.fullKey.toLowerCase() === normalized) return summarizeKeyIndexEntry(entry, outlineById);
843
+ for (const node of outlineById.values()) if (node.key.toLowerCase() === normalized || node.fullKey.toLowerCase() === normalized) return summarizeOutlineNode(node);
844
+ };
845
+ const itemMatchesQuery = (item, query) => {
846
+ return [
847
+ item.itemId,
848
+ item.itemType,
849
+ item.key,
850
+ item.fullKey,
851
+ item.itemLabel,
852
+ ...item.path ?? [],
853
+ ...item.siblingKeys ?? []
854
+ ].filter(Boolean).join(" ").toLowerCase().includes(query);
855
+ };
856
+ /**
857
+ * Direct children of `parentFullKey` derived from the full-survey key index.
858
+ * This works for any item, selected or not.
859
+ */
860
+ const findDirectChildrenByFullKey = (entries, outlineById, parentFullKey) => {
861
+ const prefix = `${parentFullKey}.`;
862
+ return entries.filter((entry) => entry.fullKey.startsWith(prefix) && !entry.fullKey.slice(prefix.length).includes(".")).map((entry) => summarizeKeyIndexEntry(entry, outlineById));
863
+ };
864
+ //#endregion
865
+ //#region src/engine/expression-mutations.ts
866
+ const jsonValueSchema = z.lazy(() => z.union([
867
+ z.string(),
868
+ z.number(),
869
+ z.boolean(),
870
+ z.null(),
871
+ z.array(jsonValueSchema),
872
+ z.record(z.string(), jsonValueSchema)
873
+ ]));
874
+ const responseValueSchema = z.object({
875
+ type: z.enum([
876
+ ValueType.string,
877
+ ValueType.duration,
878
+ ValueType.reference,
879
+ ValueType.number,
880
+ ValueType.boolean,
881
+ ValueType.date,
882
+ ValueType.stringArray,
883
+ ValueType.durationArray,
884
+ ValueType.referenceArray,
885
+ ValueType.numberArray,
886
+ ValueType.dateArray
887
+ ]),
888
+ value: jsonValueSchema.optional(),
889
+ unit: z.string().optional(),
890
+ precision: z.string().optional()
891
+ }).passthrough();
892
+ const expressionSchema = z.lazy(() => z.discriminatedUnion("type", [
893
+ z.object({
894
+ type: z.literal(ExpressionType.Const),
895
+ value: responseValueSchema.optional(),
896
+ editorConfig: z.record(z.string(), jsonValueSchema).optional()
897
+ }),
898
+ z.object({
899
+ type: z.literal(ExpressionType.ResponseVariable),
900
+ variableRef: z.string().min(1),
901
+ editorConfig: z.record(z.string(), jsonValueSchema).optional()
902
+ }),
903
+ z.object({
904
+ type: z.literal(ExpressionType.ContextVariable),
905
+ contextType: z.enum([
906
+ "locale",
907
+ "participantFlag",
908
+ "customValue",
909
+ "customExpression"
910
+ ]),
911
+ key: expressionSchema.optional(),
912
+ arguments: z.array(expressionSchema.optional()).optional(),
913
+ asType: z.enum([
914
+ ValueType.string,
915
+ ValueType.duration,
916
+ ValueType.reference,
917
+ ValueType.number,
918
+ ValueType.boolean,
919
+ ValueType.date,
920
+ ValueType.stringArray,
921
+ ValueType.durationArray,
922
+ ValueType.referenceArray,
923
+ ValueType.numberArray,
924
+ ValueType.dateArray
925
+ ]).optional(),
926
+ editorConfig: z.record(z.string(), jsonValueSchema).optional()
927
+ }),
928
+ z.object({
929
+ type: z.literal(ExpressionType.Function),
930
+ functionName: z.nativeEnum(FunctionExpressionNames),
931
+ arguments: z.array(expressionSchema.optional()),
932
+ editorConfig: z.record(z.string(), jsonValueSchema).optional()
933
+ })
934
+ ]));
935
+ const prefillTargetSchema = z.discriminatedUnion("type", [
936
+ z.object({ type: z.literal("itemResponse") }),
937
+ z.object({
938
+ type: z.literal("field"),
939
+ fieldId: z.string().min(1).max(240)
940
+ }),
941
+ z.object({
942
+ type: z.literal("embeddedField"),
943
+ optionId: z.string().min(1).max(240),
944
+ fieldId: z.string().min(1).max(240)
945
+ })
946
+ ]);
947
+ const prefillSourceSchema = z.discriminatedUnion("type", [
948
+ z.object({
949
+ type: z.literal("static"),
950
+ value: responseValueSchema
951
+ }),
952
+ z.object({
953
+ type: z.literal("expression"),
954
+ expression: expressionSchema
955
+ }),
956
+ z.object({
957
+ type: z.literal("templateValue"),
958
+ key: z.string().min(1).max(240)
959
+ }),
960
+ z.object({
961
+ type: z.literal("previousResponse"),
962
+ ref: z.object({
963
+ surveyKey: z.string().min(1).max(240).optional(),
964
+ itemId: z.string().min(1).max(240),
965
+ target: prefillTargetSchema.optional()
966
+ }),
967
+ ifUnsupported: z.literal("skip").optional()
968
+ })
969
+ ]);
970
+ const prefillSchema = z.object({
971
+ id: z.string().min(1).max(240),
972
+ target: prefillTargetSchema,
973
+ when: expressionSchema.optional(),
974
+ apply: z.literal("ifEmpty").optional(),
975
+ source: prefillSourceSchema
976
+ });
977
+ const valueTypeSchema = z.enum([
978
+ ValueType.string,
979
+ ValueType.duration,
980
+ ValueType.reference,
981
+ ValueType.number,
982
+ ValueType.boolean,
983
+ ValueType.date,
984
+ ValueType.stringArray,
985
+ ValueType.durationArray,
986
+ ValueType.referenceArray,
987
+ ValueType.numberArray,
988
+ ValueType.dateArray
989
+ ]);
990
+ const templateValueSchema = z.object({
991
+ type: z.enum(["default", "date2string"]),
992
+ returnType: valueTypeSchema,
993
+ expression: expressionSchema.optional(),
994
+ dateFormat: z.string().min(1).max(120).optional()
995
+ });
996
+ const expressionMutationSchema = z.discriminatedUnion("kind", [
997
+ z.object({
998
+ kind: z.literal("set-item-display-condition"),
999
+ itemId: z.string().min(1).max(240),
1000
+ expression: expressionSchema
1001
+ }),
1002
+ z.object({
1003
+ kind: z.literal("clear-item-display-condition"),
1004
+ itemId: z.string().min(1).max(240)
1005
+ }),
1006
+ z.object({
1007
+ kind: z.literal("set-component-display-condition"),
1008
+ itemId: z.string().min(1).max(240),
1009
+ componentId: z.string().min(1).max(240),
1010
+ expression: expressionSchema
1011
+ }),
1012
+ z.object({
1013
+ kind: z.literal("clear-component-display-condition"),
1014
+ itemId: z.string().min(1).max(240),
1015
+ componentId: z.string().min(1).max(240)
1016
+ }),
1017
+ z.object({
1018
+ kind: z.literal("set-component-disabled-condition"),
1019
+ itemId: z.string().min(1).max(240),
1020
+ componentId: z.string().min(1).max(240),
1021
+ expression: expressionSchema
1022
+ }),
1023
+ z.object({
1024
+ kind: z.literal("clear-component-disabled-condition"),
1025
+ itemId: z.string().min(1).max(240),
1026
+ componentId: z.string().min(1).max(240)
1027
+ }),
1028
+ z.object({
1029
+ kind: z.literal("set-validation-expression"),
1030
+ itemId: z.string().min(1).max(240),
1031
+ validationKey: z.string().min(1).max(240),
1032
+ expression: expressionSchema
1033
+ }),
1034
+ z.object({
1035
+ kind: z.literal("clear-validation-expression"),
1036
+ itemId: z.string().min(1).max(240),
1037
+ validationKey: z.string().min(1).max(240)
1038
+ }),
1039
+ z.object({
1040
+ kind: z.literal("set-prefill"),
1041
+ itemId: z.string().min(1).max(240),
1042
+ prefill: prefillSchema
1043
+ }),
1044
+ z.object({
1045
+ kind: z.literal("clear-prefill"),
1046
+ itemId: z.string().min(1).max(240),
1047
+ prefillId: z.string().min(1).max(240)
1048
+ }),
1049
+ z.object({
1050
+ kind: z.literal("set-template-value"),
1051
+ templateKey: z.string().min(1).max(240),
1052
+ templateValue: templateValueSchema
1053
+ }),
1054
+ z.object({
1055
+ kind: z.literal("clear-template-value"),
1056
+ templateKey: z.string().min(1).max(240)
1057
+ })
1058
+ ]);
1059
+ const createIssue$1 = ({ severity = "error", ...input }) => ({
1060
+ severity,
1061
+ ...input
1062
+ });
1063
+ const pointerEscape = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
1064
+ const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1065
+ const getRawSurvey = (snapshot) => snapshot.survey;
1066
+ const toJsonValue = (value) => value;
1067
+ const resolveItemId = (snapshot, itemRef) => {
1068
+ const rawItems = getRawSurveyItems(snapshot);
1069
+ if (new Map(rawItems.map((item) => [item.id, item])).has(itemRef)) return { itemId: itemRef };
1070
+ const keyIndexMatch = getKeyIndexEntries(snapshot).find((entry) => entry.itemId === itemRef || entry.key === itemRef || entry.fullKey === itemRef);
1071
+ if (keyIndexMatch) return { itemId: keyIndexMatch.itemId };
1072
+ const rawKeyMatch = rawItems.find((item) => item.key === itemRef);
1073
+ if (rawKeyMatch) return { itemId: rawKeyMatch.id };
1074
+ return { issue: createIssue$1({
1075
+ code: "item-not-found",
1076
+ message: `Could not resolve item "${itemRef}". Inspect current context or item details first.`,
1077
+ itemId: itemRef
1078
+ }) };
1079
+ };
1080
+ const getRawItemLocation = (snapshot, itemRef) => {
1081
+ const resolved = resolveItemId(snapshot, itemRef);
1082
+ if (resolved.issue || !resolved.itemId) return { issue: resolved.issue };
1083
+ const rawItems = getRawSurveyItems(snapshot);
1084
+ const rawIndex = rawItems.findIndex((item) => item.id === resolved.itemId);
1085
+ if (rawIndex < 0) return { issue: createIssue$1({
1086
+ code: "item-not-found",
1087
+ message: `Item "${resolved.itemId}" is not present in raw survey items.`,
1088
+ itemId: resolved.itemId
1089
+ }) };
1090
+ return {
1091
+ itemId: resolved.itemId,
1092
+ rawIndex,
1093
+ rawItem: rawItems[rawIndex]
1094
+ };
1095
+ };
1096
+ const addOrReplacePropertyPatch = ({ patches, parentPath, parentValue, property, value }) => {
1097
+ const exists = isRecord$4(parentValue) && Object.prototype.hasOwnProperty.call(parentValue, property);
1098
+ patches.push({
1099
+ op: exists ? "replace" : "add",
1100
+ path: `${parentPath}/${pointerEscape(property)}`,
1101
+ value
1102
+ });
1103
+ };
1104
+ const removePropertyPatch = ({ issues, operationIndex, patches, parentPath, parentValue, property, scope }) => {
1105
+ if (!(isRecord$4(parentValue) && Object.prototype.hasOwnProperty.call(parentValue, property))) {
1106
+ issues.push(createIssue$1({
1107
+ severity: "warning",
1108
+ code: "expression-location-already-empty",
1109
+ message: `${scope} is already empty.`,
1110
+ operationIndex
1111
+ }));
1112
+ return;
1113
+ }
1114
+ patches.push({
1115
+ op: "remove",
1116
+ path: `${parentPath}/${pointerEscape(property)}`
1117
+ });
1118
+ };
1119
+ const addNestedConditionPatch = ({ branch, expression, patches, rawIndex, rawItem, componentId }) => {
1120
+ const basePath = `/surveyItems/${rawIndex}/${branch}`;
1121
+ const currentBranch = rawItem[branch];
1122
+ if (branch === "displayConditions" && !componentId) {
1123
+ if (!isRecord$4(currentBranch)) {
1124
+ patches.push({
1125
+ op: "add",
1126
+ path: basePath,
1127
+ value: toJsonValue({ root: expression })
1128
+ });
1129
+ return;
1130
+ }
1131
+ addOrReplacePropertyPatch({
1132
+ patches,
1133
+ parentPath: basePath,
1134
+ parentValue: currentBranch,
1135
+ property: "root",
1136
+ value: toJsonValue(expression)
1137
+ });
1138
+ return;
1139
+ }
1140
+ if (!componentId) return;
1141
+ if (!isRecord$4(currentBranch)) {
1142
+ patches.push({
1143
+ op: "add",
1144
+ path: basePath,
1145
+ value: toJsonValue({ components: { [componentId]: expression } })
1146
+ });
1147
+ return;
1148
+ }
1149
+ if (!isRecord$4(currentBranch.components)) {
1150
+ patches.push({
1151
+ op: "add",
1152
+ path: `${basePath}/components`,
1153
+ value: toJsonValue({ [componentId]: expression })
1154
+ });
1155
+ return;
1156
+ }
1157
+ addOrReplacePropertyPatch({
1158
+ patches,
1159
+ parentPath: `${basePath}/components`,
1160
+ parentValue: currentBranch.components,
1161
+ property: componentId,
1162
+ value: toJsonValue(expression)
1163
+ });
1164
+ };
1165
+ const removeNestedConditionPatch = ({ branch, componentId, issues, operationIndex, patches, rawIndex, rawItem, scope }) => {
1166
+ const currentBranch = rawItem[branch];
1167
+ const basePath = `/surveyItems/${rawIndex}/${branch}`;
1168
+ if (!isRecord$4(currentBranch)) {
1169
+ issues.push(createIssue$1({
1170
+ severity: "warning",
1171
+ code: "expression-location-already-empty",
1172
+ message: `${scope} is already empty.`,
1173
+ operationIndex
1174
+ }));
1175
+ return;
1176
+ }
1177
+ if (branch === "displayConditions" && !componentId) {
1178
+ removePropertyPatch({
1179
+ issues,
1180
+ operationIndex,
1181
+ patches,
1182
+ parentPath: basePath,
1183
+ parentValue: currentBranch,
1184
+ property: "root",
1185
+ scope
1186
+ });
1187
+ return;
1188
+ }
1189
+ removePropertyPatch({
1190
+ issues,
1191
+ operationIndex,
1192
+ patches,
1193
+ parentPath: `${basePath}/components`,
1194
+ parentValue: currentBranch.components,
1195
+ property: componentId ?? "",
1196
+ scope
1197
+ });
1198
+ };
1199
+ const buildExpressionMutationPatches = ({ mutation, operationIndex, issues, patches, responseSlotTypes, snapshot }) => {
1200
+ if (mutation.kind === "set-template-value" || mutation.kind === "clear-template-value") {
1201
+ const templateValues = getRawSurvey(snapshot).templateValues;
1202
+ const basePath = "/templateValues";
1203
+ if (mutation.kind === "clear-template-value") {
1204
+ removePropertyPatch({
1205
+ issues,
1206
+ operationIndex,
1207
+ patches,
1208
+ parentPath: basePath,
1209
+ parentValue: templateValues,
1210
+ property: mutation.templateKey,
1211
+ scope: `template value ${mutation.templateKey}`
1212
+ });
1213
+ return;
1214
+ }
1215
+ if (mutation.templateValue.expression) validateExpression({
1216
+ expression: mutation.templateValue.expression,
1217
+ expectedReturnType: mutation.templateValue.returnType,
1218
+ issues,
1219
+ operationIndex,
1220
+ responseSlotTypes,
1221
+ scope: `template value ${mutation.templateKey}`
1222
+ });
1223
+ if (mutation.templateValue.type === "date2string" && !mutation.templateValue.dateFormat) issues.push(createIssue$1({
1224
+ code: "missing-date-format",
1225
+ message: `Template value "${mutation.templateKey}" uses date2string and needs dateFormat.`,
1226
+ operationIndex
1227
+ }));
1228
+ if (!isRecord$4(templateValues)) {
1229
+ patches.push({
1230
+ op: "add",
1231
+ path: basePath,
1232
+ value: toJsonValue({ [mutation.templateKey]: mutation.templateValue })
1233
+ });
1234
+ return;
1235
+ }
1236
+ addOrReplacePropertyPatch({
1237
+ patches,
1238
+ parentPath: basePath,
1239
+ parentValue: templateValues,
1240
+ property: mutation.templateKey,
1241
+ value: toJsonValue(mutation.templateValue)
1242
+ });
1243
+ return;
1244
+ }
1245
+ const location = getRawItemLocation(snapshot, mutation.itemId);
1246
+ if (location.issue || location.rawIndex === void 0 || !location.rawItem) {
1247
+ issues.push(location.issue ?? createIssue$1({
1248
+ code: "item-not-found",
1249
+ message: "Item not found."
1250
+ }));
1251
+ return;
1252
+ }
1253
+ const { rawIndex, rawItem } = location;
1254
+ const itemPath = `/surveyItems/${rawIndex}`;
1255
+ switch (mutation.kind) {
1256
+ case "set-item-display-condition":
1257
+ validateExpression({
1258
+ expression: mutation.expression,
1259
+ expectedReturnType: ValueType.boolean,
1260
+ issues,
1261
+ operationIndex,
1262
+ responseSlotTypes,
1263
+ scope: `display condition for ${location.itemId}`
1264
+ });
1265
+ addNestedConditionPatch({
1266
+ branch: "displayConditions",
1267
+ expression: mutation.expression,
1268
+ patches,
1269
+ rawIndex,
1270
+ rawItem
1271
+ });
1272
+ break;
1273
+ case "clear-item-display-condition":
1274
+ removeNestedConditionPatch({
1275
+ branch: "displayConditions",
1276
+ issues,
1277
+ operationIndex,
1278
+ patches,
1279
+ rawIndex,
1280
+ rawItem,
1281
+ scope: `display condition for ${location.itemId}`
1282
+ });
1283
+ break;
1284
+ case "set-component-display-condition":
1285
+ validateExpression({
1286
+ expression: mutation.expression,
1287
+ expectedReturnType: ValueType.boolean,
1288
+ issues,
1289
+ operationIndex,
1290
+ responseSlotTypes,
1291
+ scope: `display condition for ${location.itemId}.${mutation.componentId}`
1292
+ });
1293
+ addNestedConditionPatch({
1294
+ branch: "displayConditions",
1295
+ componentId: mutation.componentId,
1296
+ expression: mutation.expression,
1297
+ patches,
1298
+ rawIndex,
1299
+ rawItem
1300
+ });
1301
+ break;
1302
+ case "clear-component-display-condition":
1303
+ removeNestedConditionPatch({
1304
+ branch: "displayConditions",
1305
+ componentId: mutation.componentId,
1306
+ issues,
1307
+ operationIndex,
1308
+ patches,
1309
+ rawIndex,
1310
+ rawItem,
1311
+ scope: `display condition for ${location.itemId}.${mutation.componentId}`
1312
+ });
1313
+ break;
1314
+ case "set-component-disabled-condition":
1315
+ validateExpression({
1316
+ expression: mutation.expression,
1317
+ expectedReturnType: ValueType.boolean,
1318
+ issues,
1319
+ operationIndex,
1320
+ responseSlotTypes,
1321
+ scope: `disabled condition for ${location.itemId}.${mutation.componentId}`
1322
+ });
1323
+ addNestedConditionPatch({
1324
+ branch: "disabledConditions",
1325
+ componentId: mutation.componentId,
1326
+ expression: mutation.expression,
1327
+ patches,
1328
+ rawIndex,
1329
+ rawItem
1330
+ });
1331
+ break;
1332
+ case "clear-component-disabled-condition":
1333
+ removeNestedConditionPatch({
1334
+ branch: "disabledConditions",
1335
+ componentId: mutation.componentId,
1336
+ issues,
1337
+ operationIndex,
1338
+ patches,
1339
+ rawIndex,
1340
+ rawItem,
1341
+ scope: `disabled condition for ${location.itemId}.${mutation.componentId}`
1342
+ });
1343
+ break;
1344
+ case "set-validation-expression":
1345
+ validateExpression({
1346
+ expression: mutation.expression,
1347
+ expectedReturnType: ValueType.boolean,
1348
+ issues,
1349
+ operationIndex,
1350
+ responseSlotTypes,
1351
+ scope: `validation ${mutation.validationKey} for ${location.itemId}`
1352
+ });
1353
+ if (!isRecord$4(rawItem.validations)) patches.push({
1354
+ op: "add",
1355
+ path: `${itemPath}/validations`,
1356
+ value: toJsonValue({ [mutation.validationKey]: mutation.expression })
1357
+ });
1358
+ else addOrReplacePropertyPatch({
1359
+ patches,
1360
+ parentPath: `${itemPath}/validations`,
1361
+ parentValue: rawItem.validations,
1362
+ property: mutation.validationKey,
1363
+ value: toJsonValue(mutation.expression)
1364
+ });
1365
+ break;
1366
+ case "clear-validation-expression":
1367
+ removePropertyPatch({
1368
+ issues,
1369
+ operationIndex,
1370
+ patches,
1371
+ parentPath: `${itemPath}/validations`,
1372
+ parentValue: rawItem.validations,
1373
+ property: mutation.validationKey,
1374
+ scope: `validation ${mutation.validationKey} for ${location.itemId}`
1375
+ });
1376
+ break;
1377
+ case "set-prefill": {
1378
+ validateExpression({
1379
+ expression: mutation.prefill.when,
1380
+ expectedReturnType: mutation.prefill.when ? ValueType.boolean : void 0,
1381
+ issues,
1382
+ operationIndex,
1383
+ responseSlotTypes,
1384
+ scope: `prefill ${mutation.prefill.id} condition for ${location.itemId}`
1385
+ });
1386
+ if (mutation.prefill.source.type === "expression") validateExpression({
1387
+ expression: mutation.prefill.source.expression,
1388
+ issues,
1389
+ operationIndex,
1390
+ responseSlotTypes,
1391
+ scope: `prefill ${mutation.prefill.id} source expression for ${location.itemId}`
1392
+ });
1393
+ const prefills = Array.isArray(rawItem.prefills) ? rawItem.prefills : void 0;
1394
+ const existingIndex = prefills?.findIndex((prefill) => isRecord$4(prefill) && prefill.id === mutation.prefill.id);
1395
+ if (!prefills) patches.push({
1396
+ op: "add",
1397
+ path: `${itemPath}/prefills`,
1398
+ value: toJsonValue([mutation.prefill])
1399
+ });
1400
+ else if (existingIndex !== void 0 && existingIndex >= 0) patches.push({
1401
+ op: "replace",
1402
+ path: `${itemPath}/prefills/${existingIndex}`,
1403
+ value: toJsonValue(mutation.prefill)
1404
+ });
1405
+ else patches.push({
1406
+ op: "add",
1407
+ path: `${itemPath}/prefills/-`,
1408
+ value: toJsonValue(mutation.prefill)
1409
+ });
1410
+ break;
1411
+ }
1412
+ case "clear-prefill": {
1413
+ const existingIndex = (Array.isArray(rawItem.prefills) ? rawItem.prefills : []).findIndex((prefill) => isRecord$4(prefill) && prefill.id === mutation.prefillId);
1414
+ if (existingIndex < 0) {
1415
+ issues.push(createIssue$1({
1416
+ severity: "warning",
1417
+ code: "expression-location-already-empty",
1418
+ message: `Prefill "${mutation.prefillId}" is not present on ${location.itemId}.`,
1419
+ operationIndex
1420
+ }));
1421
+ return;
1422
+ }
1423
+ patches.push({
1424
+ op: "remove",
1425
+ path: `${itemPath}/prefills/${existingIndex}`
1426
+ });
1427
+ break;
1428
+ }
1429
+ }
1430
+ };
1431
+ //#endregion
1432
+ //#region src/engine/normalization.ts
1433
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1434
+ const getString = (value) => typeof value === "string" ? value.trim() : void 0;
1435
+ const normalizeChoiceMaxSelection = (config) => {
1436
+ if (config.maxSelection === null) return null;
1437
+ if (typeof config.maxSelection === "number" && Number.isFinite(config.maxSelection)) return Math.max(1, Math.floor(config.maxSelection));
1438
+ if (typeof config.maxSelection === "string") {
1439
+ const normalized = config.maxSelection.trim().toLowerCase();
1440
+ if (normalized === "multiple" || normalized === "multi" || normalized === "unlimited" || normalized === "none") return null;
1441
+ const parsed = Number.parseInt(normalized, 10);
1442
+ if (Number.isFinite(parsed) && parsed >= 1) return parsed;
1443
+ }
1444
+ const mode = getString(config.mode)?.toLowerCase() ?? getString(config.selectionType)?.toLowerCase() ?? getString(config.selectionMode)?.toLowerCase();
1445
+ if (mode === "multiple" || mode === "multi" || mode === "multi-select" || mode === "multiselect" || mode === "checkbox" || mode === "checkboxes") return null;
1446
+ return 1;
1447
+ };
1448
+ const humanizeKey = (value) => value.replace(/[_-]+/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").trim().replace(/\b\w/g, (char) => char.toUpperCase());
1449
+ const normalizeLayout = (value) => {
1450
+ const baseLayout = {
1451
+ base: 1,
1452
+ xs: 1,
1453
+ sm: 1,
1454
+ md: 1,
1455
+ xl: 1
1456
+ };
1457
+ if (!isRecord$3(value)) return baseLayout;
1458
+ const normalized = { ...baseLayout };
1459
+ for (const key of Object.keys(normalized)) {
1460
+ const count = value[key];
1461
+ if (typeof count === "number" && Number.isFinite(count)) normalized[key] = Math.min(4, Math.max(1, Math.round(count)));
1462
+ }
1463
+ return normalized;
1464
+ };
1465
+ const normalizeKey = (value) => value.trim().replace(/[.\s]+/g, "_").replace(/[^A-Za-z0-9_-]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
1466
+ const textInline = (text, style) => ({
1467
+ type: "text",
1468
+ text,
1469
+ ...style ? { style } : {}
1470
+ });
1471
+ const paragraphBlock = (text, style) => ({
1472
+ type: "paragraph",
1473
+ children: [textInline(text, style)]
1474
+ });
1475
+ const getBlockText = (block) => getString(block.text) ?? getString(block.content) ?? getString(block.label);
1476
+ const normalizeAssistantHeadingLevel = (level) => Math.min(2, Math.max(1, Math.round(level)));
1477
+ const normalizeRichTextStyle = (value) => {
1478
+ const rawStyle = isRecord$3(value.style) ? value.style : {};
1479
+ const style = {};
1480
+ const weight = rawStyle.weight ?? value.weight;
1481
+ const italic = rawStyle.italic ?? value.italic;
1482
+ const underline = rawStyle.underline ?? value.underline;
1483
+ const color = rawStyle.color ?? value.color;
1484
+ if (value.strong === true || value.bold === true || weight === "strong" || weight === "bold") style.weight = "strong";
1485
+ else if (weight === "normal") style.weight = "normal";
1486
+ if (value.italic === true || italic === "italic") style.italic = "italic";
1487
+ else if (italic === "normal") style.italic = "normal";
1488
+ if (value.underline === true || underline === "underline") style.underline = "underline";
1489
+ else if (underline === "none") style.underline = "none";
1490
+ if (color === "primary" || color === "accent" || color === "default") style.color = color;
1491
+ return Object.keys(style).length > 0 ? style : void 0;
1492
+ };
1493
+ const normalizeRichTextInline = (value, allowLink = true) => {
1494
+ if (typeof value === "string") return textInline(value);
1495
+ if (!isRecord$3(value)) return;
1496
+ const type = getString(value.type)?.toLowerCase();
1497
+ if (type === "linebreak" || type === "line-break" || type === "break") return { type: "lineBreak" };
1498
+ if (type === "link" && allowLink) {
1499
+ const href = getString(value.href) ?? getString(value.url);
1500
+ if (!href) return;
1501
+ return {
1502
+ type: "link",
1503
+ href,
1504
+ children: (Array.isArray(value.children) ? value.children : [getString(value.text) ?? getString(value.label) ?? href]).map((child) => normalizeRichTextInline(child, false)).filter((child) => child !== void 0 && child.type !== "link")
1505
+ };
1506
+ }
1507
+ const text = getBlockText(value);
1508
+ return text === void 0 ? void 0 : textInline(text, normalizeRichTextStyle(value));
1509
+ };
1510
+ const normalizeRichTextChildren = (value) => {
1511
+ if (!Array.isArray(value.children)) return;
1512
+ const children = value.children.map((child) => normalizeRichTextInline(child)).filter((child) => Boolean(child));
1513
+ return children.length > 0 ? children : void 0;
1514
+ };
1515
+ const normalizeRichTextBlock = (value) => {
1516
+ if (typeof value === "string") return paragraphBlock(value);
1517
+ if (!isRecord$3(value)) return;
1518
+ const type = getString(value.type)?.toLowerCase();
1519
+ const text = getBlockText(value);
1520
+ const style = normalizeRichTextStyle(value);
1521
+ const children = normalizeRichTextChildren(value);
1522
+ if (type === "heading" || /^h[1-6]$/.test(type ?? "")) return {
1523
+ type: "heading",
1524
+ level: normalizeAssistantHeadingLevel(typeof value.level === "number" ? value.level : type === "h1" ? 1 : 2),
1525
+ children: children ?? [textInline(text ?? "", style)]
1526
+ };
1527
+ if (type === "bulletlist" || type === "bullet-list" || type === "list" || type === "bullets") return {
1528
+ type: "bulletList",
1529
+ items: (Array.isArray(value.items) ? value.items : []).map((item) => {
1530
+ const itemText = typeof item === "string" ? item : isRecord$3(item) ? getBlockText(item) : void 0;
1531
+ return itemText ? {
1532
+ type: "listItem",
1533
+ children: [paragraphBlock(itemText)]
1534
+ } : void 0;
1535
+ }).filter((item) => Boolean(item))
1536
+ };
1537
+ if (type === "infobox" || type === "info-box" || type === "note" || type === "footnote") return {
1538
+ type: "infoBox",
1539
+ tone: value.tone === "warning" ? "warning" : "info",
1540
+ title: getString(value.title) ? [textInline(getString(value.title) ?? "")] : void 0,
1541
+ collapsible: value.collapsible === true,
1542
+ defaultOpen: value.defaultOpen !== false,
1543
+ children: [paragraphBlock(text ?? "")]
1544
+ };
1545
+ if (type === "separator" || type === "divider" || type === "rule") return { type: "separator" };
1546
+ if (type === "paragraph" || text) return children ? {
1547
+ type: "paragraph",
1548
+ children
1549
+ } : paragraphBlock(text ?? "", style);
1550
+ };
1551
+ const normalizeRichTextContent = (value) => {
1552
+ if (!isRecord$3(value)) return value;
1553
+ if (value.type === ContentType.richText && isRecord$3(value.doc) && value.version === 1) return value;
1554
+ const candidateBlocks = Array.isArray(value.richTextBlocks) ? value.richTextBlocks : Array.isArray(value.blocks) ? value.blocks : void 0;
1555
+ if (!candidateBlocks) return value;
1556
+ const blocks = candidateBlocks.map(normalizeRichTextBlock).filter((block) => Boolean(block));
1557
+ return {
1558
+ type: ContentType.richText,
1559
+ version: 1,
1560
+ doc: {
1561
+ type: "doc",
1562
+ blocks
1563
+ }
1564
+ };
1565
+ };
1566
+ const normalizeLooseTranslationRecord = (value) => {
1567
+ const locale = getString(value.locale);
1568
+ const contentKey = getString(value.contentKey);
1569
+ if (!locale || !contentKey) return;
1570
+ const content = normalizeRichTextContent(value.content ?? (Array.isArray(value.blocks) || Array.isArray(value.richTextBlocks) ? value : void 0));
1571
+ const plainText = typeof value.plainText === "string" ? value.plainText : void 0;
1572
+ if (isRecord$3(content) && content.type) return {
1573
+ locale,
1574
+ contentKey,
1575
+ content
1576
+ };
1577
+ if (plainText !== void 0) return {
1578
+ locale,
1579
+ contentKey,
1580
+ plainText
1581
+ };
1582
+ const shorthandText = getString(value.text) ?? getString(value.label);
1583
+ return shorthandText ? {
1584
+ locale,
1585
+ contentKey,
1586
+ plainText: shorthandText
1587
+ } : void 0;
1588
+ };
1589
+ const toTranslation = (locale, contentKey, value) => {
1590
+ if (typeof value === "string") return {
1591
+ locale,
1592
+ contentKey,
1593
+ plainText: value
1594
+ };
1595
+ if (!isRecord$3(value)) return;
1596
+ const normalizedContent = normalizeRichTextContent(value);
1597
+ if (isRecord$3(normalizedContent) && normalizedContent.type === ContentType.richText && isRecord$3(normalizedContent.doc) && normalizedContent.version === 1) return {
1598
+ locale,
1599
+ contentKey,
1600
+ content: normalizedContent
1601
+ };
1602
+ if ((value.type === ContentType.md || value.type === ContentType.plain) && typeof value.content === "string") return {
1603
+ locale,
1604
+ contentKey,
1605
+ content: value
1606
+ };
1607
+ if (value.type === ContentType.richText && isRecord$3(value.doc) && value.version === 1) return {
1608
+ locale,
1609
+ contentKey,
1610
+ content: value
1611
+ };
1612
+ const shorthandText = getString(value.text) ?? getString(value.content) ?? getString(value.label);
1613
+ return shorthandText ? {
1614
+ locale,
1615
+ contentKey,
1616
+ plainText: shorthandText
1617
+ } : void 0;
1618
+ };
1619
+ const upsertLooseTranslation = (translations, next) => {
1620
+ if (!next) return;
1621
+ const existingIndex = translations.findIndex((translation) => translation.locale === next.locale && translation.contentKey === next.contentKey);
1622
+ if (existingIndex >= 0) {
1623
+ translations[existingIndex] = next;
1624
+ return;
1625
+ }
1626
+ translations.push(next);
1627
+ };
1628
+ const flattenLooseItemTranslations = (value) => {
1629
+ if (Array.isArray(value)) return value.filter(isRecord$3).map(normalizeLooseTranslationRecord).filter((translation) => Boolean(translation));
1630
+ if (!isRecord$3(value)) return [];
1631
+ const translations = [];
1632
+ for (const [locale, localeValue] of Object.entries(value)) {
1633
+ if (!isRecord$3(localeValue)) continue;
1634
+ const appendContentMap = (contentMap) => {
1635
+ for (const [contentKey, contentValue] of Object.entries(contentMap)) {
1636
+ if (contentKey === "options" && isRecord$3(contentValue)) {
1637
+ for (const [optionId, optionLabel] of Object.entries(contentValue)) upsertLooseTranslation(translations, toTranslation(locale, `options.${optionId}.label`, optionLabel));
1638
+ continue;
1639
+ }
1640
+ if (contentKey === "fieldGroups" && Array.isArray(contentValue)) {
1641
+ for (const group of contentValue) {
1642
+ if (!isRecord$3(group)) continue;
1643
+ const groupId = getString(group.id) ?? getString(group.groupId);
1644
+ if (!groupId) continue;
1645
+ upsertLooseTranslation(translations, toTranslation(locale, `fieldGroups.${groupId}.title`, group.title));
1646
+ upsertLooseTranslation(translations, toTranslation(locale, `fieldGroups.${groupId}.description`, group.description));
1647
+ }
1648
+ continue;
1649
+ }
1650
+ upsertLooseTranslation(translations, toTranslation(locale, contentKey, contentValue));
1651
+ }
1652
+ };
1653
+ if (isRecord$3(localeValue.itemTranslations)) {
1654
+ for (const itemTranslation of Object.values(localeValue.itemTranslations)) if (isRecord$3(itemTranslation)) appendContentMap(itemTranslation);
1655
+ continue;
1656
+ }
1657
+ appendContentMap(localeValue);
1658
+ }
1659
+ return translations;
1660
+ };
1661
+ const appendParenthetical = (text, note) => {
1662
+ if (!note) return text;
1663
+ if (text.toLowerCase().includes(note.toLowerCase())) return text;
1664
+ return `${text} (${note})`;
1665
+ };
1666
+ const appendMarker = (text, marker) => {
1667
+ if (!marker || text.endsWith(marker)) return text;
1668
+ return `${text}${marker}`;
1669
+ };
1670
+ const getOptionNote = (option) => getString(option.note) ?? getString(option.description) ?? getString(option.helpText) ?? getString(option.instruction);
1671
+ const getFootnoteMarker = (option) => getString(option.footnoteMarker) ?? getString(option.marker);
1672
+ const hasEmbeddedForm = (option) => isRecord$3(option.embeddedForm);
1673
+ const stripTrailingEmbeddedFormCue = (text, option) => {
1674
+ if (!hasEmbeddedForm(option)) return text;
1675
+ return text.replace(/\s*[,;:]?\s*(namelijk|namely|please specify|specify|explain|explanation|toelichting)\s*[:.]?\s*$/iu, "").trim();
1676
+ };
1677
+ const getOptionTranslationLabel = (option) => {
1678
+ const directLabel = getString(option.label) ?? getString(option.text) ?? getString(option.title);
1679
+ if (directLabel) return appendMarker(stripTrailingEmbeddedFormCue(appendParenthetical(directLabel, getOptionNote(option)), option), getFootnoteMarker(option));
1680
+ if (!isRecord$3(option.translations)) return;
1681
+ for (const localeValue of Object.values(option.translations)) {
1682
+ if (!isRecord$3(localeValue)) continue;
1683
+ const translatedLabel = getString(localeValue.label) ?? getString(localeValue.text);
1684
+ if (translatedLabel) {
1685
+ const translatedNote = getString(localeValue.note) ?? getString(localeValue.description) ?? getString(localeValue.helpText) ?? getString(localeValue.instruction);
1686
+ const translatedMarker = getString(localeValue.footnoteMarker) ?? getString(localeValue.marker);
1687
+ return appendMarker(stripTrailingEmbeddedFormCue(appendParenthetical(translatedLabel, translatedNote), option), translatedMarker);
1688
+ }
1689
+ }
1690
+ };
1691
+ const getSelectOptionTranslationLabel = (option) => getString(option.label) ?? getString(option.text) ?? getString(option.title) ?? getString(option.name);
1692
+ const normalizeSelectControllerOptions = ({ controller, fieldId, translations }) => {
1693
+ if (controller.type !== "select" || !Array.isArray(controller.options)) return controller;
1694
+ return {
1695
+ ...controller,
1696
+ options: controller.options.map((option, index) => {
1697
+ if (!isRecord$3(option)) return option;
1698
+ const optionLabel = getSelectOptionTranslationLabel(option);
1699
+ const optionId = normalizeKey(getString(option.id) ?? getString(option.optionId) ?? getString(option.value) ?? (optionLabel ? normalizeKey(optionLabel) : void 0) ?? `option_${index + 1}`) || `option_${index + 1}`;
1700
+ const value = getString(option.value) ?? optionId;
1701
+ if (optionLabel) translations.push({
1702
+ contentKey: `fields.${fieldId}.options.${optionId}.label`,
1703
+ plainText: optionLabel
1704
+ });
1705
+ return {
1706
+ ...option,
1707
+ id: optionId,
1708
+ value
1709
+ };
1710
+ })
1711
+ };
1712
+ };
1713
+ const appendOptionTranslationLabels = (translations, option, optionId) => {
1714
+ if (!optionId || !isRecord$3(option.translations)) return;
1715
+ for (const [locale, localeValue] of Object.entries(option.translations)) {
1716
+ if (!isRecord$3(localeValue)) continue;
1717
+ upsertLooseTranslation(translations, toTranslation(locale, `options.${optionId}.label`, localeValue.label ?? localeValue.text));
1718
+ }
1719
+ };
1720
+ const normalizeLooseField = (field) => {
1721
+ if (!isRecord$3(field)) return { translations: [] };
1722
+ const rawId = getString(field.id) ?? getString(field.fieldId) ?? getString(field.key);
1723
+ const id = rawId ? normalizeKey(rawId) : void 0;
1724
+ if (!id) return { translations: [] };
1725
+ const rawType = getString(field.type) ?? getString(field.controlType) ?? "input";
1726
+ const type = rawType === "text" || rawType === "email" ? "input" : rawType === "checkbox" ? "switch" : rawType;
1727
+ const controller = rawType === "textarea" ? {
1728
+ type: "textarea",
1729
+ rows: 3
1730
+ } : rawType === "number" ? {
1731
+ type: "number",
1732
+ step: 1
1733
+ } : rawType === "checkbox" ? {
1734
+ type: "switch",
1735
+ defaultChecked: false
1736
+ } : rawType === "select" ? {
1737
+ type: "select",
1738
+ options: []
1739
+ } : {
1740
+ type: "input",
1741
+ inputMode: rawType === "email" ? "email" : "text"
1742
+ };
1743
+ const translations = [{
1744
+ contentKey: `fields.${id}.label`,
1745
+ plainText: getString(field.label) ?? humanizeKey(id)
1746
+ }];
1747
+ const placeholder = getString(field.placeholder);
1748
+ if (placeholder) translations.push({
1749
+ contentKey: `fields.${id}.placeholder`,
1750
+ plainText: placeholder
1751
+ });
1752
+ const description = getString(field.description);
1753
+ if (description) translations.push({
1754
+ contentKey: `fields.${id}.description`,
1755
+ plainText: description
1756
+ });
1757
+ const unit = getString(field.unit);
1758
+ if (unit) translations.push({
1759
+ contentKey: `fields.${id}.unit`,
1760
+ plainText: unit
1761
+ });
1762
+ return {
1763
+ field: {
1764
+ id,
1765
+ key: getString(field.key) ?? id.toUpperCase(),
1766
+ type,
1767
+ required: field.required === true,
1768
+ controller: normalizeSelectControllerOptions({
1769
+ controller: isRecord$3(field.controller) ? field.controller : controller,
1770
+ fieldId: id,
1771
+ translations
1772
+ }),
1773
+ validations: Array.isArray(field.validations) && field.validations.length > 0 ? field.validations : rawType === "email" ? [{
1774
+ id: "email",
1775
+ type: "email"
1776
+ }] : []
1777
+ },
1778
+ translations
1779
+ };
1780
+ };
1781
+ const normalizeFormFieldGroupSelectOptions = (fieldGroups) => {
1782
+ if (!Array.isArray(fieldGroups)) return {
1783
+ fieldGroups,
1784
+ translations: []
1785
+ };
1786
+ const translations = [];
1787
+ return {
1788
+ fieldGroups: fieldGroups.map((group) => {
1789
+ if (!isRecord$3(group) || !Array.isArray(group.fields)) return group;
1790
+ return {
1791
+ ...group,
1792
+ fields: group.fields.map((field) => {
1793
+ if (!isRecord$3(field)) return field;
1794
+ const fieldId = getString(field.id) ?? getString(field.fieldId) ?? getString(field.key);
1795
+ const controller = isRecord$3(field.controller) ? field.controller : void 0;
1796
+ if (!fieldId || !controller || controller.type !== "select") return field;
1797
+ return {
1798
+ ...field,
1799
+ controller: normalizeSelectControllerOptions({
1800
+ controller,
1801
+ fieldId,
1802
+ translations
1803
+ })
1804
+ };
1805
+ })
1806
+ };
1807
+ }),
1808
+ translations
1809
+ };
1810
+ };
1811
+ const normalizeLooseCreateOperation = (operation, defaultLocale) => {
1812
+ if (!isRecord$3(operation) || !isRecord$3(operation.item)) return operation;
1813
+ if ((getString(operation.kind) ?? getString(operation.op)) !== "create-item") return operation;
1814
+ const item = { ...operation.item };
1815
+ const translations = [...flattenLooseItemTranslations(operation.translations), ...flattenLooseItemTranslations(item.translations)];
1816
+ if (!getString(item.id)) {
1817
+ const itemId = getString(item.key) ?? getString(item.label);
1818
+ if (itemId) item.id = normalizeKey(itemId);
1819
+ }
1820
+ if (!isRecord$3(item.config) && isRecord$3(operation.config)) item.config = operation.config;
1821
+ if (item.itemType === "choiceItem") {
1822
+ const rawConfig = isRecord$3(item.config) ? item.config : {};
1823
+ const choiceConfig = isRecord$3(item.choiceConfig) ? item.choiceConfig : {};
1824
+ const looseOptions = Array.isArray(item.options) ? item.options : void 0;
1825
+ const config = {
1826
+ ...choiceConfig,
1827
+ ...rawConfig,
1828
+ ...!Array.isArray(rawConfig.options) && looseOptions ? { options: looseOptions } : {}
1829
+ };
1830
+ config.id = getString(config.id) ?? getString(item.id) ?? getString(item.key);
1831
+ config.maxSelection = normalizeChoiceMaxSelection(config);
1832
+ config.shuffleOptions = config.shuffleOptions === true;
1833
+ if (Array.isArray(config.options)) config.options = config.options.map((option) => {
1834
+ if (!isRecord$3(option)) return option;
1835
+ const optionLabel = getOptionTranslationLabel(option);
1836
+ const optionId = getString(option.id) ?? getString(option.optionId) ?? getString(option.value) ?? (optionLabel ? normalizeKey(optionLabel) : void 0);
1837
+ appendOptionTranslationLabels(translations, option, optionId);
1838
+ return {
1839
+ ...option,
1840
+ id: optionId,
1841
+ key: getString(option.key) ?? getString(option.value) ?? getString(option.optionId),
1842
+ label: optionLabel
1843
+ };
1844
+ });
1845
+ item.config = config;
1846
+ delete item.choiceConfig;
1847
+ delete item.options;
1848
+ }
1849
+ if (item.itemType === "formItem" && isRecord$3(item.config)) {
1850
+ const config = { ...item.config };
1851
+ config.id = getString(config.id) ?? getString(item.id) ?? getString(item.key);
1852
+ const looseFields = Array.isArray(config.fields) ? config.fields : [];
1853
+ const fieldTranslations = [];
1854
+ const normalizedLooseFields = looseFields.map((field) => {
1855
+ const normalized = normalizeLooseField(field);
1856
+ fieldTranslations.push(...normalized.translations);
1857
+ return normalized.field;
1858
+ }).filter((field) => Boolean(field));
1859
+ if (Array.isArray(config.fieldGroups)) config.fieldGroups = config.fieldGroups.map((group, index) => {
1860
+ if (!isRecord$3(group)) return group;
1861
+ return {
1862
+ ...group,
1863
+ id: getString(group.id) ?? getString(group.groupId) ?? `group_${index + 1}`,
1864
+ layout: normalizeLayout(group.layout),
1865
+ fields: Array.isArray(group.fields) && group.fields.length > 0 ? group.fields : index === 0 ? normalizedLooseFields : []
1866
+ };
1867
+ });
1868
+ else if (normalizedLooseFields.length > 0) config.fieldGroups = [{
1869
+ id: "main",
1870
+ layout: {
1871
+ base: 1,
1872
+ xs: 1,
1873
+ sm: 1,
1874
+ md: 1,
1875
+ xl: 1
1876
+ },
1877
+ fields: normalizedLooseFields
1878
+ }];
1879
+ delete config.fields;
1880
+ const normalizedSelectOptions = normalizeFormFieldGroupSelectOptions(config.fieldGroups);
1881
+ config.fieldGroups = normalizedSelectOptions.fieldGroups;
1882
+ fieldTranslations.push(...normalizedSelectOptions.translations);
1883
+ for (const translation of fieldTranslations) upsertLooseTranslation(translations, {
1884
+ locale: defaultLocale,
1885
+ contentKey: translation.contentKey,
1886
+ plainText: translation.plainText
1887
+ });
1888
+ item.config = config;
1889
+ }
1890
+ delete item.translations;
1891
+ return {
1892
+ ...operation,
1893
+ kind: "create-item",
1894
+ item,
1895
+ ...translations.length > 0 ? { translations } : {}
1896
+ };
1897
+ };
1898
+ /**
1899
+ * Accepts common model-authored shorthand and converts it to the strict
1900
+ * assistant operation shape before validation. This keeps the public tool
1901
+ * compact while making it tolerant of predictable schema-near authoring
1902
+ * mistakes: nested raw translation maps, sibling config objects, choice
1903
+ * optionId aliases, and flat form fields.
1904
+ */
1905
+ const normalizeSurveyChangeOperations = (operations, defaultLocale) => operations.map((operation) => normalizeLooseCreateOperation(operation, defaultLocale));
1906
+ //#endregion
1907
+ //#region src/engine/operations.ts
1908
+ const ITEM_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
1909
+ const MAX_SURVEY_ASSISTANT_OPERATIONS_PER_PROPOSAL = 200;
1910
+ const CHOICE_ITEM_TYPE = "choiceItem";
1911
+ const FORM_ITEM_TYPE = "formItem";
1912
+ const CHOICE_OPTION_LABEL_CONTENT_KEY_PATTERN = /^options\.([^.]+)\.label$/;
1913
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1914
+ const getTrimmedString = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
1915
+ const getFirstStringProperty = (record, keys) => {
1916
+ for (const key of keys) {
1917
+ const value = getTrimmedString(record[key]);
1918
+ if (value) return value;
1919
+ }
1920
+ };
1921
+ const isContent = (value) => {
1922
+ if (!isRecord$2(value)) return false;
1923
+ if ((value.type === ContentType.md || value.type === ContentType.plain) && typeof value.content === "string") return true;
1924
+ return value.type === ContentType.richText && value.version === 1 && isRecord$2(value.doc) && value.doc.type === "doc" && Array.isArray(value.doc.blocks);
1925
+ };
1926
+ const cloneContent = (content) => structuredCloneMethod(content);
1927
+ const getTranslationPlainText = ({ content, plainText }) => {
1928
+ if (content) return getContentPlainText(content).trim();
1929
+ return (plainText ?? "").trim();
1930
+ };
1931
+ const isTranslationContentEmpty = ({ content, plainText }) => content ? isContentEmpty(content) : (plainText ?? "").trim().length === 0;
1932
+ const getCreateItemNestedTranslations = (item) => {
1933
+ const rawTranslations = item.translations;
1934
+ if (!Array.isArray(rawTranslations)) return [];
1935
+ return rawTranslations.filter(isRecord$2).map((translation) => {
1936
+ const locale = getTrimmedString(translation.locale);
1937
+ const contentKey = getTrimmedString(translation.contentKey);
1938
+ const content = isContent(translation.content) ? cloneContent(translation.content) : void 0;
1939
+ const plainText = typeof translation.plainText === "string" ? translation.plainText : void 0;
1940
+ if (!locale || !contentKey || !content && plainText === void 0) return;
1941
+ return {
1942
+ locale,
1943
+ contentKey,
1944
+ ...plainText !== void 0 ? { plainText } : {},
1945
+ ...content ? { content } : {}
1946
+ };
1947
+ }).filter((translation) => Boolean(translation));
1948
+ };
1949
+ const getGroupChildren = (item) => {
1950
+ const items = (isRecord$2(item?.config) ? item.config : void 0)?.items;
1951
+ if (!Array.isArray(items)) return [];
1952
+ return items.filter((entry) => typeof entry === "string");
1953
+ };
1954
+ const isRootItem = (item) => {
1955
+ return (isRecord$2(item?.config) ? item.config : void 0)?.isRoot === true;
1956
+ };
1957
+ const isSurveyJsonPatchOperation = (operation) => operation.kind === "survey-json-patch";
1958
+ const getPatchTouchedPaths = (operation) => operation.patches.flatMap((patch) => "from" in patch ? [patch.path, patch.from] : [patch.path]);
1959
+ const touchesSurveySchemaPath = (path) => {
1960
+ return parseJsonPointer(path)[0] === "$schema";
1961
+ };
1962
+ const isTranslationContainerMutation = (patch) => patch.op !== "test";
1963
+ const validateTranslationPatchSafety = ({ operation, survey }) => {
1964
+ const issues = [];
1965
+ const operationId = getOperationId(operation);
1966
+ const translations = survey.translations ?? {};
1967
+ for (const [patchIndex, patch] of operation.patches.entries()) {
1968
+ const paths = patch.op === "move" ? [patch.path, patch.from] : [patch.path];
1969
+ for (const path of paths) {
1970
+ let segments;
1971
+ try {
1972
+ segments = parseJsonPointer(path);
1973
+ } catch {
1974
+ continue;
1975
+ }
1976
+ if (segments[0] !== "translations" || !isTranslationContainerMutation(patch)) continue;
1977
+ if (segments.length === 1) {
1978
+ issues.push(createIssue({
1979
+ code: "unsafe-translation-root-patch",
1980
+ message: "Raw patches cannot replace or remove the complete translations map. Patch a specific locale/content leaf or use typed translation operations.",
1981
+ operationIndex: patchIndex,
1982
+ operationId
1983
+ }));
1984
+ continue;
1985
+ }
1986
+ const locale = segments[1];
1987
+ const localeTranslations = translations[locale];
1988
+ if (segments.length === 2 && localeTranslations !== void 0) {
1989
+ issues.push(createIssue({
1990
+ code: "unsafe-locale-translation-patch",
1991
+ message: `Raw patch ${patchIndex} would replace or remove the existing ${locale} locale branch and discard sibling translations. Patch a leaf under /translations/${locale}/itemTranslations/<itemId>/... instead.`,
1992
+ operationIndex: patchIndex,
1993
+ operationId
1994
+ }));
1995
+ continue;
1996
+ }
1997
+ if (segments[2] === "itemTranslations" && segments.length === 4 && localeTranslations?.itemTranslations?.[segments[3]] !== void 0) issues.push(createIssue({
1998
+ code: "unsafe-item-translation-patch",
1999
+ message: `Raw patch ${patchIndex} would replace or remove all existing translations for item "${segments[3]}" in locale ${locale}. Patch the individual content key instead, or use update-item-translation.`,
2000
+ operationIndex: patchIndex,
2001
+ operationId,
2002
+ itemId: segments[3]
2003
+ }));
2004
+ }
2005
+ }
2006
+ return issues;
2007
+ };
2008
+ const validateTranslationBranchShapes = ({ operation, survey }) => {
2009
+ const issues = [];
2010
+ const operationId = getOperationId(operation);
2011
+ for (const [locale, localeTranslations] of Object.entries(survey.translations ?? {})) {
2012
+ if (!isRecord$2(localeTranslations)) {
2013
+ issues.push(createIssue({
2014
+ code: "invalid-locale-translation-shape",
2015
+ message: `Locale "${locale}" must be a translation object, not an array or primitive value.`,
2016
+ operationIndex: 0,
2017
+ operationId
2018
+ }));
2019
+ continue;
2020
+ }
2021
+ if (localeTranslations.itemTranslations !== void 0 && !isRecord$2(localeTranslations.itemTranslations)) issues.push(createIssue({
2022
+ code: "invalid-item-translations-shape",
2023
+ message: `Locale "${locale}" itemTranslations must be an object keyed by item id.`,
2024
+ operationIndex: 0,
2025
+ operationId
2026
+ }));
2027
+ }
2028
+ return issues;
2029
+ };
2030
+ const getRawItemsFromSurvey = (survey) => Array.isArray(survey.surveyItems) ? survey.surveyItems : [];
2031
+ const createStructureIssue = ({ code, message, operationId, itemId }) => createIssue({
2032
+ code,
2033
+ message,
2034
+ operationIndex: 0,
2035
+ operationId,
2036
+ itemId
2037
+ });
2038
+ const buildResponseSlotTypesForDraft = ({ currentItem, rawItemsById, snapshot }) => {
2039
+ const draftItems = Array.from(rawItemsById.values());
2040
+ if (currentItem) {
2041
+ const existingIndex = draftItems.findIndex((item) => item.id === currentItem.id);
2042
+ if (existingIndex >= 0) draftItems[existingIndex] = currentItem;
2043
+ else draftItems.push(currentItem);
2044
+ }
2045
+ return deriveResponseSlotTypes({
2046
+ ...snapshot.survey,
2047
+ surveyItems: draftItems
2048
+ });
2049
+ };
2050
+ const isJsonExpression = (value) => isRecord$2(value) && typeof value.type === "string";
2051
+ const validateCreateItemExpressions = ({ issues, item, operationId, operationIndex, responseSlotTypes }) => {
2052
+ const addExpressionValidation = (expression, scope, expectedReturnType) => {
2053
+ if (!isJsonExpression(expression)) {
2054
+ issues.push(createIssue({
2055
+ code: "invalid-expression",
2056
+ message: `${scope} must be a CASE expression JSON object.`,
2057
+ operationIndex,
2058
+ operationId,
2059
+ itemId: item.id
2060
+ }));
2061
+ return;
2062
+ }
2063
+ const beforeCount = issues.length;
2064
+ validateExpression({
2065
+ expression,
2066
+ expectedReturnType,
2067
+ issues,
2068
+ operationIndex,
2069
+ responseSlotTypes,
2070
+ scope
2071
+ });
2072
+ for (const issue of issues.slice(beforeCount)) {
2073
+ issue.operationId = issue.operationId ?? operationId;
2074
+ issue.itemId = issue.itemId ?? item.id;
2075
+ }
2076
+ };
2077
+ const displayConditions = isRecord$2(item.displayConditions) ? item.displayConditions : void 0;
2078
+ if (displayConditions?.root !== void 0) addExpressionValidation(displayConditions.root, `display condition for new item ${item.id}`, ValueType.boolean);
2079
+ const displayComponents = isRecord$2(displayConditions?.components) ? displayConditions.components : void 0;
2080
+ for (const [componentId, expression] of Object.entries(displayComponents ?? {})) addExpressionValidation(expression, `display condition for new item ${item.id}.${componentId}`, ValueType.boolean);
2081
+ const disabledConditions = isRecord$2(item.disabledConditions) ? item.disabledConditions : void 0;
2082
+ const disabledComponents = isRecord$2(disabledConditions?.components) ? disabledConditions.components : void 0;
2083
+ for (const [componentId, expression] of Object.entries(disabledComponents ?? {})) addExpressionValidation(expression, `disabled condition for new item ${item.id}.${componentId}`, ValueType.boolean);
2084
+ const validations = isRecord$2(item.validations) ? item.validations : void 0;
2085
+ for (const [validationKey, expression] of Object.entries(validations ?? {})) addExpressionValidation(expression, `validation ${validationKey} for new item ${item.id}`, ValueType.boolean);
2086
+ const prefills = Array.isArray(item.prefills) ? item.prefills : [];
2087
+ for (const prefill of prefills) {
2088
+ if (!isRecord$2(prefill)) continue;
2089
+ const prefillId = getTrimmedString(prefill.id) ?? "unnamed";
2090
+ if (prefill.when !== void 0) addExpressionValidation(prefill.when, `prefill ${prefillId} condition for new item ${item.id}`, ValueType.boolean);
2091
+ const source = isRecord$2(prefill.source) ? prefill.source : void 0;
2092
+ if (source?.type === "expression") addExpressionValidation(source.expression, `prefill ${prefillId} source expression for new item ${item.id}`);
2093
+ }
2094
+ };
2095
+ const validateSurveyTreeStructure = (survey, operationId, availableItemTypes) => {
2096
+ const issues = [];
2097
+ if (!isRecord$2(survey)) return [createStructureIssue({
2098
+ code: "patched-survey-not-object",
2099
+ message: "The patched survey must be a JSON object.",
2100
+ operationId
2101
+ })];
2102
+ if (typeof survey.$schema !== "string" || survey.$schema.trim().length === 0) issues.push(createStructureIssue({
2103
+ code: "missing-survey-schema",
2104
+ message: "The patched survey must keep a non-empty $schema value.",
2105
+ operationId
2106
+ }));
2107
+ if (!Array.isArray(survey.surveyItems) || survey.surveyItems.length === 0) {
2108
+ issues.push(createStructureIssue({
2109
+ code: "missing-survey-items",
2110
+ message: "The patched survey must contain at least one survey item.",
2111
+ operationId
2112
+ }));
2113
+ return issues;
2114
+ }
2115
+ const itemsById = /* @__PURE__ */ new Map();
2116
+ const rootItems = [];
2117
+ for (const [itemIndex, item] of survey.surveyItems.entries()) {
2118
+ if (!isRecord$2(item)) {
2119
+ issues.push(createStructureIssue({
2120
+ code: "invalid-survey-item",
2121
+ message: `Survey item at index ${itemIndex} must be an object.`,
2122
+ operationId
2123
+ }));
2124
+ continue;
2125
+ }
2126
+ const itemId = getTrimmedString(item.id);
2127
+ const itemKey = getTrimmedString(item.key);
2128
+ const itemType = getTrimmedString(item.itemType);
2129
+ if (!itemId || !itemKey || !itemType) {
2130
+ issues.push(createStructureIssue({
2131
+ code: "invalid-survey-item-identity",
2132
+ message: `Survey item at index ${itemIndex} must have non-empty id, key, and itemType fields.`,
2133
+ operationId,
2134
+ itemId
2135
+ }));
2136
+ continue;
2137
+ }
2138
+ if (!availableItemTypes.has(itemType)) issues.push(createStructureIssue({
2139
+ code: "unknown-item-type",
2140
+ message: `Item "${itemId}" uses item type "${itemType}", which is not available in the current editor registry.`,
2141
+ operationId,
2142
+ itemId
2143
+ }));
2144
+ if (!ITEM_KEY_PATTERN.test(itemKey)) issues.push(createStructureIssue({
2145
+ code: "invalid-item-key",
2146
+ message: `Item "${itemId}" has an invalid key. Keys must contain only letters, numbers, underscores, or hyphens.`,
2147
+ operationId,
2148
+ itemId
2149
+ }));
2150
+ if (itemsById.has(itemId)) {
2151
+ issues.push(createStructureIssue({
2152
+ code: "duplicate-item-id",
2153
+ message: `The patched survey contains duplicate item id "${itemId}".`,
2154
+ operationId,
2155
+ itemId
2156
+ }));
2157
+ continue;
2158
+ }
2159
+ const rawItem = item;
2160
+ itemsById.set(itemId, rawItem);
2161
+ if (isRootItem(rawItem)) rootItems.push(rawItem);
2162
+ }
2163
+ if (rootItems.length !== 1) issues.push(createStructureIssue({
2164
+ code: "invalid-root-count",
2165
+ message: rootItems.length === 0 ? "The patched survey must contain one root group item." : "The patched survey cannot contain more than one root group item.",
2166
+ operationId
2167
+ }));
2168
+ else if (rootItems[0].itemType !== ReservedSurveyItemTypes.Group) issues.push(createStructureIssue({
2169
+ code: "root-not-group",
2170
+ message: "The root item must be a group item.",
2171
+ operationId,
2172
+ itemId: rootItems[0].id
2173
+ }));
2174
+ const parentByChildId = /* @__PURE__ */ new Map();
2175
+ for (const item of itemsById.values()) {
2176
+ const childRefs = (isRecord$2(item.config) ? item.config : void 0)?.items;
2177
+ if (childRefs === void 0) continue;
2178
+ if (item.itemType !== ReservedSurveyItemTypes.Group || !Array.isArray(childRefs)) {
2179
+ issues.push(createStructureIssue({
2180
+ code: "invalid-group-items",
2181
+ message: `Item "${item.id}" has an items array but is not a valid group item.`,
2182
+ operationId,
2183
+ itemId: item.id
2184
+ }));
2185
+ continue;
2186
+ }
2187
+ const seenChildRefs = /* @__PURE__ */ new Set();
2188
+ for (const childId of childRefs) {
2189
+ if (typeof childId !== "string" || childId.trim().length === 0) {
2190
+ issues.push(createStructureIssue({
2191
+ code: "invalid-child-reference",
2192
+ message: `Group item "${item.id}" contains an invalid child reference.`,
2193
+ operationId,
2194
+ itemId: item.id
2195
+ }));
2196
+ continue;
2197
+ }
2198
+ if (childId === item.id) issues.push(createStructureIssue({
2199
+ code: "self-child-reference",
2200
+ message: `Group item "${item.id}" cannot include itself as a child.`,
2201
+ operationId,
2202
+ itemId: item.id
2203
+ }));
2204
+ if (seenChildRefs.has(childId)) issues.push(createStructureIssue({
2205
+ code: "duplicate-child-reference",
2206
+ message: `Group item "${item.id}" lists child "${childId}" more than once.`,
2207
+ operationId,
2208
+ itemId: item.id
2209
+ }));
2210
+ seenChildRefs.add(childId);
2211
+ if (!itemsById.has(childId)) {
2212
+ issues.push(createStructureIssue({
2213
+ code: "missing-child-item",
2214
+ message: `Group item "${item.id}" references missing child item "${childId}".`,
2215
+ operationId,
2216
+ itemId: item.id
2217
+ }));
2218
+ continue;
2219
+ }
2220
+ const existingParentId = parentByChildId.get(childId);
2221
+ if (existingParentId && existingParentId !== item.id) issues.push(createStructureIssue({
2222
+ code: "multiple-child-parents",
2223
+ message: `Child item "${childId}" is listed under both "${existingParentId}" and "${item.id}".`,
2224
+ operationId,
2225
+ itemId: childId
2226
+ }));
2227
+ parentByChildId.set(childId, item.id);
2228
+ }
2229
+ }
2230
+ for (const item of itemsById.values()) {
2231
+ if (isRootItem(item)) continue;
2232
+ if (!parentByChildId.has(item.id)) issues.push(createStructureIssue({
2233
+ code: "orphan-survey-item",
2234
+ message: `Item "${item.id}" is not reachable from any group parent.`,
2235
+ operationId,
2236
+ itemId: item.id
2237
+ }));
2238
+ }
2239
+ for (const item of itemsById.values()) {
2240
+ const siblingKeys = /* @__PURE__ */ new Map();
2241
+ for (const childId of getGroupChildren(item)) {
2242
+ const child = itemsById.get(childId);
2243
+ if (!child) continue;
2244
+ const conflictingChildId = siblingKeys.get(child.key);
2245
+ if (conflictingChildId) issues.push(createStructureIssue({
2246
+ code: "duplicate-sibling-key",
2247
+ message: `Siblings "${conflictingChildId}" and "${childId}" both use key "${child.key}".`,
2248
+ operationId,
2249
+ itemId: childId
2250
+ }));
2251
+ siblingKeys.set(child.key, childId);
2252
+ }
2253
+ }
2254
+ const rootItem = rootItems[0];
2255
+ if (rootItem) {
2256
+ const visiting = /* @__PURE__ */ new Set();
2257
+ const visited = /* @__PURE__ */ new Set();
2258
+ const visit = (itemId, path) => {
2259
+ if (visiting.has(itemId)) {
2260
+ issues.push(createStructureIssue({
2261
+ code: "survey-item-cycle",
2262
+ message: `The patched survey contains a cycle through item "${itemId}" (${path.join(" -> ")} -> ${itemId}).`,
2263
+ operationId,
2264
+ itemId
2265
+ }));
2266
+ return;
2267
+ }
2268
+ if (visited.has(itemId)) return;
2269
+ visiting.add(itemId);
2270
+ const item = itemsById.get(itemId);
2271
+ for (const childId of getGroupChildren(item)) visit(childId, [...path, itemId]);
2272
+ visiting.delete(itemId);
2273
+ visited.add(itemId);
2274
+ };
2275
+ visit(rootItem.id, []);
2276
+ }
2277
+ return issues;
2278
+ };
2279
+ const validateSurveyJsonPatchOperation = ({ operation, snapshot }) => {
2280
+ const operationId = getOperationId(operation);
2281
+ const issues = [];
2282
+ const warnings = [];
2283
+ issues.push(...validateTranslationPatchSafety({
2284
+ operation,
2285
+ survey: snapshot.survey
2286
+ }));
2287
+ for (const path of getPatchTouchedPaths(operation)) try {
2288
+ if (touchesSurveySchemaPath(path)) issues.push(createIssue({
2289
+ code: "cannot-edit-survey-schema",
2290
+ message: "Assistant raw patches cannot modify the survey $schema field.",
2291
+ operationIndex: 0,
2292
+ operationId
2293
+ }));
2294
+ } catch (error) {
2295
+ issues.push(createIssue({
2296
+ code: "invalid-json-pointer",
2297
+ message: error instanceof Error ? error.message : `Invalid JSON Pointer path "${path}" in raw survey patch.`,
2298
+ operationIndex: 0,
2299
+ operationId
2300
+ }));
2301
+ }
2302
+ if (issues.length > 0) return {
2303
+ valid: false,
2304
+ issues,
2305
+ warnings,
2306
+ normalizedOperations: [operation]
2307
+ };
2308
+ let patchedSurvey;
2309
+ try {
2310
+ patchedSurvey = applyJsonPatch(snapshot.survey, operation.patches);
2311
+ } catch (error) {
2312
+ const patchError = error instanceof JsonPatchError ? error : void 0;
2313
+ return {
2314
+ valid: false,
2315
+ issues: [createIssue({
2316
+ code: patchError?.code ?? "json-patch-failed",
2317
+ message: error instanceof Error ? error.message : "The raw survey JSON Patch could not be applied.",
2318
+ operationIndex: patchError?.operationIndex ?? 0,
2319
+ operationId
2320
+ })],
2321
+ warnings,
2322
+ normalizedOperations: [operation]
2323
+ };
2324
+ }
2325
+ const existingItemIds = new Set(snapshot.survey.surveyItems.map((item) => item.id));
2326
+ const manifestByItemType = new Map(snapshot.capabilities.itemTypes.map((entry) => [entry.itemType, entry]));
2327
+ for (const item of patchedSurvey.surveyItems) {
2328
+ if (existingItemIds.has(item.id)) continue;
2329
+ const capability = manifestByItemType.get(item.itemType);
2330
+ if (capability?.create !== "deterministic") issues.push(createIssue({
2331
+ code: "item-type-not-deterministically-creatable",
2332
+ message: capability ? `Item type "${item.itemType}" requires an interactive editor flow and cannot be created through a raw patch.` : `Item type "${item.itemType}" is not available in the current assistant capability set.`,
2333
+ operationIndex: 0,
2334
+ operationId,
2335
+ itemId: item.id
2336
+ }));
2337
+ }
2338
+ for (const item of patchedSurvey.surveyItems) {
2339
+ const capability = snapshot.trustedCapabilities?.byItemType.get(item.itemType);
2340
+ for (const issue of capability?.validateItem?.(item) ?? []) {
2341
+ const normalizedIssue = {
2342
+ ...issue,
2343
+ operationIndex: issue.operationIndex ?? 0,
2344
+ operationId: issue.operationId ?? operationId,
2345
+ itemId: issue.itemId ?? item.id
2346
+ };
2347
+ (normalizedIssue.severity === "warning" ? warnings : issues).push(normalizedIssue);
2348
+ }
2349
+ }
2350
+ issues.push(...validateTranslationBranchShapes({
2351
+ operation,
2352
+ survey: patchedSurvey
2353
+ }), ...validateSurveyTreeStructure(patchedSurvey, operationId, new Set(snapshot.capabilities.itemTypes.map((entry) => entry.itemType))));
2354
+ const documentResult = rawSurveyDocumentSchema.safeParse(patchedSurvey);
2355
+ if (!documentResult.success) {
2356
+ const firstIssue = documentResult.error.issues[0];
2357
+ const path = firstIssue?.path.length ? ` at /${firstIssue.path.join("/")}` : "";
2358
+ issues.push(createIssue({
2359
+ code: "invalid-survey-document",
2360
+ message: `The raw patch would produce an invalid survey document${path}: ${firstIssue?.message ?? "schema validation failed"}.`,
2361
+ operationIndex: 0,
2362
+ operationId
2363
+ }));
2364
+ }
2365
+ if (getRawItemsFromSurvey(patchedSurvey).length !== getRawSurveyItems(snapshot).length) warnings.push(createIssue({
2366
+ severity: "warning",
2367
+ code: "raw-patch-changed-item-count",
2368
+ message: "The raw survey patch changes the number of survey items. Confirm that added or removed ids are reflected in the parent group items array and translations.",
2369
+ operationIndex: 0,
2370
+ operationId
2371
+ }));
2372
+ return {
2373
+ valid: issues.length === 0,
2374
+ issues,
2375
+ warnings,
2376
+ normalizedOperations: [operation]
2377
+ };
2378
+ };
2379
+ const buildParentByChildId = (rawItems) => {
2380
+ const parentByChildId = /* @__PURE__ */ new Map();
2381
+ for (const item of rawItems) for (const childId of getGroupChildren(item)) parentByChildId.set(childId, item.id);
2382
+ return parentByChildId;
2383
+ };
2384
+ const collectDescendantIds = (itemId, rawItemsById, descendants = /* @__PURE__ */ new Set()) => {
2385
+ const item = rawItemsById.get(itemId);
2386
+ for (const childId of getGroupChildren(item)) {
2387
+ if (descendants.has(childId)) continue;
2388
+ descendants.add(childId);
2389
+ collectDescendantIds(childId, rawItemsById, descendants);
2390
+ }
2391
+ return descendants;
2392
+ };
2393
+ const normalizeSummary = (summary) => {
2394
+ const trimmed = summary.trim();
2395
+ return trimmed.length > 0 ? trimmed : "Prepared survey change";
2396
+ };
2397
+ const createIssue = ({ severity = "error", ...input }) => ({
2398
+ severity,
2399
+ ...input
2400
+ });
2401
+ const getOperationId = (operation) => operation.id;
2402
+ const isSurveyLevelOperation = (operation) => [
2403
+ "update-survey-metadata",
2404
+ "update-survey-pagination",
2405
+ "update-survey-translation",
2406
+ "remove-survey-locale",
2407
+ "set-image-asset",
2408
+ "update-image-asset-metadata",
2409
+ "remove-image-asset"
2410
+ ].includes(operation.kind);
2411
+ const getOperationTarget = (operation) => {
2412
+ switch (operation.kind) {
2413
+ case "create-item": return operation.target.parentItemId;
2414
+ case "upsert-form-field-group":
2415
+ case "remove-form-field-group":
2416
+ case "reorder-form-field-groups":
2417
+ case "upsert-form-field":
2418
+ case "remove-form-field":
2419
+ case "move-form-field": return operation.target.itemId;
2420
+ case "reorder-items": return operation.parentItemId;
2421
+ case "update-survey-metadata":
2422
+ case "update-survey-pagination":
2423
+ case "update-survey-translation":
2424
+ case "remove-survey-locale":
2425
+ case "set-image-asset":
2426
+ case "update-image-asset-metadata":
2427
+ case "remove-image-asset": return "";
2428
+ default: return operation.itemId;
2429
+ }
2430
+ };
2431
+ const createChoiceOptionLabelContentKey = (optionId) => `options.${optionId}.label`;
2432
+ const KNOWN_CHOICE_OPTION_INPUT_KEYS = new Set([
2433
+ "id",
2434
+ "optionId",
2435
+ "label",
2436
+ "text",
2437
+ "title",
2438
+ "name",
2439
+ "value",
2440
+ "key",
2441
+ "hasDisplayCondition",
2442
+ "embeddedFormItemId",
2443
+ "embeddedForm"
2444
+ ]);
2445
+ const getUnrecognizedChoiceOptionKeys = (option) => Object.keys(option).filter((key) => !KNOWN_CHOICE_OPTION_INPUT_KEYS.has(key));
2446
+ const normalizeChoiceOptionId = (value) => value.trim().replace(/[.\s]+/g, "_").replace(/[^A-Za-z0-9_-]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
2447
+ const normalizeChoiceOptionKey = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
2448
+ const getNextChoiceOptionKey = (usedKeys) => {
2449
+ const usedNumbers = new Set(Array.from(usedKeys).map((key) => normalizeChoiceOptionKey(key).match(/^opt_(\d+)$/)?.[1]).filter((value) => value !== void 0).map((value) => Number.parseInt(value, 10)).filter((value) => Number.isFinite(value)));
2450
+ let nextNumber = 1;
2451
+ while (usedNumbers.has(nextNumber)) nextNumber += 1;
2452
+ return `opt_${nextNumber}`;
2453
+ };
2454
+ const ensureUniqueId = (candidate, usedIds) => {
2455
+ const baseId = (candidate ? normalizeChoiceOptionId(candidate) : "") || generateId();
2456
+ if (!usedIds.has(baseId)) {
2457
+ usedIds.add(baseId);
2458
+ return baseId;
2459
+ }
2460
+ let suffix = 2;
2461
+ while (usedIds.has(`${baseId}_${suffix}`)) suffix += 1;
2462
+ const uniqueId = `${baseId}_${suffix}`;
2463
+ usedIds.add(uniqueId);
2464
+ return uniqueId;
2465
+ };
2466
+ const ensureUniqueChoiceOptionKey = (candidate, usedKeys) => {
2467
+ const normalizedUsedKeys = new Set(Array.from(usedKeys).map((key) => normalizeChoiceOptionKey(key)).filter((key) => key.length > 0));
2468
+ const baseKey = (candidate ? normalizeChoiceOptionKey(candidate) : "") || getNextChoiceOptionKey(usedKeys);
2469
+ if (!usedKeys.has(baseKey) && !normalizedUsedKeys.has(baseKey)) {
2470
+ usedKeys.add(baseKey);
2471
+ return baseKey;
2472
+ }
2473
+ let suffix = 2;
2474
+ while (usedKeys.has(`${baseKey}_${suffix}`) || normalizedUsedKeys.has(`${baseKey}_${suffix}`)) suffix += 1;
2475
+ const uniqueKey = `${baseKey}_${suffix}`;
2476
+ usedKeys.add(uniqueKey);
2477
+ return uniqueKey;
2478
+ };
2479
+ const getChoiceOptionsFromRawItem = (rawItem) => {
2480
+ const rawOptions = (isRecord$2(rawItem.config) ? rawItem.config : void 0)?.options;
2481
+ if (!Array.isArray(rawOptions)) return [];
2482
+ return rawOptions.filter(isRecord$2).map((option) => {
2483
+ const optionId = getTrimmedString(option.id);
2484
+ if (!optionId) return;
2485
+ const optionKey = getTrimmedString(option.key);
2486
+ return {
2487
+ ...option,
2488
+ id: optionId,
2489
+ ...optionKey ? { key: optionKey } : {}
2490
+ };
2491
+ }).filter((option) => Boolean(option));
2492
+ };
2493
+ const withChoiceOptionsOnRawItem = (rawItem, options) => {
2494
+ const config = isRecord$2(rawItem.config) ? rawItem.config : {};
2495
+ return {
2496
+ ...rawItem,
2497
+ config: {
2498
+ id: rawItem.id,
2499
+ maxSelection: 1,
2500
+ shuffleOptions: false,
2501
+ ...config,
2502
+ options
2503
+ }
2504
+ };
2505
+ };
2506
+ const normalizeFormFieldGroupRecord = (value) => {
2507
+ if (!isRecord$2(value)) return void 0;
2508
+ const id = getTrimmedString(value.id);
2509
+ if (!id) return void 0;
2510
+ return {
2511
+ ...value,
2512
+ id,
2513
+ layout: isRecord$2(value.layout) ? value.layout : {
2514
+ base: 1,
2515
+ xs: 1,
2516
+ sm: 1,
2517
+ md: 1,
2518
+ xl: 1
2519
+ },
2520
+ fields: Array.isArray(value.fields) ? value.fields.filter(isRecord$2) : []
2521
+ };
2522
+ };
2523
+ const getFormFieldGroupsFromConfig = (config) => {
2524
+ if (!isRecord$2(config) || !Array.isArray(config.fieldGroups)) return [];
2525
+ return config.fieldGroups.map(normalizeFormFieldGroupRecord).filter((group) => Boolean(group));
2526
+ };
2527
+ const getFormFieldGroupsForTarget = (rawItem, optionId) => {
2528
+ if (!optionId) return rawItem.itemType === FORM_ITEM_TYPE ? getFormFieldGroupsFromConfig(rawItem.config) : void 0;
2529
+ if (rawItem.itemType !== CHOICE_ITEM_TYPE) return void 0;
2530
+ const option = getChoiceOptionsFromRawItem(rawItem).find((candidate) => candidate.id === optionId);
2531
+ return option ? getFormFieldGroupsFromConfig(option.embeddedForm) : void 0;
2532
+ };
2533
+ const withFormFieldGroupsForTarget = (rawItem, fieldGroups, optionId) => {
2534
+ if (!optionId) {
2535
+ if (rawItem.itemType !== FORM_ITEM_TYPE) return void 0;
2536
+ const config = isRecord$2(rawItem.config) ? rawItem.config : {};
2537
+ return {
2538
+ ...rawItem,
2539
+ config: {
2540
+ id: rawItem.id,
2541
+ ...config,
2542
+ fieldGroups
2543
+ }
2544
+ };
2545
+ }
2546
+ if (rawItem.itemType !== CHOICE_ITEM_TYPE) return void 0;
2547
+ const options = getChoiceOptionsFromRawItem(rawItem);
2548
+ const optionIndex = options.findIndex((candidate) => candidate.id === optionId);
2549
+ if (optionIndex < 0) return void 0;
2550
+ const option = options[optionIndex];
2551
+ const embeddedForm = isRecord$2(option.embeddedForm) ? option.embeddedForm : {};
2552
+ options[optionIndex] = {
2553
+ ...option,
2554
+ embeddedForm: {
2555
+ id: optionId,
2556
+ ...embeddedForm,
2557
+ fieldGroups
2558
+ }
2559
+ };
2560
+ return withChoiceOptionsOnRawItem(rawItem, options);
2561
+ };
2562
+ const normalizeFormTranslationKey = (contentKey, optionId) => optionId && !contentKey.startsWith(`embeddedForms.${optionId}.`) ? `embeddedForms.${optionId}.${contentKey}` : contentKey;
2563
+ const normalizeFormOperationTranslations = (operation, optionId) => ({
2564
+ ...operation,
2565
+ translations: operation.translations?.map((translation) => ({
2566
+ ...translation,
2567
+ contentKey: normalizeFormTranslationKey(translation.contentKey.trim(), optionId)
2568
+ }))
2569
+ });
2570
+ const getItemTranslationText = ({ contentKey, itemId, locale, snapshot }) => {
2571
+ const content = new SurveyTranslations(snapshot.survey.translations).getItemTranslations(itemId)?.getContent(locale, contentKey);
2572
+ return content ? getContentPlainText(content).trim() : void 0;
2573
+ };
2574
+ const resolveChoiceOption = ({ itemId, locale, optionRef, options, snapshot }) => {
2575
+ const normalizedRef = optionRef.trim();
2576
+ const normalizedSearchText = normalizedRef.toLowerCase();
2577
+ for (const option of options) if (option.id === normalizedRef || option.key === normalizedRef) return option;
2578
+ const labelMatches = options.filter((option) => {
2579
+ return getItemTranslationText({
2580
+ contentKey: createChoiceOptionLabelContentKey(option.id),
2581
+ itemId,
2582
+ locale,
2583
+ snapshot
2584
+ })?.toLowerCase() === normalizedSearchText;
2585
+ });
2586
+ return labelMatches.length === 1 ? labelMatches[0] : void 0;
2587
+ };
2588
+ const createChoiceOptionsConflictIssue = ({ code, itemId, message, operationId, operationIndex }) => createIssue({
2589
+ code,
2590
+ itemId,
2591
+ message,
2592
+ operationId,
2593
+ operationIndex
2594
+ });
2595
+ const upsertCreateItemTranslation = (translations, nextTranslation) => {
2596
+ const existingIndex = translations.findIndex((translation) => translation.locale === nextTranslation.locale && translation.contentKey === nextTranslation.contentKey);
2597
+ if (existingIndex >= 0) {
2598
+ translations[existingIndex] = nextTranslation;
2599
+ return;
2600
+ }
2601
+ translations.push(nextTranslation);
2602
+ };
2603
+ const normalizeChoiceOptionInput = ({ defaultLocale, option, usedIds }) => {
2604
+ if (typeof option === "string") {
2605
+ const label = option.trim();
2606
+ if (!label) return;
2607
+ const optionId = ensureUniqueId(label, usedIds);
2608
+ const optionKey = normalizeChoiceOptionKey(label);
2609
+ return {
2610
+ labelTranslation: {
2611
+ locale: defaultLocale,
2612
+ contentKey: createChoiceOptionLabelContentKey(optionId),
2613
+ plainText: label
2614
+ },
2615
+ option: {
2616
+ id: optionId,
2617
+ ...optionKey ? { key: optionKey } : {}
2618
+ }
2619
+ };
2620
+ }
2621
+ if (!isRecord$2(option)) return;
2622
+ const label = getFirstStringProperty(option, [
2623
+ "label",
2624
+ "text",
2625
+ "title",
2626
+ "name"
2627
+ ]);
2628
+ const optionId = ensureUniqueId(getFirstStringProperty(option, ["id", "optionId"]) ?? getFirstStringProperty(option, ["value", "key"]) ?? label, usedIds);
2629
+ const keyCandidate = getFirstStringProperty(option, ["key", "value"]) ?? label;
2630
+ const optionKey = keyCandidate ? normalizeChoiceOptionKey(keyCandidate) : void 0;
2631
+ const embeddedFormItemId = getTrimmedString(option.embeddedFormItemId);
2632
+ const unrecognizedKeys = getUnrecognizedChoiceOptionKeys(option);
2633
+ return {
2634
+ labelTranslation: label ? {
2635
+ locale: defaultLocale,
2636
+ contentKey: createChoiceOptionLabelContentKey(optionId),
2637
+ plainText: label
2638
+ } : void 0,
2639
+ option: {
2640
+ id: optionId,
2641
+ ...optionKey ? { key: optionKey } : {},
2642
+ ...option.hasDisplayCondition === true ? { hasDisplayCondition: true } : {},
2643
+ ...embeddedFormItemId ? { embeddedFormItemId } : {},
2644
+ ...isRecord$2(option.embeddedForm) ? { embeddedForm: option.embeddedForm } : {}
2645
+ },
2646
+ ...unrecognizedKeys.length > 0 ? { unrecognizedKeys } : {}
2647
+ };
2648
+ };
2649
+ const normalizeContentKeyForItemType = (contentKey, registryEntry) => {
2650
+ const normalizedContentKey = contentKey.trim();
2651
+ const contentSurfaces = registryEntry?.contentSurfaces;
2652
+ if (!contentSurfaces) return normalizedContentKey;
2653
+ const normalizedLowerCase = normalizedContentKey.toLowerCase();
2654
+ for (const surface of contentSurfaces) if (surface.aliases?.some((alias) => alias.toLowerCase() === normalizedLowerCase)) return surface.key;
2655
+ return contentSurfaces.find((surface) => surface.key.toLowerCase() === normalizedLowerCase)?.key ?? normalizedContentKey;
2656
+ };
2657
+ const normalizeCreateItemTranslationContentKeys = (operation, registryEntry) => ({
2658
+ ...operation,
2659
+ translations: operation.translations?.map((translation) => ({
2660
+ ...translation,
2661
+ contentKey: normalizeContentKeyForItemType(translation.contentKey, registryEntry)
2662
+ }))
2663
+ });
2664
+ const normalizeChoiceItemCreateOperation = (operation, defaultLocale) => {
2665
+ if (operation.item.itemType !== CHOICE_ITEM_TYPE) return {
2666
+ operation,
2667
+ optionWarnings: []
2668
+ };
2669
+ const config = isRecord$2(operation.item.config) ? operation.item.config : {};
2670
+ const rawOptions = Array.isArray(config.options) ? config.options : [];
2671
+ const usedIds = /* @__PURE__ */ new Set();
2672
+ const normalizedOptions = [];
2673
+ const translations = [...operation.translations ?? []];
2674
+ const optionWarnings = [];
2675
+ for (const option of rawOptions) {
2676
+ const normalized = normalizeChoiceOptionInput({
2677
+ defaultLocale,
2678
+ option,
2679
+ usedIds
2680
+ });
2681
+ if (!normalized) continue;
2682
+ normalizedOptions.push(normalized.option);
2683
+ if (normalized.labelTranslation) upsertCreateItemTranslation(translations, normalized.labelTranslation);
2684
+ if (normalized.unrecognizedKeys?.length) optionWarnings.push({
2685
+ optionId: normalized.option.id,
2686
+ unrecognizedKeys: normalized.unrecognizedKeys
2687
+ });
2688
+ }
2689
+ for (const translation of translations) {
2690
+ const optionId = translation.contentKey.match(CHOICE_OPTION_LABEL_CONTENT_KEY_PATTERN)?.[1];
2691
+ const labelText = getTranslationPlainText(translation);
2692
+ if (!optionId || usedIds.has(optionId) || !labelText) continue;
2693
+ usedIds.add(optionId);
2694
+ normalizedOptions.push({
2695
+ id: optionId,
2696
+ key: normalizeChoiceOptionKey(labelText)
2697
+ });
2698
+ }
2699
+ return {
2700
+ operation: {
2701
+ ...operation,
2702
+ item: {
2703
+ ...operation.item,
2704
+ config: {
2705
+ ...config,
2706
+ options: normalizedOptions
2707
+ }
2708
+ },
2709
+ translations
2710
+ },
2711
+ optionWarnings
2712
+ };
2713
+ };
2714
+ const hasRequiredCreateContent = (operation, registryEntry) => {
2715
+ const requiredContentSurfaces = registryEntry?.contentSurfaces.filter((surface) => surface.requiredOnCreate) ?? [];
2716
+ if (requiredContentSurfaces.length === 0) return true;
2717
+ return requiredContentSurfaces.every((surface) => (operation.translations ?? []).some((translation) => translation.contentKey === surface.key && !isTranslationContentEmpty(translation)));
2718
+ };
2719
+ const getChoiceCreateOptionIds = (operation) => {
2720
+ if (operation.item.itemType !== CHOICE_ITEM_TYPE || !isRecord$2(operation.item.config)) return [];
2721
+ const options = operation.item.config.options;
2722
+ if (!Array.isArray(options)) return [];
2723
+ return options.map((option) => isRecord$2(option) ? getTrimmedString(option.id) : void 0).filter((optionId) => Boolean(optionId));
2724
+ };
2725
+ const getChoiceCreateContentIssue = ({ operation, operationId, operationIndex, itemId }) => {
2726
+ if (operation.item.itemType !== CHOICE_ITEM_TYPE) return;
2727
+ const optionIds = getChoiceCreateOptionIds(operation);
2728
+ if (optionIds.length === 0) return createIssue({
2729
+ code: "missing-choice-options",
2730
+ message: "New choice items need response options. Add item.config.options entries with labels, for example { \"label\": \"Fuji\" }.",
2731
+ operationIndex,
2732
+ operationId,
2733
+ itemId
2734
+ });
2735
+ const missingLabelOptionIds = optionIds.filter((optionId) => !(operation.translations ?? []).some((translation) => translation.contentKey === createChoiceOptionLabelContentKey(optionId) && getTranslationPlainText(translation).length > 0));
2736
+ if (missingLabelOptionIds.length === 0) return;
2737
+ return createIssue({
2738
+ code: "missing-choice-option-labels",
2739
+ message: `New choice item options need visible labels. Missing labels for option id(s): ${missingLabelOptionIds.join(", ")}.`,
2740
+ operationIndex,
2741
+ operationId,
2742
+ itemId
2743
+ });
2744
+ };
2745
+ const getRawFieldIds = (fieldGroups) => {
2746
+ if (!Array.isArray(fieldGroups)) return [];
2747
+ const fieldIds = [];
2748
+ for (const group of fieldGroups) {
2749
+ if (!isRecord$2(group) || !Array.isArray(group.fields)) continue;
2750
+ for (const field of group.fields) {
2751
+ const fieldId = isRecord$2(field) ? getTrimmedString(field.id) : void 0;
2752
+ if (fieldId) fieldIds.push(fieldId);
2753
+ }
2754
+ }
2755
+ return fieldIds;
2756
+ };
2757
+ const getRawFields = (fieldGroups) => {
2758
+ if (!Array.isArray(fieldGroups)) return [];
2759
+ const fields = [];
2760
+ for (const group of fieldGroups) {
2761
+ if (!isRecord$2(group) || !Array.isArray(group.fields)) continue;
2762
+ for (const field of group.fields) if (isRecord$2(field)) fields.push(field);
2763
+ }
2764
+ return fields;
2765
+ };
2766
+ const getMissingSelectOptionLabelContentKeys = ({ fieldGroups, prefix, translations }) => {
2767
+ const missingContentKeys = [];
2768
+ for (const field of getRawFields(fieldGroups)) {
2769
+ const fieldId = getTrimmedString(field.id);
2770
+ const controller = isRecord$2(field.controller) ? field.controller : void 0;
2771
+ if (!fieldId || field.type !== "select" || controller?.type !== "select") continue;
2772
+ const options = Array.isArray(controller.options) ? controller.options : [];
2773
+ for (const option of options) {
2774
+ const optionId = isRecord$2(option) ? getTrimmedString(option.id) : void 0;
2775
+ if (!optionId) continue;
2776
+ const contentKey = `${prefix}.${fieldId}.options.${optionId}.label`;
2777
+ if (!hasNonEmptyCreateTranslation(translations, contentKey)) missingContentKeys.push(contentKey);
2778
+ }
2779
+ }
2780
+ return missingContentKeys;
2781
+ };
2782
+ /**
2783
+ * A `fieldGroups` array only counts as "usable" if at least one group actually
2784
+ * carries a non-empty `fields` array. An empty/missing `fieldGroups` (or one
2785
+ * full of empty groups) renders nothing, even though it looks superficially
2786
+ * valid.
2787
+ */
2788
+ const hasUsableFieldGroups = (fieldGroups) => {
2789
+ if (!Array.isArray(fieldGroups) || fieldGroups.length === 0) return false;
2790
+ return fieldGroups.some((group) => isRecord$2(group) && Array.isArray(group.fields) && group.fields.length > 0);
2791
+ };
2792
+ /**
2793
+ * Shallow recursive search (bounded depth) for a stray `fields` array anywhere
2794
+ * inside a malformed form/embeddedForm record, so the validation message can
2795
+ * point at exactly where the model misplaced it (e.g. wrapped it in a spurious
2796
+ * `itemType`/`config` envelope instead of putting `fieldGroups` at the top
2797
+ * level of the record).
2798
+ */
2799
+ const findNestedFieldsArrayPath = (value, path, depth) => {
2800
+ if (depth > 3 || !isRecord$2(value)) return;
2801
+ if (Array.isArray(value.fields) && value.fields.length > 0) return path ? `${path}.fields` : "fields";
2802
+ for (const [key, nested] of Object.entries(value)) {
2803
+ if (key === "fields" || !isRecord$2(nested)) continue;
2804
+ const found = findNestedFieldsArrayPath(nested, path ? `${path}.${key}` : key, depth + 1);
2805
+ if (found) return found;
2806
+ }
2807
+ };
2808
+ /**
2809
+ * Detects any shape where a form's fields are not usable because they are not
2810
+ * nested inside a `fieldGroups` array directly on this record — including the
2811
+ * near-miss of a top-level `fields` array, AND shapes that wrap everything in
2812
+ * a spurious envelope like `{ "itemType": "formItem", "config": { "fields": [...] } }`
2813
+ * (a real model failure mode: it invents an `itemType`/`config` wrapper around
2814
+ * the embedded form instead of using the flat `{ "id", "fieldGroups" }` shape).
2815
+ * Since `fieldGroups` is not schema-validated (config is `z.unknown()`), any of
2816
+ * these mistakes is otherwise silently dropped and results in no visible fields.
2817
+ */
2818
+ const getMissingFieldGroupsWrapperHint = (record) => {
2819
+ if (hasUsableFieldGroups(record.fieldGroups)) return;
2820
+ const nestedFieldsPath = findNestedFieldsArrayPath(record, "", 0);
2821
+ if (nestedFieldsPath) return `found a "${nestedFieldsPath}" array, but fields must be nested inside a top-level "fieldGroups" array on this exact object, for example { "id": "...", "fieldGroups": [{ "id": "main", "fields": [...] }] } — do NOT wrap this in an "itemType"/"config" envelope; the shape is flat`;
2822
+ return "no \"fieldGroups\" array with any fields was found; the shape must be { \"id\": \"...\", \"fieldGroups\": [{ \"id\": \"main\", \"fields\": [...] }] }";
2823
+ };
2824
+ const hasNonEmptyCreateTranslation = (translations, contentKey) => (translations ?? []).some((translation) => translation.contentKey === contentKey && getTranslationPlainText(translation).length > 0);
2825
+ const getFormCreateFieldLabelIssue = ({ operation, operationId, operationIndex, itemId }) => {
2826
+ if (operation.item.itemType !== FORM_ITEM_TYPE || !isRecord$2(operation.item.config)) return;
2827
+ const wrapperHint = getMissingFieldGroupsWrapperHint(operation.item.config);
2828
+ if (wrapperHint) return createIssue({
2829
+ code: "missing-field-groups-wrapper",
2830
+ message: `New form item has no usable fields: ${wrapperHint}.`,
2831
+ operationIndex,
2832
+ operationId,
2833
+ itemId
2834
+ });
2835
+ const missingLabelFieldIds = getRawFieldIds(operation.item.config.fieldGroups).filter((fieldId) => !hasNonEmptyCreateTranslation(operation.translations, `fields.${fieldId}.label`));
2836
+ const missingSelectOptionLabelContentKeys = getMissingSelectOptionLabelContentKeys({
2837
+ fieldGroups: operation.item.config.fieldGroups,
2838
+ prefix: "fields",
2839
+ translations: operation.translations
2840
+ });
2841
+ if (missingSelectOptionLabelContentKeys.length > 0) return createIssue({
2842
+ code: "missing-form-select-option-labels",
2843
+ message: `New form item select options need visible labels. Add translations for: ` + missingSelectOptionLabelContentKeys.join(", ") + ".",
2844
+ operationIndex,
2845
+ operationId,
2846
+ itemId
2847
+ });
2848
+ if (missingLabelFieldIds.length === 0) return;
2849
+ return createIssue({
2850
+ code: "missing-form-field-labels",
2851
+ message: `New form item fields need visible labels. Add a translation with contentKey "fields.<fieldId>.label" for field id(s): ${missingLabelFieldIds.join(", ")}.`,
2852
+ operationIndex,
2853
+ operationId,
2854
+ itemId
2855
+ });
2856
+ };
2857
+ const getChoiceEmbeddedFormFieldLabelIssue = ({ operation, operationId, operationIndex, itemId }) => {
2858
+ if (operation.item.itemType !== CHOICE_ITEM_TYPE || !isRecord$2(operation.item.config)) return;
2859
+ const options = operation.item.config.options;
2860
+ if (!Array.isArray(options)) return;
2861
+ const missingLabelContentKeys = [];
2862
+ for (const option of options) {
2863
+ if (!isRecord$2(option) || !isRecord$2(option.embeddedForm)) continue;
2864
+ const optionId = getTrimmedString(option.id);
2865
+ if (!optionId) continue;
2866
+ const wrapperHint = getMissingFieldGroupsWrapperHint(option.embeddedForm);
2867
+ if (wrapperHint) return createIssue({
2868
+ code: "missing-field-groups-wrapper",
2869
+ message: `Embedded form on option "${optionId}" has no usable fields: ${wrapperHint}.`,
2870
+ operationIndex,
2871
+ operationId,
2872
+ itemId
2873
+ });
2874
+ for (const fieldId of getRawFieldIds(option.embeddedForm.fieldGroups)) {
2875
+ const contentKey = `embeddedForms.${optionId}.fields.${fieldId}.label`;
2876
+ if (!hasNonEmptyCreateTranslation(operation.translations, contentKey)) missingLabelContentKeys.push(contentKey);
2877
+ }
2878
+ missingLabelContentKeys.push(...getMissingSelectOptionLabelContentKeys({
2879
+ fieldGroups: option.embeddedForm.fieldGroups,
2880
+ prefix: `embeddedForms.${optionId}.fields`,
2881
+ translations: operation.translations
2882
+ }));
2883
+ }
2884
+ if (missingLabelContentKeys.length === 0) return;
2885
+ return createIssue({
2886
+ code: "missing-embedded-form-field-labels",
2887
+ message: `New embedded form fields and select options need visible labels, otherwise respondents only see raw keys. Add translations for: ${missingLabelContentKeys.join(", ")}.`,
2888
+ operationIndex,
2889
+ operationId,
2890
+ itemId
2891
+ });
2892
+ };
2893
+ const resolveRawItemByIdentifier = ({ identifier, indexById, outlineById, rawItemsById }) => {
2894
+ const direct = rawItemsById.get(identifier);
2895
+ if (direct) return {
2896
+ itemId: identifier,
2897
+ rawItem: direct
2898
+ };
2899
+ const resolved = resolveItemSummary(identifier, indexById, outlineById);
2900
+ if (!resolved) return;
2901
+ const rawItem = rawItemsById.get(resolved.itemId);
2902
+ return rawItem ? {
2903
+ itemId: resolved.itemId,
2904
+ rawItem
2905
+ } : void 0;
2906
+ };
2907
+ const resolveCreateParentByIdentifier = ({ identifier, indexById, outlineById, rawItemsById }) => {
2908
+ if (identifier.trim().length > 0) return resolveRawItemByIdentifier({
2909
+ identifier,
2910
+ indexById,
2911
+ outlineById,
2912
+ rawItemsById
2913
+ });
2914
+ const rootItems = Array.from(rawItemsById.entries()).filter(([, rawItem]) => rawItem.itemType === ReservedSurveyItemTypes.Group && isRootItem(rawItem));
2915
+ if (rootItems.length !== 1) return;
2916
+ const [itemId, rawItem] = rootItems[0];
2917
+ return {
2918
+ itemId,
2919
+ rawItem
2920
+ };
2921
+ };
2922
+ const normalizeOperation = (operation, resolvedItemId) => {
2923
+ switch (operation.kind) {
2924
+ case "create-item": {
2925
+ const nestedTranslations = getCreateItemNestedTranslations(operation.item);
2926
+ const topLevelTranslations = operation.translations ?? [];
2927
+ const translations = topLevelTranslations.length > 0 ? topLevelTranslations : nestedTranslations;
2928
+ const item = { ...operation.item };
2929
+ delete item.translations;
2930
+ return {
2931
+ ...operation,
2932
+ target: {
2933
+ ...operation.target,
2934
+ parentItemId: operation.target.parentItemId.trim()
2935
+ },
2936
+ item: {
2937
+ ...item,
2938
+ id: operation.item.id?.trim(),
2939
+ key: operation.item.key.trim(),
2940
+ itemType: operation.item.itemType.trim()
2941
+ },
2942
+ translations: translations.map((translation) => ({
2943
+ ...translation,
2944
+ locale: translation.locale.trim(),
2945
+ contentKey: translation.contentKey.trim()
2946
+ }))
2947
+ };
2948
+ }
2949
+ case "update-item-key": return {
2950
+ ...operation,
2951
+ itemId: resolvedItemId,
2952
+ key: operation.key.trim()
2953
+ };
2954
+ case "update-item-label": return {
2955
+ ...operation,
2956
+ itemId: resolvedItemId,
2957
+ itemLabel: operation.itemLabel.trim()
2958
+ };
2959
+ case "update-item-color": return {
2960
+ ...operation,
2961
+ itemId: resolvedItemId
2962
+ };
2963
+ case "update-item-translation": return {
2964
+ ...operation,
2965
+ itemId: resolvedItemId,
2966
+ locale: operation.locale.trim(),
2967
+ contentKey: operation.contentKey.trim()
2968
+ };
2969
+ case "update-placed-image": return {
2970
+ ...operation,
2971
+ itemId: resolvedItemId,
2972
+ locale: operation.locale.trim(),
2973
+ contentKey: operation.contentKey.trim(),
2974
+ expectedAssetId: operation.expectedAssetId?.trim(),
2975
+ changes: {
2976
+ ...operation.changes,
2977
+ source: operation.changes.source?.type === "asset" ? {
2978
+ type: "asset",
2979
+ assetId: operation.changes.source.assetId.trim()
2980
+ } : operation.changes.source?.type === "external" ? {
2981
+ type: "external",
2982
+ url: operation.changes.source.url.trim()
2983
+ } : void 0
2984
+ }
2985
+ };
2986
+ case "patch-item-config": return {
2987
+ ...operation,
2988
+ itemId: resolvedItemId
2989
+ };
2990
+ case "upsert-form-field-group":
2991
+ case "remove-form-field-group":
2992
+ case "reorder-form-field-groups":
2993
+ case "upsert-form-field":
2994
+ case "remove-form-field":
2995
+ case "move-form-field": return {
2996
+ ...operation,
2997
+ target: {
2998
+ ...operation.target,
2999
+ itemId: resolvedItemId,
3000
+ optionId: operation.target.optionId?.trim()
3001
+ }
3002
+ };
3003
+ case "set-embedded-form":
3004
+ case "remove-embedded-form": return {
3005
+ ...operation,
3006
+ itemId: resolvedItemId,
3007
+ optionId: operation.optionId.trim()
3008
+ };
3009
+ case "add-choice-option": return {
3010
+ ...operation,
3011
+ itemId: resolvedItemId,
3012
+ locale: operation.locale?.trim(),
3013
+ option: {
3014
+ ...operation.option,
3015
+ id: operation.option.id?.trim(),
3016
+ key: operation.option.key?.trim(),
3017
+ label: operation.option.label.trim()
3018
+ }
3019
+ };
3020
+ case "update-choice-option-label": return {
3021
+ ...operation,
3022
+ itemId: resolvedItemId,
3023
+ optionId: operation.optionId.trim(),
3024
+ locale: operation.locale?.trim(),
3025
+ label: operation.label.trim()
3026
+ };
3027
+ case "remove-choice-option": return {
3028
+ ...operation,
3029
+ itemId: resolvedItemId,
3030
+ optionId: operation.optionId.trim()
3031
+ };
3032
+ case "reorder-choice-options": return {
3033
+ ...operation,
3034
+ itemId: resolvedItemId,
3035
+ optionIds: operation.optionIds.map((optionId) => optionId.trim())
3036
+ };
3037
+ case "remove-item": return {
3038
+ ...operation,
3039
+ itemId: resolvedItemId,
3040
+ includeChildren: operation.includeChildren ?? false
3041
+ };
3042
+ case "move-item": return {
3043
+ ...operation,
3044
+ itemId: resolvedItemId,
3045
+ target: {
3046
+ ...operation.target,
3047
+ parentItemId: operation.target.parentItemId.trim()
3048
+ }
3049
+ };
3050
+ case "reorder-items": return {
3051
+ ...operation,
3052
+ parentItemId: resolvedItemId,
3053
+ itemIds: operation.itemIds.map((itemId) => itemId.trim())
3054
+ };
3055
+ case "update-survey-metadata":
3056
+ case "update-survey-pagination":
3057
+ case "set-image-asset":
3058
+ case "update-image-asset-metadata":
3059
+ case "remove-image-asset": return operation;
3060
+ case "update-survey-translation": return {
3061
+ ...operation,
3062
+ locale: operation.locale.trim(),
3063
+ contentKey: operation.contentKey.trim()
3064
+ };
3065
+ case "remove-survey-locale": return {
3066
+ ...operation,
3067
+ locale: operation.locale.trim()
3068
+ };
3069
+ case "survey-json-patch": return operation;
3070
+ }
3071
+ };
3072
+ const createEmptyValidationResult = (issue, operations) => ({
3073
+ valid: false,
3074
+ issues: [issue],
3075
+ warnings: [],
3076
+ normalizedOperations: operations
3077
+ });
3078
+ const getSiblingKeyConflict = ({ createdKeysByParentId, deletedItemIds, itemId, key, parentByChildId, plannedKeys, rawItemsById }) => {
3079
+ const parentId = parentByChildId.get(itemId);
3080
+ if (!parentId) return;
3081
+ const createdKeys = createdKeysByParentId.get(parentId);
3082
+ for (const [createdItemId, createdKey] of createdKeys ?? []) if (createdItemId !== itemId && createdKey === key) return createdItemId;
3083
+ for (const siblingId of getGroupChildren(rawItemsById.get(parentId))) {
3084
+ if (siblingId === itemId || deletedItemIds.has(siblingId)) continue;
3085
+ if ((plannedKeys.get(siblingId) ?? rawItemsById.get(siblingId)?.key) === key) return siblingId;
3086
+ }
3087
+ };
3088
+ const getKeyConflictInParent = ({ createdKeysByParentId, deletedItemIds, key, parentId, rawItemsById }) => {
3089
+ const createdKeys = createdKeysByParentId.get(parentId);
3090
+ for (const [createdItemId, createdKey] of createdKeys ?? []) if (createdKey === key) return createdItemId;
3091
+ for (const siblingId of getGroupChildren(rawItemsById.get(parentId))) {
3092
+ if (deletedItemIds.has(siblingId)) continue;
3093
+ if (rawItemsById.get(siblingId)?.key === key) return siblingId;
3094
+ }
3095
+ };
3096
+ const getPlannedCreateCountForParent = (parentId, createdKeysByParentId) => createdKeysByParentId.get(parentId)?.size ?? 0;
3097
+ const getDeletedAncestor = (itemId, parentByChildId, deletedItemIds) => {
3098
+ let currentParentId = parentByChildId.get(itemId);
3099
+ while (currentParentId) {
3100
+ if (deletedItemIds.has(currentParentId)) return currentParentId;
3101
+ currentParentId = parentByChildId.get(currentParentId);
3102
+ }
3103
+ };
3104
+ const buildSurveyDocumentVersion = (snapshot) => ({ ...snapshot.version });
3105
+ const resolveEngineContext = (snapshot) => "document" in snapshot ? snapshot : createSurveyAssistantEngineContext(snapshot);
3106
+ const validateSurveyOperations = ({ operations, snapshot: snapshotInput }) => {
3107
+ if (!snapshotInput) return createEmptyValidationResult(createIssue({
3108
+ code: "missing-snapshot",
3109
+ message: "No live editor snapshot was provided for this assistant turn."
3110
+ }), operations);
3111
+ const snapshot = resolveEngineContext(snapshotInput);
3112
+ if (operations.length === 0) return createEmptyValidationResult(createIssue({
3113
+ code: "empty-operations",
3114
+ message: "No survey change operations were provided."
3115
+ }), operations);
3116
+ if (operations.length > 200) return createEmptyValidationResult(createIssue({
3117
+ code: "too-many-operations",
3118
+ message: `At most 200 operations can be proposed at once.`
3119
+ }), operations);
3120
+ const surveyJsonPatchOperations = operations.filter(isSurveyJsonPatchOperation);
3121
+ if (surveyJsonPatchOperations.length > 0) {
3122
+ if (operations.length > 1 || surveyJsonPatchOperations.length > 1) return createEmptyValidationResult(createIssue({
3123
+ code: "raw-patch-must-be-single-operation",
3124
+ message: "A survey-json-patch operation must be the only operation in a proposal. Put all raw edits in that operation's patches array.",
3125
+ operationIndex: operations.findIndex(isSurveyJsonPatchOperation),
3126
+ operationId: surveyJsonPatchOperations[0]?.id
3127
+ }), operations);
3128
+ return validateSurveyJsonPatchOperation({
3129
+ operation: surveyJsonPatchOperations[0],
3130
+ snapshot
3131
+ });
3132
+ }
3133
+ const rawItems = getRawSurveyItems(snapshot);
3134
+ const rawItemsById = mapRawItemsById(snapshot);
3135
+ const parentByChildId = buildParentByChildId(rawItems);
3136
+ const outlineById = mapOutlineById(snapshot);
3137
+ const indexById = mapKeyIndexById(snapshot);
3138
+ const itemTypeManifestByType = new Map(snapshot.capabilities.itemTypes.map((entry) => [entry.itemType, entry]));
3139
+ const deterministicCreateItemTypes = new Set(snapshot.capabilities.itemTypes.filter((entry) => entry.create === "deterministic").map((entry) => entry.itemType));
3140
+ const issues = [];
3141
+ const warnings = [];
3142
+ const normalizedOperations = [];
3143
+ const createdKeysByParentId = /* @__PURE__ */ new Map();
3144
+ const plannedKeys = /* @__PURE__ */ new Map();
3145
+ const deletedItemIds = /* @__PURE__ */ new Set();
3146
+ const introducedLocaleWarnings = /* @__PURE__ */ new Set();
3147
+ const addIntroducedLocaleWarning = ({ itemId, locale, operationId, operationIndex }) => {
3148
+ if (snapshot.inspection.locales.includes(locale)) return;
3149
+ const warningKey = `${operationIndex}:${itemId ?? ""}:${locale}`;
3150
+ if (introducedLocaleWarnings.has(warningKey)) return;
3151
+ introducedLocaleWarnings.add(warningKey);
3152
+ warnings.push(createIssue({
3153
+ severity: "warning",
3154
+ code: "introduced-locale",
3155
+ message: `Locale "${locale}" is not currently present and will be introduced by this translation change.`,
3156
+ operationIndex,
3157
+ operationId,
3158
+ itemId
3159
+ }));
3160
+ };
3161
+ operations.forEach((operation, operationIndex) => {
3162
+ const operationId = getOperationId(operation);
3163
+ if (operation.kind === "create-item") {
3164
+ const baseNormalized = normalizeOperation(operation, "");
3165
+ const registryEntry = itemTypeManifestByType.get(baseNormalized.item.itemType);
3166
+ const { operation: normalized, optionWarnings } = normalizeChoiceItemCreateOperation(normalizeCreateItemTranslationContentKeys(baseNormalized, registryEntry), snapshot.ui.selectedLocale);
3167
+ for (const optionWarning of optionWarnings) issues.push(createIssue({
3168
+ code: "unrecognized-choice-option-keys",
3169
+ message: `Option "${optionWarning.optionId}" has unrecognized field(s): ${optionWarning.unrecognizedKeys.join(", ")}. Recognized option fields are id, key, label, hasDisplayCondition, embeddedFormItemId, and embeddedForm (singular). Put respondent labels in translations at options.<optionId>.label; do not use provider-specific option metadata.`,
3170
+ operationIndex,
3171
+ operationId
3172
+ }));
3173
+ const parent = resolveCreateParentByIdentifier({
3174
+ identifier: normalized.target.parentItemId,
3175
+ indexById,
3176
+ outlineById,
3177
+ rawItemsById
3178
+ });
3179
+ if (!parent) {
3180
+ issues.push(createIssue({
3181
+ code: "target-parent-not-found",
3182
+ message: normalized.target.parentItemId.length > 0 ? `No parent group matching "${normalized.target.parentItemId}" was found.` : "No root group parent could be inferred for this create-item operation.",
3183
+ operationIndex,
3184
+ operationId
3185
+ }));
3186
+ return;
3187
+ }
3188
+ if (parent.rawItem.itemType !== ReservedSurveyItemTypes.Group) {
3189
+ issues.push(createIssue({
3190
+ code: "target-parent-not-group",
3191
+ message: `The target parent "${parent.itemId}" is not a group item.`,
3192
+ operationIndex,
3193
+ operationId,
3194
+ itemId: parent.itemId
3195
+ }));
3196
+ return;
3197
+ }
3198
+ const deletedParentAncestorId = getDeletedAncestor(parent.itemId, parentByChildId, deletedItemIds);
3199
+ if (deletedItemIds.has(parent.itemId) || deletedParentAncestorId) {
3200
+ issues.push(createIssue({
3201
+ code: "target-parent-already-removed",
3202
+ message: "The target parent is removed earlier in this proposal.",
3203
+ operationIndex,
3204
+ operationId,
3205
+ itemId: parent.itemId
3206
+ }));
3207
+ return;
3208
+ }
3209
+ const itemId = normalized.item.id && normalized.item.id.length > 0 ? normalized.item.id : generateId();
3210
+ if (rawItemsById.has(itemId)) {
3211
+ issues.push(createIssue({
3212
+ code: "duplicate-item-id",
3213
+ message: `The new item id "${itemId}" already exists in the survey or this proposal.`,
3214
+ operationIndex,
3215
+ operationId,
3216
+ itemId
3217
+ }));
3218
+ return;
3219
+ }
3220
+ if (!ITEM_KEY_PATTERN.test(normalized.item.key)) {
3221
+ issues.push(createIssue({
3222
+ code: "invalid-item-key",
3223
+ message: "Item keys must contain only letters, numbers, underscores, or hyphens.",
3224
+ operationIndex,
3225
+ operationId,
3226
+ itemId
3227
+ }));
3228
+ return;
3229
+ }
3230
+ if (normalized.item.itemType === "root" || isRecord$2(normalized.item.config) && normalized.item.config.isRoot === true) {
3231
+ issues.push(createIssue({
3232
+ code: "cannot-create-root",
3233
+ message: "The assistant cannot create a second survey root item.",
3234
+ operationIndex,
3235
+ operationId,
3236
+ itemId
3237
+ }));
3238
+ return;
3239
+ }
3240
+ if (normalized.item.itemType === ReservedSurveyItemTypes.Group && getGroupChildren(normalized.item).length > 0) {
3241
+ issues.push(createIssue({
3242
+ code: "create-group-with-children",
3243
+ message: "Create group items without embedded child ids; create children as separate operations.",
3244
+ operationIndex,
3245
+ operationId,
3246
+ itemId
3247
+ }));
3248
+ return;
3249
+ }
3250
+ const conflictId = getKeyConflictInParent({
3251
+ createdKeysByParentId,
3252
+ deletedItemIds,
3253
+ key: normalized.item.key,
3254
+ parentId: parent.itemId,
3255
+ rawItemsById
3256
+ });
3257
+ if (conflictId) {
3258
+ issues.push(createIssue({
3259
+ code: "duplicate-sibling-key",
3260
+ message: `The key "${normalized.item.key}" is already used by sibling item "${conflictId}".`,
3261
+ operationIndex,
3262
+ operationId,
3263
+ itemId
3264
+ }));
3265
+ return;
3266
+ }
3267
+ if (normalized.target.index !== void 0) {
3268
+ const maximumUsefulIndex = getGroupChildren(parent.rawItem).length + getPlannedCreateCountForParent(parent.itemId, createdKeysByParentId);
3269
+ if (normalized.target.index > maximumUsefulIndex) warnings.push(createIssue({
3270
+ severity: "warning",
3271
+ code: "target-index-after-end",
3272
+ message: `The target index is after the end of the parent group and will be clamped by the editor apply path.`,
3273
+ operationIndex,
3274
+ operationId,
3275
+ itemId
3276
+ }));
3277
+ }
3278
+ if (!deterministicCreateItemTypes.has(normalized.item.itemType)) issues.push(createIssue({
3279
+ code: "item-type-not-deterministically-creatable",
3280
+ message: registryEntry ? registryEntry.create === "interactive-only" ? `Item type "${normalized.item.itemType}" requires an interactive editor flow and cannot be created silently.` : `Item type "${normalized.item.itemType}" is available in the editor registry but does not support deterministic creation.` : `Item type "${normalized.item.itemType}" is not available in the current editor registry.`,
3281
+ operationIndex,
3282
+ operationId,
3283
+ itemId
3284
+ }));
3285
+ if (!hasRequiredCreateContent(normalized, registryEntry)) issues.push(createIssue({
3286
+ code: "missing-create-content",
3287
+ message: registryEntry?.contentSurfaces.find((surface) => surface.requiredOnCreate)?.key ? `New "${normalized.item.itemType}" items need visible content. Add a translation for "${registryEntry.contentSurfaces.find((surface) => surface.requiredOnCreate)?.key}".` : `New "${normalized.item.itemType}" items need visible content.`,
3288
+ operationIndex,
3289
+ operationId,
3290
+ itemId
3291
+ }));
3292
+ const choiceContentIssue = getChoiceCreateContentIssue({
3293
+ operation: normalized,
3294
+ operationId,
3295
+ operationIndex,
3296
+ itemId
3297
+ });
3298
+ if (choiceContentIssue) issues.push(choiceContentIssue);
3299
+ const embeddedFormFieldLabelIssue = getChoiceEmbeddedFormFieldLabelIssue({
3300
+ operation: normalized,
3301
+ operationId,
3302
+ operationIndex,
3303
+ itemId
3304
+ });
3305
+ if (embeddedFormFieldLabelIssue) issues.push(embeddedFormFieldLabelIssue);
3306
+ const formFieldLabelIssue = getFormCreateFieldLabelIssue({
3307
+ operation: normalized,
3308
+ operationId,
3309
+ operationIndex,
3310
+ itemId
3311
+ });
3312
+ if (formFieldLabelIssue) issues.push(formFieldLabelIssue);
3313
+ for (const translation of normalized.translations ?? []) {
3314
+ addIntroducedLocaleWarning({
3315
+ locale: translation.locale,
3316
+ operationIndex,
3317
+ operationId,
3318
+ itemId
3319
+ });
3320
+ if (translation.contentKey.includes("..")) issues.push(createIssue({
3321
+ code: "invalid-content-key",
3322
+ message: "Translation content keys cannot contain empty path segments.",
3323
+ operationIndex,
3324
+ operationId,
3325
+ itemId
3326
+ }));
3327
+ }
3328
+ let normalizedItem = {
3329
+ ...normalized.item,
3330
+ id: itemId
3331
+ };
3332
+ const trustedCapability = snapshot.trustedCapabilities?.byItemType.get(normalizedItem.itemType);
3333
+ if (trustedCapability?.normalizeCreate) normalizedItem = trustedCapability.normalizeCreate(normalizedItem);
3334
+ if (normalizedItem.id !== itemId || normalizedItem.itemType !== normalized.item.itemType || normalizedItem.key !== normalized.item.key) issues.push(createIssue({
3335
+ code: "invalid-capability-normalization",
3336
+ message: "The trusted item capability changed the new item's identity during normalization.",
3337
+ operationIndex,
3338
+ operationId,
3339
+ itemId
3340
+ }));
3341
+ for (const issue of trustedCapability?.validateItem?.(normalizedItem) ?? []) {
3342
+ const normalizedIssue = {
3343
+ ...issue,
3344
+ operationIndex: issue.operationIndex ?? operationIndex,
3345
+ operationId: issue.operationId ?? operationId,
3346
+ itemId: issue.itemId ?? itemId
3347
+ };
3348
+ (normalizedIssue.severity === "warning" ? warnings : issues).push(normalizedIssue);
3349
+ }
3350
+ validateCreateItemExpressions({
3351
+ issues,
3352
+ item: normalizedItem,
3353
+ operationId,
3354
+ operationIndex,
3355
+ responseSlotTypes: buildResponseSlotTypesForDraft({
3356
+ currentItem: normalizedItem,
3357
+ rawItemsById,
3358
+ snapshot
3359
+ })
3360
+ });
3361
+ if (issues.some((issue) => issue.operationIndex === operationIndex)) return;
3362
+ const createdKeys = createdKeysByParentId.get(parent.itemId) ?? /* @__PURE__ */ new Map();
3363
+ createdKeys.set(itemId, normalizedItem.key);
3364
+ createdKeysByParentId.set(parent.itemId, createdKeys);
3365
+ rawItemsById.set(itemId, normalizedItem);
3366
+ parentByChildId.set(itemId, parent.itemId);
3367
+ normalizedOperations.push({
3368
+ ...normalized,
3369
+ target: {
3370
+ ...normalized.target,
3371
+ parentItemId: parent.itemId
3372
+ },
3373
+ item: normalizedItem
3374
+ });
3375
+ return;
3376
+ }
3377
+ if (operation.kind === "survey-json-patch") return;
3378
+ if (isSurveyLevelOperation(operation)) {
3379
+ const normalized = normalizeOperation(operation, "");
3380
+ switch (normalized.kind) {
3381
+ case "update-survey-metadata":
3382
+ if (Object.keys(normalized.values).length === 0) {
3383
+ issues.push(createIssue({
3384
+ code: "empty-survey-metadata-update",
3385
+ message: "Provide at least one survey metadata key to set or remove.",
3386
+ operationIndex,
3387
+ operationId
3388
+ }));
3389
+ return;
3390
+ }
3391
+ break;
3392
+ case "update-survey-translation":
3393
+ addIntroducedLocaleWarning({
3394
+ locale: normalized.locale,
3395
+ operationIndex,
3396
+ operationId
3397
+ });
3398
+ if (normalized.contentKey.includes("..")) {
3399
+ issues.push(createIssue({
3400
+ code: "invalid-content-key",
3401
+ message: "Translation content keys cannot contain empty path segments.",
3402
+ operationIndex,
3403
+ operationId
3404
+ }));
3405
+ return;
3406
+ }
3407
+ break;
3408
+ case "remove-survey-locale":
3409
+ if (!snapshot.inspection.locales.includes(normalized.locale)) {
3410
+ issues.push(createIssue({
3411
+ code: "survey-locale-not-found",
3412
+ message: `Locale "${normalized.locale}" is not present in the survey.`,
3413
+ operationIndex,
3414
+ operationId
3415
+ }));
3416
+ return;
3417
+ }
3418
+ if (snapshot.inspection.locales.length <= 1) {
3419
+ issues.push(createIssue({
3420
+ code: "cannot-remove-last-survey-locale",
3421
+ message: "The survey's final locale cannot be removed.",
3422
+ operationIndex,
3423
+ operationId
3424
+ }));
3425
+ return;
3426
+ }
3427
+ break;
3428
+ case "update-image-asset-metadata":
3429
+ case "remove-image-asset":
3430
+ if (!snapshot.survey.assets?.[normalized.assetId]) {
3431
+ issues.push(createIssue({
3432
+ code: "image-asset-not-found",
3433
+ message: `Image asset "${normalized.assetId}" was not found.`,
3434
+ operationIndex,
3435
+ operationId
3436
+ }));
3437
+ return;
3438
+ }
3439
+ break;
3440
+ case "set-image-asset":
3441
+ case "update-survey-pagination": break;
3442
+ }
3443
+ normalizedOperations.push(normalized);
3444
+ return;
3445
+ }
3446
+ const item = resolveRawItemByIdentifier({
3447
+ identifier: getOperationTarget(operation),
3448
+ indexById,
3449
+ outlineById,
3450
+ rawItemsById
3451
+ });
3452
+ if (!item) {
3453
+ issues.push(createIssue({
3454
+ code: "item-not-found",
3455
+ message: `No survey item matching "${getOperationTarget(operation)}" was found.`,
3456
+ operationIndex,
3457
+ operationId
3458
+ }));
3459
+ return;
3460
+ }
3461
+ const deletedAncestorId = getDeletedAncestor(item.itemId, parentByChildId, deletedItemIds);
3462
+ if (deletedItemIds.has(item.itemId) || deletedAncestorId) {
3463
+ issues.push(createIssue({
3464
+ code: "target-already-removed",
3465
+ message: `The operation targets an item that is already removed earlier in this proposal.`,
3466
+ operationIndex,
3467
+ operationId,
3468
+ itemId: item.itemId
3469
+ }));
3470
+ return;
3471
+ }
3472
+ const { rawItem } = item;
3473
+ const rawNormalized = normalizeOperation(operation, item.itemId);
3474
+ const normalized = rawNormalized.kind === "update-item-translation" || rawNormalized.kind === "update-placed-image" ? {
3475
+ ...rawNormalized,
3476
+ contentKey: normalizeContentKeyForItemType(rawNormalized.contentKey, itemTypeManifestByType.get(rawItem.itemType))
3477
+ } : rawNormalized.kind === "upsert-form-field-group" || rawNormalized.kind === "upsert-form-field" ? normalizeFormOperationTranslations(rawNormalized, rawNormalized.target.optionId) : rawNormalized.kind === "set-embedded-form" ? normalizeFormOperationTranslations(rawNormalized, rawNormalized.optionId) : rawNormalized;
3478
+ if (!itemTypeManifestByType.get(rawItem.itemType)?.supportedOperations.includes(normalized.kind)) {
3479
+ issues.push(createIssue({
3480
+ code: "operation-not-supported-for-item-type",
3481
+ message: `Item type "${rawItem.itemType}" does not support operation "${normalized.kind}".`,
3482
+ operationIndex,
3483
+ operationId,
3484
+ itemId: item.itemId
3485
+ }));
3486
+ return;
3487
+ }
3488
+ const trustedCapability = snapshot.trustedCapabilities?.byItemType.get(rawItem.itemType);
3489
+ if (trustedCapability?.supportsOperation && !trustedCapability.supportsOperation(normalized)) {
3490
+ issues.push(createIssue({
3491
+ code: "operation-rejected-by-item-capability",
3492
+ message: `The trusted capability for "${rawItem.itemType}" rejected operation "${normalized.kind}".`,
3493
+ operationIndex,
3494
+ operationId,
3495
+ itemId: item.itemId
3496
+ }));
3497
+ return;
3498
+ }
3499
+ switch (normalized.kind) {
3500
+ case "update-item-key": {
3501
+ if (!ITEM_KEY_PATTERN.test(normalized.key)) {
3502
+ issues.push(createIssue({
3503
+ code: "invalid-item-key",
3504
+ message: "Item keys must contain only letters, numbers, underscores, or hyphens.",
3505
+ operationIndex,
3506
+ operationId,
3507
+ itemId: item.itemId
3508
+ }));
3509
+ break;
3510
+ }
3511
+ const conflictId = getSiblingKeyConflict({
3512
+ createdKeysByParentId,
3513
+ deletedItemIds,
3514
+ itemId: item.itemId,
3515
+ key: normalized.key,
3516
+ parentByChildId,
3517
+ plannedKeys,
3518
+ rawItemsById
3519
+ });
3520
+ if (conflictId) {
3521
+ issues.push(createIssue({
3522
+ code: "duplicate-sibling-key",
3523
+ message: `The key "${normalized.key}" is already used by sibling item "${conflictId}".`,
3524
+ operationIndex,
3525
+ operationId,
3526
+ itemId: item.itemId
3527
+ }));
3528
+ break;
3529
+ }
3530
+ if (rawItem.key === normalized.key) warnings.push(createIssue({
3531
+ severity: "warning",
3532
+ code: "no-key-change",
3533
+ message: `The item already has the key "${normalized.key}".`,
3534
+ operationIndex,
3535
+ operationId,
3536
+ itemId: item.itemId
3537
+ }));
3538
+ plannedKeys.set(item.itemId, normalized.key);
3539
+ normalizedOperations.push(normalized);
3540
+ break;
3541
+ }
3542
+ case "update-item-label":
3543
+ if ((rawItem.metadata?.itemLabel ?? "") === normalized.itemLabel) warnings.push(createIssue({
3544
+ severity: "warning",
3545
+ code: "no-label-change",
3546
+ message: "The item already has that editor label.",
3547
+ operationIndex,
3548
+ operationId,
3549
+ itemId: item.itemId
3550
+ }));
3551
+ normalizedOperations.push(normalized);
3552
+ break;
3553
+ case "update-item-color":
3554
+ if (rawItem.metadata?.editorItemColor === normalized.color) warnings.push(createIssue({
3555
+ severity: "warning",
3556
+ code: "no-color-change",
3557
+ message: "The item already has that editor color.",
3558
+ operationIndex,
3559
+ operationId,
3560
+ itemId: item.itemId
3561
+ }));
3562
+ normalizedOperations.push(normalized);
3563
+ break;
3564
+ case "update-item-translation":
3565
+ addIntroducedLocaleWarning({
3566
+ locale: normalized.locale,
3567
+ operationIndex,
3568
+ operationId,
3569
+ itemId: item.itemId
3570
+ });
3571
+ if (normalized.contentKey.includes("..")) {
3572
+ issues.push(createIssue({
3573
+ code: "invalid-content-key",
3574
+ message: "Translation content keys cannot contain empty path segments.",
3575
+ operationIndex,
3576
+ operationId,
3577
+ itemId: item.itemId
3578
+ }));
3579
+ break;
3580
+ }
3581
+ normalizedOperations.push(normalized);
3582
+ break;
3583
+ case "update-placed-image": {
3584
+ const content = new SurveyTranslations(snapshot.survey.translations).getItemTranslations(item.itemId)?.getContent(normalized.locale, normalized.contentKey);
3585
+ if (content?.type !== ContentType.richText) {
3586
+ issues.push(createIssue({
3587
+ code: "placed-image-content-not-rich-text",
3588
+ message: `Content "${normalized.locale}:${normalized.contentKey}" is not rich text and has no placed image to update.`,
3589
+ operationIndex,
3590
+ operationId,
3591
+ itemId: item.itemId
3592
+ }));
3593
+ break;
3594
+ }
3595
+ const imageBlocks = content.doc.blocks.filter((block) => block.type === "image");
3596
+ const image = imageBlocks[normalized.imageIndex];
3597
+ if (!image) {
3598
+ issues.push(createIssue({
3599
+ code: "placed-image-not-found",
3600
+ message: `No image exists at imageIndex ${normalized.imageIndex} in "${normalized.locale}:${normalized.contentKey}". The content contains ${imageBlocks.length} image block${imageBlocks.length === 1 ? "" : "s"}.`,
3601
+ operationIndex,
3602
+ operationId,
3603
+ itemId: item.itemId
3604
+ }));
3605
+ break;
3606
+ }
3607
+ if (normalized.expectedAssetId && (image.source.type !== "asset" || image.source.assetId !== normalized.expectedAssetId)) {
3608
+ issues.push(createIssue({
3609
+ code: "placed-image-asset-mismatch",
3610
+ message: `The selected image no longer references expected asset "${normalized.expectedAssetId}". Inspect the item again before changing it.`,
3611
+ operationIndex,
3612
+ operationId,
3613
+ itemId: item.itemId
3614
+ }));
3615
+ break;
3616
+ }
3617
+ normalizedOperations.push(normalized);
3618
+ break;
3619
+ }
3620
+ case "patch-item-config": {
3621
+ let nextConfig;
3622
+ try {
3623
+ nextConfig = applyJsonPatch(rawItem.config ?? {}, normalized.patches);
3624
+ } catch (error) {
3625
+ const patchError = error instanceof JsonPatchError ? error : void 0;
3626
+ issues.push(createIssue({
3627
+ code: patchError?.code ?? "item-config-patch-failed",
3628
+ message: error instanceof Error ? error.message : "The item config JSON Patch could not be applied.",
3629
+ operationIndex,
3630
+ operationId,
3631
+ itemId: item.itemId
3632
+ }));
3633
+ break;
3634
+ }
3635
+ const nextItem = {
3636
+ ...rawItem,
3637
+ config: nextConfig
3638
+ };
3639
+ const capabilityIssues = trustedCapability?.validateItem?.(nextItem) ?? [];
3640
+ for (const issue of capabilityIssues) {
3641
+ const normalizedIssue = {
3642
+ ...issue,
3643
+ operationIndex: issue.operationIndex ?? operationIndex,
3644
+ operationId: issue.operationId ?? operationId,
3645
+ itemId: issue.itemId ?? item.itemId
3646
+ };
3647
+ (normalizedIssue.severity === "warning" ? warnings : issues).push(normalizedIssue);
3648
+ }
3649
+ if (issues.some((issue) => issue.operationIndex === operationIndex)) break;
3650
+ rawItemsById.set(item.itemId, nextItem);
3651
+ normalizedOperations.push(normalized);
3652
+ break;
3653
+ }
3654
+ case "upsert-form-field-group": {
3655
+ const groups = getFormFieldGroupsForTarget(rawItem, normalized.target.optionId);
3656
+ if (!groups) {
3657
+ issues.push(createIssue({
3658
+ code: "form-target-not-found",
3659
+ message: normalized.target.optionId ? `No inline embedded form was found on choice option "${normalized.target.optionId}".` : "Form operations require a formItem target.",
3660
+ operationIndex,
3661
+ operationId,
3662
+ itemId: item.itemId
3663
+ }));
3664
+ break;
3665
+ }
3666
+ const existingIndex = groups.findIndex((group) => group.id === normalized.group.id);
3667
+ const existing = existingIndex >= 0 ? groups[existingIndex] : void 0;
3668
+ const nextGroup = normalizeFormFieldGroupRecord({
3669
+ ...existing,
3670
+ ...normalized.group,
3671
+ fields: Array.isArray(normalized.group.fields) ? normalized.group.fields : existing?.fields ?? []
3672
+ });
3673
+ if (!nextGroup) break;
3674
+ const nextGroups = [...groups];
3675
+ if (existingIndex >= 0) nextGroups[existingIndex] = nextGroup;
3676
+ else {
3677
+ const insertIndex = Math.min(normalized.index ?? nextGroups.length, nextGroups.length);
3678
+ nextGroups.splice(insertIndex, 0, nextGroup);
3679
+ }
3680
+ const nextItem = withFormFieldGroupsForTarget(rawItem, nextGroups, normalized.target.optionId);
3681
+ if (!nextItem) break;
3682
+ for (const translation of normalized.translations ?? []) addIntroducedLocaleWarning({
3683
+ locale: translation.locale,
3684
+ operationIndex,
3685
+ operationId,
3686
+ itemId: item.itemId
3687
+ });
3688
+ rawItemsById.set(item.itemId, nextItem);
3689
+ normalizedOperations.push(normalized);
3690
+ break;
3691
+ }
3692
+ case "remove-form-field-group": {
3693
+ const groups = getFormFieldGroupsForTarget(rawItem, normalized.target.optionId);
3694
+ const groupIndex = groups?.findIndex((group) => group.id === normalized.groupId) ?? -1;
3695
+ if (!groups || groupIndex < 0) {
3696
+ issues.push(createIssue({
3697
+ code: "form-field-group-not-found",
3698
+ message: `Form field group "${normalized.groupId}" was not found.`,
3699
+ operationIndex,
3700
+ operationId,
3701
+ itemId: item.itemId
3702
+ }));
3703
+ break;
3704
+ }
3705
+ const nextItem = withFormFieldGroupsForTarget(rawItem, groups.filter((_, index) => index !== groupIndex), normalized.target.optionId);
3706
+ if (nextItem) rawItemsById.set(item.itemId, nextItem);
3707
+ normalizedOperations.push(normalized);
3708
+ break;
3709
+ }
3710
+ case "reorder-form-field-groups": {
3711
+ const groups = getFormFieldGroupsForTarget(rawItem, normalized.target.optionId);
3712
+ const groupIds = groups?.map((group) => group.id) ?? [];
3713
+ if (!groups || normalized.groupIds.length !== groupIds.length || new Set(normalized.groupIds).size !== groupIds.length || normalized.groupIds.some((groupId) => !groupIds.includes(groupId))) {
3714
+ issues.push(createIssue({
3715
+ code: "incomplete-form-field-group-order",
3716
+ message: "Reordering form groups requires every existing group id exactly once.",
3717
+ operationIndex,
3718
+ operationId,
3719
+ itemId: item.itemId
3720
+ }));
3721
+ break;
3722
+ }
3723
+ const byId = new Map(groups.map((group) => [group.id, group]));
3724
+ const nextItem = withFormFieldGroupsForTarget(rawItem, normalized.groupIds.map((groupId) => byId.get(groupId)), normalized.target.optionId);
3725
+ if (nextItem) rawItemsById.set(item.itemId, nextItem);
3726
+ normalizedOperations.push(normalized);
3727
+ break;
3728
+ }
3729
+ case "upsert-form-field": {
3730
+ const groups = getFormFieldGroupsForTarget(rawItem, normalized.target.optionId);
3731
+ const groupIndex = groups?.findIndex((group) => group.id === normalized.groupId) ?? -1;
3732
+ if (!groups || groupIndex < 0) {
3733
+ issues.push(createIssue({
3734
+ code: "form-field-group-not-found",
3735
+ message: `Form field group "${normalized.groupId}" was not found.`,
3736
+ operationIndex,
3737
+ operationId,
3738
+ itemId: item.itemId
3739
+ }));
3740
+ break;
3741
+ }
3742
+ if (groups.flatMap((group) => group.fields).find((field) => field.id !== normalized.field.id && field.key === normalized.field.key)) {
3743
+ issues.push(createIssue({
3744
+ code: "duplicate-form-field-key",
3745
+ message: `Form field key "${normalized.field.key}" is already in use.`,
3746
+ operationIndex,
3747
+ operationId,
3748
+ itemId: item.itemId
3749
+ }));
3750
+ break;
3751
+ }
3752
+ const nextGroups = groups.map((group) => ({
3753
+ ...group,
3754
+ fields: [...group.fields]
3755
+ }));
3756
+ let existingField;
3757
+ for (const group of nextGroups) {
3758
+ const existingIndex = group.fields.findIndex((field) => field.id === normalized.field.id);
3759
+ if (existingIndex >= 0) {
3760
+ existingField = group.fields[existingIndex];
3761
+ group.fields.splice(existingIndex, 1);
3762
+ }
3763
+ }
3764
+ const targetFields = nextGroups[groupIndex].fields;
3765
+ const insertIndex = Math.min(normalized.index ?? targetFields.length, targetFields.length);
3766
+ targetFields.splice(insertIndex, 0, {
3767
+ ...existingField,
3768
+ ...normalized.field
3769
+ });
3770
+ const nextItem = withFormFieldGroupsForTarget(rawItem, nextGroups, normalized.target.optionId);
3771
+ if (nextItem) rawItemsById.set(item.itemId, nextItem);
3772
+ for (const translation of normalized.translations ?? []) addIntroducedLocaleWarning({
3773
+ locale: translation.locale,
3774
+ operationIndex,
3775
+ operationId,
3776
+ itemId: item.itemId
3777
+ });
3778
+ normalizedOperations.push(normalized);
3779
+ break;
3780
+ }
3781
+ case "remove-form-field":
3782
+ case "move-form-field": {
3783
+ const groups = getFormFieldGroupsForTarget(rawItem, normalized.target.optionId);
3784
+ if (!groups) {
3785
+ issues.push(createIssue({
3786
+ code: "form-target-not-found",
3787
+ message: "The requested form or embedded form was not found.",
3788
+ operationIndex,
3789
+ operationId,
3790
+ itemId: item.itemId
3791
+ }));
3792
+ break;
3793
+ }
3794
+ const nextGroups = groups.map((group) => ({
3795
+ ...group,
3796
+ fields: [...group.fields]
3797
+ }));
3798
+ let foundField;
3799
+ for (const group of nextGroups) {
3800
+ const fieldIndex = group.fields.findIndex((field) => field.id === normalized.fieldId);
3801
+ if (fieldIndex >= 0) {
3802
+ [foundField] = group.fields.splice(fieldIndex, 1);
3803
+ break;
3804
+ }
3805
+ }
3806
+ if (!foundField) {
3807
+ issues.push(createIssue({
3808
+ code: "form-field-not-found",
3809
+ message: `Form field "${normalized.fieldId}" was not found.`,
3810
+ operationIndex,
3811
+ operationId,
3812
+ itemId: item.itemId
3813
+ }));
3814
+ break;
3815
+ }
3816
+ if (normalized.kind === "move-form-field") {
3817
+ const targetGroup = nextGroups.find((group) => group.id === normalized.targetGroupId);
3818
+ if (!targetGroup) {
3819
+ issues.push(createIssue({
3820
+ code: "form-field-group-not-found",
3821
+ message: `Target form field group "${normalized.targetGroupId}" was not found.`,
3822
+ operationIndex,
3823
+ operationId,
3824
+ itemId: item.itemId
3825
+ }));
3826
+ break;
3827
+ }
3828
+ targetGroup.fields.splice(Math.min(normalized.index ?? targetGroup.fields.length, targetGroup.fields.length), 0, foundField);
3829
+ }
3830
+ const nextItem = withFormFieldGroupsForTarget(rawItem, nextGroups, normalized.target.optionId);
3831
+ if (nextItem) rawItemsById.set(item.itemId, nextItem);
3832
+ normalizedOperations.push(normalized);
3833
+ break;
3834
+ }
3835
+ case "set-embedded-form":
3836
+ case "remove-embedded-form": {
3837
+ if (rawItem.itemType !== CHOICE_ITEM_TYPE) {
3838
+ issues.push(createIssue({
3839
+ code: "not-choice-item",
3840
+ message: "Embedded forms can only target choiceItem options.",
3841
+ operationIndex,
3842
+ operationId,
3843
+ itemId: item.itemId
3844
+ }));
3845
+ break;
3846
+ }
3847
+ const options = getChoiceOptionsFromRawItem(rawItem);
3848
+ const optionIndex = options.findIndex((option) => option.id === normalized.optionId);
3849
+ if (optionIndex < 0) {
3850
+ issues.push(createIssue({
3851
+ code: "choice-option-not-found",
3852
+ message: `Choice option "${normalized.optionId}" was not found.`,
3853
+ operationIndex,
3854
+ operationId,
3855
+ itemId: item.itemId
3856
+ }));
3857
+ break;
3858
+ }
3859
+ options[optionIndex] = { ...options[optionIndex] };
3860
+ if (normalized.kind === "set-embedded-form") {
3861
+ options[optionIndex].embeddedForm = normalized.form;
3862
+ for (const translation of normalized.translations ?? []) addIntroducedLocaleWarning({
3863
+ locale: translation.locale,
3864
+ operationIndex,
3865
+ operationId,
3866
+ itemId: item.itemId
3867
+ });
3868
+ } else {
3869
+ delete options[optionIndex].embeddedForm;
3870
+ delete options[optionIndex].embeddedFormItemId;
3871
+ }
3872
+ rawItemsById.set(item.itemId, withChoiceOptionsOnRawItem(rawItem, options));
3873
+ normalizedOperations.push(normalized);
3874
+ break;
3875
+ }
3876
+ case "add-choice-option": {
3877
+ const locale = normalized.locale ?? snapshot.ui.selectedLocale;
3878
+ if (rawItem.itemType !== CHOICE_ITEM_TYPE) {
3879
+ issues.push(createIssue({
3880
+ code: "not-choice-item",
3881
+ message: "Choice option operations can only target choiceItem items.",
3882
+ operationIndex,
3883
+ operationId,
3884
+ itemId: item.itemId
3885
+ }));
3886
+ break;
3887
+ }
3888
+ addIntroducedLocaleWarning({
3889
+ locale,
3890
+ operationIndex,
3891
+ operationId,
3892
+ itemId: item.itemId
3893
+ });
3894
+ if (!normalized.option.label.trim()) {
3895
+ issues.push(createIssue({
3896
+ code: "missing-choice-option-label",
3897
+ message: "A new choice option needs a non-empty respondent-visible label.",
3898
+ operationIndex,
3899
+ operationId,
3900
+ itemId: item.itemId
3901
+ }));
3902
+ break;
3903
+ }
3904
+ const options = getChoiceOptionsFromRawItem(rawItem);
3905
+ const usedIds = new Set(options.map((option) => option.id));
3906
+ const usedKeys = new Set(options.map((option) => option.key).filter((key) => Boolean(key)));
3907
+ const optionId = ensureUniqueId(normalized.option.id ?? normalized.option.label, usedIds);
3908
+ const optionKey = ensureUniqueChoiceOptionKey(normalized.option.key ?? normalized.option.label, usedKeys);
3909
+ const nextOption = {
3910
+ id: optionId,
3911
+ key: optionKey
3912
+ };
3913
+ const insertIndex = normalized.index === void 0 ? options.length : Math.min(options.length, Math.max(0, normalized.index));
3914
+ const nextOptions = [...options];
3915
+ nextOptions.splice(insertIndex, 0, nextOption);
3916
+ if (normalized.index !== void 0 && normalized.index > options.length) warnings.push(createIssue({
3917
+ severity: "warning",
3918
+ code: "option-index-after-end",
3919
+ message: "The option index is after the end of the option list and will be clamped.",
3920
+ operationIndex,
3921
+ operationId,
3922
+ itemId: item.itemId
3923
+ }));
3924
+ rawItemsById.set(item.itemId, withChoiceOptionsOnRawItem(rawItem, nextOptions));
3925
+ normalizedOperations.push({
3926
+ ...normalized,
3927
+ locale,
3928
+ index: insertIndex,
3929
+ option: {
3930
+ id: optionId,
3931
+ key: optionKey,
3932
+ label: normalized.option.label
3933
+ }
3934
+ });
3935
+ break;
3936
+ }
3937
+ case "update-choice-option-label": {
3938
+ const locale = normalized.locale ?? snapshot.ui.selectedLocale;
3939
+ if (rawItem.itemType !== CHOICE_ITEM_TYPE) {
3940
+ issues.push(createIssue({
3941
+ code: "not-choice-item",
3942
+ message: "Choice option operations can only target choiceItem items.",
3943
+ operationIndex,
3944
+ operationId,
3945
+ itemId: item.itemId
3946
+ }));
3947
+ break;
3948
+ }
3949
+ addIntroducedLocaleWarning({
3950
+ locale,
3951
+ operationIndex,
3952
+ operationId,
3953
+ itemId: item.itemId
3954
+ });
3955
+ const option = resolveChoiceOption({
3956
+ itemId: item.itemId,
3957
+ locale,
3958
+ optionRef: normalized.optionId,
3959
+ options: getChoiceOptionsFromRawItem(rawItem),
3960
+ snapshot
3961
+ });
3962
+ if (!option) {
3963
+ issues.push(createChoiceOptionsConflictIssue({
3964
+ code: "choice-option-not-found",
3965
+ message: `No option matching "${normalized.optionId}" was found on this choice item.`,
3966
+ operationIndex,
3967
+ operationId,
3968
+ itemId: item.itemId
3969
+ }));
3970
+ break;
3971
+ }
3972
+ if (getItemTranslationText({
3973
+ contentKey: createChoiceOptionLabelContentKey(option.id),
3974
+ itemId: item.itemId,
3975
+ locale,
3976
+ snapshot
3977
+ }) === normalized.label) warnings.push(createIssue({
3978
+ severity: "warning",
3979
+ code: "no-choice-option-label-change",
3980
+ message: "The option already has that label.",
3981
+ operationIndex,
3982
+ operationId,
3983
+ itemId: item.itemId
3984
+ }));
3985
+ normalizedOperations.push({
3986
+ ...normalized,
3987
+ locale,
3988
+ optionId: option.id
3989
+ });
3990
+ break;
3991
+ }
3992
+ case "remove-choice-option": {
3993
+ if (rawItem.itemType !== CHOICE_ITEM_TYPE) {
3994
+ issues.push(createIssue({
3995
+ code: "not-choice-item",
3996
+ message: "Choice option operations can only target choiceItem items.",
3997
+ operationIndex,
3998
+ operationId,
3999
+ itemId: item.itemId
4000
+ }));
4001
+ break;
4002
+ }
4003
+ const options = getChoiceOptionsFromRawItem(rawItem);
4004
+ const option = resolveChoiceOption({
4005
+ itemId: item.itemId,
4006
+ locale: snapshot.ui.selectedLocale,
4007
+ optionRef: normalized.optionId,
4008
+ options,
4009
+ snapshot
4010
+ });
4011
+ if (!option) {
4012
+ issues.push(createChoiceOptionsConflictIssue({
4013
+ code: "choice-option-not-found",
4014
+ message: `No option matching "${normalized.optionId}" was found on this choice item.`,
4015
+ operationIndex,
4016
+ operationId,
4017
+ itemId: item.itemId
4018
+ }));
4019
+ break;
4020
+ }
4021
+ rawItemsById.set(item.itemId, withChoiceOptionsOnRawItem(rawItem, options.filter((candidate) => candidate.id !== option.id)));
4022
+ normalizedOperations.push({
4023
+ ...normalized,
4024
+ optionId: option.id
4025
+ });
4026
+ break;
4027
+ }
4028
+ case "reorder-choice-options": {
4029
+ if (rawItem.itemType !== CHOICE_ITEM_TYPE) {
4030
+ issues.push(createIssue({
4031
+ code: "not-choice-item",
4032
+ message: "Choice option operations can only target choiceItem items.",
4033
+ operationIndex,
4034
+ operationId,
4035
+ itemId: item.itemId
4036
+ }));
4037
+ break;
4038
+ }
4039
+ const options = getChoiceOptionsFromRawItem(rawItem);
4040
+ const resolvedOptions = normalized.optionIds.map((optionRef) => resolveChoiceOption({
4041
+ itemId: item.itemId,
4042
+ locale: snapshot.ui.selectedLocale,
4043
+ optionRef,
4044
+ options,
4045
+ snapshot
4046
+ }));
4047
+ const missingIndex = resolvedOptions.findIndex((option) => !option);
4048
+ if (missingIndex >= 0) {
4049
+ issues.push(createChoiceOptionsConflictIssue({
4050
+ code: "choice-option-not-found",
4051
+ message: `No option matching "${normalized.optionIds[missingIndex]}" was found on this choice item.`,
4052
+ operationIndex,
4053
+ operationId,
4054
+ itemId: item.itemId
4055
+ }));
4056
+ break;
4057
+ }
4058
+ const resolvedOptionIds = resolvedOptions.map((option) => option.id);
4059
+ if (new Set(resolvedOptionIds).size !== resolvedOptionIds.length) {
4060
+ issues.push(createChoiceOptionsConflictIssue({
4061
+ code: "duplicate-choice-option-order-entry",
4062
+ message: "The option order contains the same option more than once.",
4063
+ operationIndex,
4064
+ operationId,
4065
+ itemId: item.itemId
4066
+ }));
4067
+ break;
4068
+ }
4069
+ const existingOptionIds = new Set(options.map((option) => option.id));
4070
+ if (resolvedOptionIds.length !== options.length || resolvedOptionIds.some((optionId) => !existingOptionIds.has(optionId))) {
4071
+ issues.push(createChoiceOptionsConflictIssue({
4072
+ code: "incomplete-choice-option-order",
4073
+ message: "Reordering options requires a complete order containing every existing option exactly once.",
4074
+ operationIndex,
4075
+ operationId,
4076
+ itemId: item.itemId
4077
+ }));
4078
+ break;
4079
+ }
4080
+ const optionsById = new Map(options.map((option) => [option.id, option]));
4081
+ rawItemsById.set(item.itemId, withChoiceOptionsOnRawItem(rawItem, resolvedOptionIds.map((optionId) => optionsById.get(optionId))));
4082
+ normalizedOperations.push({
4083
+ ...normalized,
4084
+ optionIds: resolvedOptionIds
4085
+ });
4086
+ break;
4087
+ }
4088
+ case "move-item": {
4089
+ if (isRootItem(rawItem)) {
4090
+ issues.push(createIssue({
4091
+ code: "cannot-move-root",
4092
+ message: "The survey root item cannot be moved.",
4093
+ operationIndex,
4094
+ operationId,
4095
+ itemId: item.itemId
4096
+ }));
4097
+ break;
4098
+ }
4099
+ const parent = resolveCreateParentByIdentifier({
4100
+ identifier: normalized.target.parentItemId,
4101
+ indexById,
4102
+ outlineById,
4103
+ rawItemsById
4104
+ });
4105
+ if (!parent || parent.rawItem.itemType !== ReservedSurveyItemTypes.Group) {
4106
+ issues.push(createIssue({
4107
+ code: "target-parent-not-group",
4108
+ message: `Move target "${normalized.target.parentItemId}" is not an existing group.`,
4109
+ operationIndex,
4110
+ operationId,
4111
+ itemId: item.itemId
4112
+ }));
4113
+ break;
4114
+ }
4115
+ if (item.itemId === parent.itemId || collectDescendantIds(item.itemId, rawItemsById).has(parent.itemId)) {
4116
+ issues.push(createIssue({
4117
+ code: "move-would-create-cycle",
4118
+ message: "An item cannot be moved into itself or one of its descendants.",
4119
+ operationIndex,
4120
+ operationId,
4121
+ itemId: item.itemId
4122
+ }));
4123
+ break;
4124
+ }
4125
+ const previousParentId = parentByChildId.get(item.itemId);
4126
+ if (previousParentId) {
4127
+ const previousParent = rawItemsById.get(previousParentId);
4128
+ if (previousParent) rawItemsById.set(previousParentId, {
4129
+ ...previousParent,
4130
+ config: {
4131
+ ...isRecord$2(previousParent.config) ? previousParent.config : {},
4132
+ items: getGroupChildren(previousParent).filter((childId) => childId !== item.itemId)
4133
+ }
4134
+ });
4135
+ }
4136
+ const nextChildren = getGroupChildren(parent.rawItem).filter((childId) => childId !== item.itemId);
4137
+ nextChildren.splice(Math.min(normalized.target.index ?? nextChildren.length, nextChildren.length), 0, item.itemId);
4138
+ rawItemsById.set(parent.itemId, {
4139
+ ...parent.rawItem,
4140
+ config: {
4141
+ ...isRecord$2(parent.rawItem.config) ? parent.rawItem.config : {},
4142
+ items: nextChildren
4143
+ }
4144
+ });
4145
+ parentByChildId.set(item.itemId, parent.itemId);
4146
+ normalizedOperations.push({
4147
+ ...normalized,
4148
+ target: {
4149
+ ...normalized.target,
4150
+ parentItemId: parent.itemId
4151
+ }
4152
+ });
4153
+ break;
4154
+ }
4155
+ case "reorder-items": {
4156
+ if (rawItem.itemType !== ReservedSurveyItemTypes.Group) {
4157
+ issues.push(createIssue({
4158
+ code: "target-parent-not-group",
4159
+ message: "Only group children can be reordered.",
4160
+ operationIndex,
4161
+ operationId,
4162
+ itemId: item.itemId
4163
+ }));
4164
+ break;
4165
+ }
4166
+ const currentChildren = getGroupChildren(rawItem);
4167
+ const resolvedIds = normalized.itemIds.map((itemRef) => resolveRawItemByIdentifier({
4168
+ identifier: itemRef,
4169
+ indexById,
4170
+ outlineById,
4171
+ rawItemsById
4172
+ })).map((child) => child?.itemId);
4173
+ if (resolvedIds.some((childId) => !childId) || resolvedIds.length !== currentChildren.length || new Set(resolvedIds).size !== currentChildren.length || resolvedIds.some((childId) => !currentChildren.includes(childId))) {
4174
+ issues.push(createIssue({
4175
+ code: "incomplete-item-order",
4176
+ message: "Reordering group children requires every existing direct child exactly once.",
4177
+ operationIndex,
4178
+ operationId,
4179
+ itemId: item.itemId
4180
+ }));
4181
+ break;
4182
+ }
4183
+ rawItemsById.set(item.itemId, {
4184
+ ...rawItem,
4185
+ config: {
4186
+ ...isRecord$2(rawItem.config) ? rawItem.config : {},
4187
+ items: resolvedIds
4188
+ }
4189
+ });
4190
+ normalizedOperations.push({
4191
+ ...normalized,
4192
+ itemIds: resolvedIds
4193
+ });
4194
+ break;
4195
+ }
4196
+ case "remove-item": {
4197
+ if (isRootItem(rawItem)) {
4198
+ issues.push(createIssue({
4199
+ code: "cannot-remove-root",
4200
+ message: "The survey root item cannot be removed.",
4201
+ operationIndex,
4202
+ operationId,
4203
+ itemId: item.itemId
4204
+ }));
4205
+ break;
4206
+ }
4207
+ const descendantIds = collectDescendantIds(item.itemId, rawItemsById);
4208
+ if (descendantIds.size > 0 && !normalized.includeChildren) {
4209
+ issues.push(createIssue({
4210
+ code: "remove-would-delete-children",
4211
+ message: `Removing this item would also remove ${descendantIds.size} child item(s). Set includeChildren to true if that is intended.`,
4212
+ operationIndex,
4213
+ operationId,
4214
+ itemId: item.itemId
4215
+ }));
4216
+ break;
4217
+ }
4218
+ deletedItemIds.add(item.itemId);
4219
+ for (const descendantId of descendantIds) deletedItemIds.add(descendantId);
4220
+ normalizedOperations.push(normalized);
4221
+ break;
4222
+ }
4223
+ }
4224
+ });
4225
+ return {
4226
+ valid: issues.length === 0,
4227
+ issues,
4228
+ warnings,
4229
+ normalizedOperations
4230
+ };
4231
+ };
4232
+ const createSurveyChangeSet = ({ operations, snapshot, summary, turnId }) => {
4233
+ const validation = validateSurveyOperations({
4234
+ operations,
4235
+ snapshot
4236
+ });
4237
+ return {
4238
+ protocolVersion: 1,
4239
+ id: globalThis.crypto.randomUUID(),
4240
+ turnId,
4241
+ status: validation.valid ? "validated" : "rejected",
4242
+ summary: normalizeSummary(summary),
4243
+ operations: validation.normalizedOperations,
4244
+ precondition: snapshot ? buildSurveyDocumentVersion(snapshot) : void 0,
4245
+ validation,
4246
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
4247
+ };
4248
+ };
4249
+ //#endregion
4250
+ //#region src/engine/turn-change-collector.ts
4251
+ const createId = () => globalThis.crypto.randomUUID();
4252
+ const SURVEY_CHANGE_CONTINUATION_MODES = ["append", "replace"];
4253
+ const SURVEY_CHANGE_CONTINUATION_DESCRIPTION = "Omit only for the first mutation call in this turn. On every later mutation call, use \"append\" for independent additional work or \"replace\" for a correction that should discard all currently active candidates.";
4254
+ var TurnChangeCollector = class {
4255
+ turnId;
4256
+ #candidates = /* @__PURE__ */ new Map();
4257
+ #activeChangeSetIds = [];
4258
+ #blockedChangeSetIds = /* @__PURE__ */ new Set();
4259
+ #ambiguousChangeSetIds = /* @__PURE__ */ new Set();
4260
+ #requiresReplacement = false;
4261
+ #final = null;
4262
+ constructor(turnId = createId()) {
4263
+ this.turnId = turnId;
4264
+ }
4265
+ register(candidate, { continuationMode } = {}) {
4266
+ if (this.#final) throw new Error("Cannot register a survey change after the turn was finalized.");
4267
+ if (this.#candidates.has(candidate.id)) throw new Error(`Survey change "${candidate.id}" was registered more than once.`);
4268
+ const modeRequired = this.#candidates.size > 0 && continuationMode === void 0;
4269
+ const replacementRequired = this.#requiresReplacement && continuationMode !== "replace";
4270
+ const continuationIssue = modeRequired ? {
4271
+ severity: "error",
4272
+ code: "change-mode-required",
4273
+ message: "A prior mutation candidate exists in this turn. Retry this complete proposal with continuationMode \"append\" for independent additional work or \"replace\" for a correction that discards the active candidates."
4274
+ } : replacementRequired ? {
4275
+ severity: "error",
4276
+ code: "change-mode-repair-required",
4277
+ message: "An earlier mutation proposal was rejected and blocked turn authorization. Retry the complete intended proposal with continuationMode \"replace\" before appending any later independent chunks."
4278
+ } : void 0;
4279
+ const candidateWasRejected = candidate.status !== "validated" || !candidate.validation.valid;
4280
+ const ambiguousModeOnly = modeRequired && !candidateWasRejected && continuationIssue?.code === "change-mode-required";
4281
+ const shouldClearActive = continuationMode === "replace" || continuationIssue !== void 0 && !ambiguousModeOnly || candidateWasRejected;
4282
+ const supersedesChangeIds = shouldClearActive ? [...this.#activeChangeSetIds] : [];
4283
+ const registered = surveyChangeSetSchema.parse({
4284
+ ...candidate,
4285
+ turnId: this.turnId,
4286
+ ...continuationIssue ? {
4287
+ status: "rejected",
4288
+ validation: {
4289
+ ...candidate.validation,
4290
+ valid: false,
4291
+ issues: [...candidate.validation.issues, continuationIssue]
4292
+ }
4293
+ } : {},
4294
+ ...supersedesChangeIds.length > 0 ? { supersedesChangeIds } : {}
4295
+ });
4296
+ this.#candidates.set(registered.id, registered);
4297
+ const isValidated = registered.status === "validated" && registered.validation.valid;
4298
+ if (shouldClearActive) this.#activeChangeSetIds = [];
4299
+ if (ambiguousModeOnly) {
4300
+ this.#blockedChangeSetIds.add(registered.id);
4301
+ this.#ambiguousChangeSetIds.add(registered.id);
4302
+ } else if (!isValidated) {
4303
+ this.#blockedChangeSetIds.add(registered.id);
4304
+ this.#requiresReplacement = true;
4305
+ } else if (continuationMode === "replace") {
4306
+ this.#blockedChangeSetIds.clear();
4307
+ this.#ambiguousChangeSetIds.clear();
4308
+ this.#requiresReplacement = false;
4309
+ } else if (continuationMode === "append" && this.#ambiguousChangeSetIds.size > 0) {
4310
+ for (const changeSetId of this.#ambiguousChangeSetIds) this.#blockedChangeSetIds.delete(changeSetId);
4311
+ this.#ambiguousChangeSetIds.clear();
4312
+ }
4313
+ if (!this.#requiresReplacement && isValidated) this.#activeChangeSetIds.push(registered.id);
4314
+ return registered;
4315
+ }
4316
+ finalize(status, createdAt = (/* @__PURE__ */ new Date()).toISOString()) {
4317
+ if (this.#final) return this.#final;
4318
+ const blockedChangeSetIds = [...this.#blockedChangeSetIds];
4319
+ const canAuthorize = status === "completed" && !this.#requiresReplacement && blockedChangeSetIds.length === 0;
4320
+ this.#final = surveyAssistantTurnFinalSchema.parse({
4321
+ protocolVersion: 1,
4322
+ turnId: this.turnId,
4323
+ status,
4324
+ executableChangeSetIds: canAuthorize ? this.#activeChangeSetIds : [],
4325
+ ...blockedChangeSetIds.length > 0 ? { blockedChangeSetIds } : {},
4326
+ createdAt
4327
+ });
4328
+ return this.#final;
4329
+ }
4330
+ getCandidate(changeSetId) {
4331
+ return this.#candidates.get(changeSetId);
4332
+ }
4333
+ get final() {
4334
+ return this.#final;
4335
+ }
4336
+ };
4337
+ //#endregion
4338
+ //#region src/engine/task-context.ts
4339
+ const MAX_RELATED_ITEMS = 48;
4340
+ const MAX_SIBLING_ITEMS = 80;
4341
+ const MAX_VISIBLE_TEXT_ENTRIES_PER_ITEM = 24;
4342
+ const MAX_VISIBLE_TEXT_CHARS = 600;
4343
+ const MAX_NAMING_EXAMPLES = 40;
4344
+ const MAX_TRANSLATION_EXAMPLES = 60;
4345
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4346
+ const getItemLabel = (item) => {
4347
+ if (!isRecord$1(item.metadata)) return void 0;
4348
+ const label = item.metadata.itemLabel;
4349
+ return typeof label === "string" && label.trim().length > 0 ? label.trim() : void 0;
4350
+ };
4351
+ const getCenteredNeighbors = (values, focusIndex, limit) => {
4352
+ if (focusIndex < 0 || limit < 1) return [];
4353
+ const windowSize = Math.min(values.length, limit + 1);
4354
+ const idealStart = focusIndex - Math.floor(limit / 2);
4355
+ const start = Math.min(Math.max(0, idealStart), values.length - windowSize);
4356
+ return values.slice(start, start + windowSize).filter((_, index) => start + index !== focusIndex);
4357
+ };
4358
+ const buildSurveyAssistantTaskContext = (snapshot, options) => {
4359
+ const { inspection } = createSurveyAssistantEngineContext(snapshot, void 0, { focusItemId: options.focusItemId });
4360
+ const itemsById = new Map(snapshot.survey.surveyItems.map((item) => [item.id, item]));
4361
+ const parentById = new Map(inspection.outline.flatMap((item) => item.parentId ? [[item.itemId, item.parentId]] : []));
4362
+ const childIdsByParent = /* @__PURE__ */ new Map();
4363
+ for (const item of inspection.outline) {
4364
+ if (!item.parentId) continue;
4365
+ const childIds = childIdsByParent.get(item.parentId) ?? [];
4366
+ childIds.push(item.itemId);
4367
+ childIdsByParent.set(item.parentId, childIds);
4368
+ }
4369
+ const fullKeyById = new Map(inspection.keyIndex.map((item) => [item.itemId, item.fullKey]));
4370
+ const summarizeItem = (item) => {
4371
+ const allVisibleText = [];
4372
+ let visibleTextValueTruncated = false;
4373
+ for (const [locale, translations] of Object.entries(snapshot.survey.translations ?? {})) {
4374
+ const itemTranslations = translations.itemTranslations?.[item.id];
4375
+ if (!itemTranslations) continue;
4376
+ for (const [contentKey, content] of Object.entries(itemTranslations)) {
4377
+ const text = getContentPlainText(content).trim();
4378
+ if (text) {
4379
+ if (text.length > MAX_VISIBLE_TEXT_CHARS) visibleTextValueTruncated = true;
4380
+ allVisibleText.push({
4381
+ locale,
4382
+ contentKey,
4383
+ text: text.length > MAX_VISIBLE_TEXT_CHARS ? `${text.slice(0, MAX_VISIBLE_TEXT_CHARS)}...` : text
4384
+ });
4385
+ }
4386
+ }
4387
+ }
4388
+ return {
4389
+ id: item.id,
4390
+ key: item.key,
4391
+ fullKey: fullKeyById.get(item.id) ?? item.key,
4392
+ itemType: item.itemType,
4393
+ itemLabel: getItemLabel(item),
4394
+ visibleText: allVisibleText.slice(0, MAX_VISIBLE_TEXT_ENTRIES_PER_ITEM),
4395
+ visibleTextTruncated: allVisibleText.length > MAX_VISIBLE_TEXT_ENTRIES_PER_ITEM || visibleTextValueTruncated
4396
+ };
4397
+ };
4398
+ const rootItemId = inspection.outline.find((item) => item.depth === 0)?.itemId;
4399
+ const rootItem = rootItemId ? itemsById.get(rootItemId) : void 0;
4400
+ const result = {
4401
+ document: {
4402
+ revision: snapshot.version.revision,
4403
+ contentHash: snapshot.version.contentHash,
4404
+ capabilityDigest: snapshot.version.capabilityDigest
4405
+ },
4406
+ survey: {
4407
+ key: rootItem?.key,
4408
+ locales: inspection.locales,
4409
+ itemCount: inspection.itemCount,
4410
+ selectedLocale: snapshot.ui.selectedLocale
4411
+ }
4412
+ };
4413
+ const focusItem = options.focusItemId ? itemsById.get(options.focusItemId) : void 0;
4414
+ if (focusItem) {
4415
+ const parentId = parentById.get(focusItem.id);
4416
+ const parentItem = parentId ? itemsById.get(parentId) : void 0;
4417
+ const ancestorItems = [];
4418
+ const seenAncestorIds = /* @__PURE__ */ new Set();
4419
+ let ancestorId = parentId;
4420
+ while (ancestorId && !seenAncestorIds.has(ancestorId)) {
4421
+ seenAncestorIds.add(ancestorId);
4422
+ const ancestor = itemsById.get(ancestorId);
4423
+ if (!ancestor) break;
4424
+ ancestorItems.unshift(ancestor);
4425
+ ancestorId = parentById.get(ancestorId);
4426
+ }
4427
+ const siblingIds = parentItem ? childIdsByParent.get(parentItem.id) ?? [] : [];
4428
+ const siblingItemCount = Math.max(0, siblingIds.length - 1);
4429
+ const siblingItems = getCenteredNeighbors(siblingIds, siblingIds.indexOf(focusItem.id), MAX_SIBLING_ITEMS).map((itemId) => itemsById.get(itemId)).filter((item) => Boolean(item));
4430
+ const allRelatedItems = [];
4431
+ const seenRelatedIds = /* @__PURE__ */ new Set();
4432
+ const pending = [...childIdsByParent.get(focusItem.id) ?? []];
4433
+ while (pending.length > 0) {
4434
+ const itemId = pending.shift();
4435
+ const item = itemId ? itemsById.get(itemId) : void 0;
4436
+ if (!item || seenRelatedIds.has(item.id)) continue;
4437
+ seenRelatedIds.add(item.id);
4438
+ allRelatedItems.push(item);
4439
+ pending.push(...childIdsByParent.get(item.id) ?? []);
4440
+ }
4441
+ const relatedItems = allRelatedItems.slice(0, MAX_RELATED_ITEMS);
4442
+ result.focus = {
4443
+ item: summarizeItem(focusItem),
4444
+ parent: parentItem ? summarizeItem(parentItem) : void 0,
4445
+ ancestorPath: ancestorItems.map(summarizeItem),
4446
+ siblingKeys: siblingItems.map((item) => item.key),
4447
+ siblingItems: siblingItems.map(summarizeItem),
4448
+ siblingItemCount,
4449
+ siblingItemsTruncated: siblingItemCount > siblingItems.length,
4450
+ relatedItems: relatedItems.map(summarizeItem),
4451
+ relatedItemCount: allRelatedItems.length,
4452
+ relatedItemsTruncated: allRelatedItems.length > relatedItems.length
4453
+ };
4454
+ }
4455
+ if ((options.purpose === "suggest-item-key" || options.purpose === "suggest-item-label") && focusItem) {
4456
+ const parentId = parentById.get(focusItem.id);
4457
+ const siblingIds = parentId ? childIdsByParent.get(parentId) ?? [] : [];
4458
+ const siblingPositionById = new Map(siblingIds.map((itemId, index) => [itemId, index]));
4459
+ const focusSiblingPosition = siblingPositionById.get(focusItem.id);
4460
+ const rankedItems = snapshot.survey.surveyItems.filter((item) => item.id !== focusItem.id).map((item, index) => ({
4461
+ item,
4462
+ index,
4463
+ siblingDistance: focusSiblingPosition !== void 0 && siblingPositionById.has(item.id) ? Math.abs(siblingPositionById.get(item.id) - focusSiblingPosition) : Number.POSITIVE_INFINITY,
4464
+ relationship: parentById.get(item.id) === parentId ? "sibling" : item.itemType === focusItem.itemType ? "same-type" : "survey"
4465
+ })).sort((left, right) => {
4466
+ const rank = {
4467
+ sibling: 0,
4468
+ "same-type": 1,
4469
+ survey: 2
4470
+ };
4471
+ return rank[left.relationship] - rank[right.relationship] || left.siblingDistance - right.siblingDistance || left.index - right.index;
4472
+ });
4473
+ const allKeyExamples = rankedItems.map(({ item, relationship }) => ({
4474
+ itemId: item.id,
4475
+ itemType: item.itemType,
4476
+ key: item.key,
4477
+ relationship
4478
+ }));
4479
+ const allLabelExamples = rankedItems.flatMap(({ item, relationship }) => {
4480
+ const label = getItemLabel(item);
4481
+ return label ? [{
4482
+ itemId: item.id,
4483
+ itemType: item.itemType,
4484
+ key: item.key,
4485
+ label,
4486
+ relationship
4487
+ }] : [];
4488
+ });
4489
+ result.naming = {
4490
+ keyExamples: allKeyExamples.slice(0, MAX_NAMING_EXAMPLES),
4491
+ keyExampleCount: allKeyExamples.length,
4492
+ keyExamplesTruncated: allKeyExamples.length > MAX_NAMING_EXAMPLES,
4493
+ labelExamples: allLabelExamples.slice(0, MAX_NAMING_EXAMPLES),
4494
+ labelExampleCount: allLabelExamples.length,
4495
+ labelExamplesTruncated: allLabelExamples.length > MAX_NAMING_EXAMPLES
4496
+ };
4497
+ }
4498
+ const sourceLocale = options.sourceLocale;
4499
+ if (options.purpose === "translate-content" && sourceLocale) {
4500
+ const relevantLocales = [sourceLocale, ...options.targetLocales ?? []];
4501
+ const sourceTokens = new Set((options.sourceTexts ?? []).flatMap((text) => text.toLocaleLowerCase().split(/[^\p{L}\p{N}]+/u)).filter((token) => token.length >= 3));
4502
+ const allExamples = [];
4503
+ snapshot.survey.surveyItems.forEach((item, itemIndex) => {
4504
+ const contentKeys = /* @__PURE__ */ new Set();
4505
+ for (const locale of relevantLocales) {
4506
+ const itemTranslations = snapshot.survey.translations?.[locale]?.itemTranslations?.[item.id];
4507
+ for (const contentKey of Object.keys(itemTranslations ?? {})) contentKeys.add(contentKey);
4508
+ }
4509
+ for (const contentKey of contentKeys) {
4510
+ const values = {};
4511
+ for (const locale of relevantLocales) {
4512
+ const content = snapshot.survey.translations?.[locale]?.itemTranslations?.[item.id]?.[contentKey];
4513
+ if (!content) continue;
4514
+ const text = getContentPlainText(content).trim();
4515
+ if (text) values[locale] = text.slice(0, MAX_VISIBLE_TEXT_CHARS);
4516
+ }
4517
+ const sourceText = values[sourceLocale];
4518
+ if (!sourceText || Object.keys(values).length < 2) continue;
4519
+ const normalizedSource = sourceText.toLocaleLowerCase();
4520
+ const score = [...sourceTokens].reduce((total, token) => total + (normalizedSource.includes(token) ? 1 : 0), 0);
4521
+ allExamples.push({
4522
+ itemId: item.id,
4523
+ contentKey,
4524
+ values,
4525
+ score,
4526
+ order: itemIndex
4527
+ });
4528
+ }
4529
+ });
4530
+ allExamples.sort((left, right) => right.score - left.score || left.order - right.order);
4531
+ result.translation = {
4532
+ examples: allExamples.slice(0, MAX_TRANSLATION_EXAMPLES).map(({ itemId, contentKey, values }) => ({
4533
+ itemId,
4534
+ contentKey,
4535
+ values
4536
+ })),
4537
+ exampleCount: allExamples.length,
4538
+ examplesTruncated: allExamples.length > MAX_TRANSLATION_EXAMPLES
4539
+ };
4540
+ }
4541
+ return result;
4542
+ };
4543
+ //#endregion
4544
+ //#region src/engine/task-result-cache.ts
4545
+ const DEFAULT_TTL_MS = 900 * 1e3;
4546
+ const DEFAULT_MAX_ENTRIES = 64;
4547
+ /** Small in-memory LRU cache for deterministic, snapshot-scoped one-shot results. */
4548
+ var SurveyAssistantTaskResultCache = class {
4549
+ entries = /* @__PURE__ */ new Map();
4550
+ ttlMs;
4551
+ maxEntries;
4552
+ now;
4553
+ constructor(options = {}) {
4554
+ this.ttlMs = Math.max(0, options.ttlMs ?? DEFAULT_TTL_MS);
4555
+ this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES);
4556
+ this.now = options.now ?? Date.now;
4557
+ }
4558
+ get(key) {
4559
+ const entry = this.entries.get(key);
4560
+ if (!entry) return void 0;
4561
+ if (entry.expiresAt <= this.now()) {
4562
+ this.entries.delete(key);
4563
+ return;
4564
+ }
4565
+ this.entries.delete(key);
4566
+ this.entries.set(key, entry);
4567
+ return entry.value;
4568
+ }
4569
+ set(key, value) {
4570
+ this.entries.delete(key);
4571
+ this.entries.set(key, {
4572
+ expiresAt: this.now() + this.ttlMs,
4573
+ value
4574
+ });
4575
+ while (this.entries.size > this.maxEntries) {
4576
+ const oldestKey = this.entries.keys().next().value;
4577
+ if (oldestKey === void 0) break;
4578
+ this.entries.delete(oldestKey);
4579
+ }
4580
+ }
4581
+ clear() {
4582
+ this.entries.clear();
4583
+ }
4584
+ };
4585
+ //#endregion
4586
+ //#region src/engine/audit.ts
4587
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4588
+ const records = (value) => Array.isArray(value) ? value.filter(isRecord) : [];
4589
+ const strings = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
4590
+ const QUALITY_PRINCIPLES = [
4591
+ {
4592
+ id: "purpose-alignment",
4593
+ title: "Purpose and construct alignment",
4594
+ prompt: "Does every question serve a stated objective, and does it measure the intended construct?"
4595
+ },
4596
+ {
4597
+ id: "cognitive-burden",
4598
+ title: "Respondent burden",
4599
+ prompt: "Is the survey as short and cognitively simple as its purpose allows?"
4600
+ },
4601
+ {
4602
+ id: "question-wording",
4603
+ title: "Clear, neutral wording",
4604
+ prompt: "Are questions specific, neutral, answerable, and limited to one idea without assumptions or double negatives?"
4605
+ },
4606
+ {
4607
+ id: "recall",
4608
+ title: "Recall and reference periods",
4609
+ prompt: "Are recall periods explicit, realistic, and consistent with what respondents can remember?"
4610
+ },
4611
+ {
4612
+ id: "response-options",
4613
+ title: "Response option quality",
4614
+ prompt: "Are options mutually exclusive, collectively sufficient, balanced, ordered, and consistently scaled?"
4615
+ },
4616
+ {
4617
+ id: "flow",
4618
+ title: "Flow and completeness",
4619
+ prompt: "Does the order move logically from easy/general to specific, with needed instructions and no important objective omitted?"
4620
+ },
4621
+ {
4622
+ id: "routing",
4623
+ title: "Requiredness and routing",
4624
+ prompt: "Do required fields, branching, validation, and not-applicable paths avoid forcing inaccurate answers?"
4625
+ },
4626
+ {
4627
+ id: "sensitivity",
4628
+ title: "Sensitivity and ethics",
4629
+ prompt: "Are sensitive questions justified, respectfully phrased, appropriately optional, and paired with privacy context where needed?"
4630
+ },
4631
+ {
4632
+ id: "accessibility",
4633
+ title: "Accessibility and inclusion",
4634
+ prompt: "Is language plain and inclusive, and are content, controls, and images understandable without avoidable barriers?"
4635
+ },
4636
+ {
4637
+ id: "localization",
4638
+ title: "Cross-language equivalence",
4639
+ prompt: "Do translations preserve meaning and cultural relevance rather than merely matching words?"
4640
+ },
4641
+ {
4642
+ id: "testing",
4643
+ title: "Testing and validation",
4644
+ prompt: "Has the instrument been cognitively tested, piloted, and checked for reliability or validity where the intended use requires it?"
4645
+ }
4646
+ ];
4647
+ const translationText = (context, locale, itemId, contentKey) => {
4648
+ const localeTranslations = isRecord(context.survey.translations?.[locale]) ? context.survey.translations?.[locale] : void 0;
4649
+ const itemTranslations = isRecord(localeTranslations?.itemTranslations) ? localeTranslations.itemTranslations : void 0;
4650
+ const content = (isRecord(itemTranslations?.[itemId]) ? itemTranslations[itemId] : void 0)?.[contentKey];
4651
+ return content ? getContentPlainText(content).trim() : "";
4652
+ };
4653
+ const getGroups = (item, optionId) => {
4654
+ const config = isRecord(item.config) ? item.config : {};
4655
+ if (!optionId) return records(config.fieldGroups);
4656
+ const option = records(config.options).find((entry) => entry.id === optionId);
4657
+ return records(isRecord(option?.embeddedForm) ? option.embeddedForm.fieldGroups : void 0);
4658
+ };
4659
+ const auditSurvey = (context, options = {}) => {
4660
+ const findings = [];
4661
+ const add = (finding) => findings.push(finding);
4662
+ const items = context.survey.surveyItems;
4663
+ const itemsById = /* @__PURE__ */ new Map();
4664
+ const parentById = /* @__PURE__ */ new Map();
4665
+ const itemTypes = {};
4666
+ for (const item of items) {
4667
+ itemTypes[item.itemType] = (itemTypes[item.itemType] ?? 0) + 1;
4668
+ if (itemsById.has(item.id)) add({
4669
+ code: "duplicate-item-id",
4670
+ severity: "error",
4671
+ category: "structure",
4672
+ itemId: item.id,
4673
+ message: `Item id "${item.id}" is duplicated.`
4674
+ });
4675
+ itemsById.set(item.id, item);
4676
+ }
4677
+ for (const parent of items) {
4678
+ const seenChildren = /* @__PURE__ */ new Set();
4679
+ for (const childId of strings(isRecord(parent.config) ? parent.config.items : void 0)) {
4680
+ if (seenChildren.has(childId)) add({
4681
+ code: "duplicate-child-reference",
4682
+ severity: "error",
4683
+ category: "structure",
4684
+ itemId: parent.id,
4685
+ message: `Child "${childId}" is listed more than once.`
4686
+ });
4687
+ seenChildren.add(childId);
4688
+ if (!itemsById.has(childId)) add({
4689
+ code: "missing-child",
4690
+ severity: "error",
4691
+ category: "structure",
4692
+ itemId: parent.id,
4693
+ message: `Referenced child "${childId}" does not exist.`
4694
+ });
4695
+ else if (parentById.has(childId)) add({
4696
+ code: "multiple-parents",
4697
+ severity: "error",
4698
+ category: "structure",
4699
+ itemId: childId,
4700
+ message: `Item is referenced by multiple parents.`
4701
+ });
4702
+ else parentById.set(childId, parent.id);
4703
+ }
4704
+ }
4705
+ const roots = items.filter((item) => !parentById.has(item.id));
4706
+ if (roots.length !== 1) add({
4707
+ code: "root-count",
4708
+ severity: "error",
4709
+ category: "structure",
4710
+ message: `Expected one survey root but found ${roots.length}.`
4711
+ });
4712
+ for (const parent of items) {
4713
+ const siblingKeys = /* @__PURE__ */ new Map();
4714
+ for (const childId of strings(isRecord(parent.config) ? parent.config.items : void 0)) {
4715
+ const child = itemsById.get(childId);
4716
+ if (!child) continue;
4717
+ const existing = siblingKeys.get(child.key);
4718
+ if (existing) add({
4719
+ code: "duplicate-sibling-key",
4720
+ severity: "error",
4721
+ category: "structure",
4722
+ itemId: child.id,
4723
+ message: `Key "${child.key}" duplicates sibling "${existing}".`
4724
+ });
4725
+ siblingKeys.set(child.key, child.id);
4726
+ }
4727
+ }
4728
+ const locales = context.inspection.locales;
4729
+ const sourceLocale = options.sourceLocale ?? context.ui.selectedLocale ?? locales[0];
4730
+ const targetLocales = options.targetLocales ?? locales.filter((locale) => locale !== sourceLocale);
4731
+ const manifests = new Map(context.capabilities.itemTypes.map((entry) => [entry.itemType, entry]));
4732
+ for (const item of items) {
4733
+ const manifest = manifests.get(item.itemType);
4734
+ if (!manifest) {
4735
+ add({
4736
+ code: "unsupported-item-type",
4737
+ severity: "warning",
4738
+ category: "structure",
4739
+ itemId: item.id,
4740
+ message: `Item type "${item.itemType}" is not advertised by the active editor.`
4741
+ });
4742
+ continue;
4743
+ }
4744
+ for (const surface of manifest.contentSurfaces.filter((entry) => entry.requiredOnCreate && !entry.pattern)) if (!translationText(context, sourceLocale, item.id, surface.key)) add({
4745
+ code: "missing-primary-content",
4746
+ severity: "warning",
4747
+ category: "content",
4748
+ itemId: item.id,
4749
+ locale: sourceLocale,
4750
+ contentKey: surface.key,
4751
+ message: `Required respondent content "${surface.key}" is empty in ${sourceLocale}.`
4752
+ });
4753
+ const sourceContent = isRecord(context.survey.translations?.[sourceLocale]?.itemTranslations?.[item.id]) ? context.survey.translations?.[sourceLocale]?.itemTranslations?.[item.id] : {};
4754
+ for (const [contentKey, content] of Object.entries(sourceContent ?? {})) {
4755
+ if (!getContentPlainText(content).trim()) continue;
4756
+ for (const locale of targetLocales) if (!translationText(context, locale, item.id, contentKey)) add({
4757
+ code: "missing-translation",
4758
+ severity: "warning",
4759
+ category: "localization",
4760
+ itemId: item.id,
4761
+ locale,
4762
+ contentKey,
4763
+ message: `Content "${contentKey}" has source text but no ${locale} translation.`
4764
+ });
4765
+ }
4766
+ const config = isRecord(item.config) ? item.config : {};
4767
+ if (item.itemType === "choiceItem") {
4768
+ const optionsList = records(config.options);
4769
+ if (optionsList.length === 0) add({
4770
+ code: "choice-without-options",
4771
+ severity: "error",
4772
+ category: "content",
4773
+ itemId: item.id,
4774
+ message: "Choice item has no options."
4775
+ });
4776
+ for (const option of optionsList) {
4777
+ if (typeof option.id === "string" && !translationText(context, sourceLocale, item.id, `options.${option.id}.label`)) add({
4778
+ code: "missing-option-label",
4779
+ severity: "warning",
4780
+ category: "content",
4781
+ itemId: item.id,
4782
+ locale: sourceLocale,
4783
+ contentKey: `options.${option.id}.label`,
4784
+ message: `Choice option "${option.id}" has no visible label.`
4785
+ });
4786
+ if (typeof option.id === "string" && option.embeddedForm) {
4787
+ for (const group of getGroups(item, option.id)) for (const field of records(group.fields)) if (typeof field.id === "string" && !translationText(context, sourceLocale, item.id, `embeddedForms.${option.id}.fields.${field.id}.label`)) add({
4788
+ code: "missing-field-label",
4789
+ severity: "warning",
4790
+ category: "content",
4791
+ itemId: item.id,
4792
+ locale: sourceLocale,
4793
+ contentKey: `embeddedForms.${option.id}.fields.${field.id}.label`,
4794
+ message: `Embedded field "${field.id}" has no visible label.`
4795
+ });
4796
+ }
4797
+ }
4798
+ }
4799
+ if (item.itemType === "formItem") {
4800
+ const groups = getGroups(item);
4801
+ if (groups.length === 0) add({
4802
+ code: "form-without-groups",
4803
+ severity: "error",
4804
+ category: "content",
4805
+ itemId: item.id,
4806
+ message: "Form item has no field groups."
4807
+ });
4808
+ for (const group of groups) for (const field of records(group.fields)) if (typeof field.id === "string" && !translationText(context, sourceLocale, item.id, `fields.${field.id}.label`)) add({
4809
+ code: "missing-field-label",
4810
+ severity: "warning",
4811
+ category: "content",
4812
+ itemId: item.id,
4813
+ locale: sourceLocale,
4814
+ contentKey: `fields.${field.id}.label`,
4815
+ message: `Form field "${field.id}" has no visible label.`
4816
+ });
4817
+ }
4818
+ }
4819
+ const assets = context.survey.assets ?? {};
4820
+ for (const [assetId, asset] of Object.entries(assets)) {
4821
+ const metadata = isRecord(asset.metadata) ? asset.metadata : {};
4822
+ if (asset.kind === "image" && typeof metadata.alt !== "string") add({
4823
+ code: "missing-image-alt",
4824
+ severity: "warning",
4825
+ category: "assets",
4826
+ message: `Image asset "${assetId}" has no alternative text.`,
4827
+ suggestion: "Add concise alt text, or an empty alt value when the image is purely decorative."
4828
+ });
4829
+ }
4830
+ const limited = findings.slice(0, options.maxFindings ?? 200);
4831
+ return {
4832
+ summary: limited.reduce((acc, entry) => {
4833
+ acc[entry.severity] += 1;
4834
+ acc.total += 1;
4835
+ return acc;
4836
+ }, {
4837
+ error: 0,
4838
+ warning: 0,
4839
+ info: 0,
4840
+ total: 0
4841
+ }),
4842
+ metrics: {
4843
+ itemCount: items.length,
4844
+ localeCount: locales.length,
4845
+ responseSlotCount: context.inspection.responseSlots.length,
4846
+ assetCount: Object.keys(assets).length,
4847
+ itemTypes
4848
+ },
4849
+ findings: limited,
4850
+ qualityReviewPrinciples: QUALITY_PRINCIPLES,
4851
+ note: "Technical findings are deterministic. Quality principles are a structured review rubric; apply them with human/model judgment and domain context, and do not present them as psychometric validation."
4852
+ };
4853
+ };
4854
+ //#endregion
4855
+ export { deriveResponseSlotTypes as $, getRawSurvey$1 as A, resolveItemSummary as B, clampLimit as C, formFieldGroupLayoutSchema as D, formFieldGroupDetailsSchema as E, mapKeyIndexById as F, summarizeKeyIndexEntry as G, summarizeFocusItemNode as H, mapOutlineById as I, translationEntrySchema as J, summarizeOutlineNode as K, mapRawItemsById as L, itemConfigDetailsSchema as M, itemMatchesQuery as N, formSelectOptionDetailsSchema as O, itemSummarySchema as P, createSurveyAssistantEngineContext as Q, normalizeText as R, choiceOptionDetailsSchema as S, formFieldDetailsSchema as T, summarizeItemById as U, sliceWithCursor as V, summarizeKeyIndexEntries as W, inferExpressionReturnType as X, unique as Y, validateExpression as Z, buildConfigSearchText as _, SURVEY_CHANGE_CONTINUATION_MODES as a, buildItemTranslationEntries as b, buildSurveyDocumentVersion as c, normalizeSurveyChangeOperations as d, buildExpressionMutationPatches as f, MAX_LIMIT as g, DEFAULT_LIMIT as h, SURVEY_CHANGE_CONTINUATION_DESCRIPTION as i, getRawSurveyItems as j, getKeyIndexEntries as k, createSurveyChangeSet as l, expressionSchema as m, SurveyAssistantTaskResultCache as n, TurnChangeCollector as o, expressionMutationSchema as p, summarizeRawItem as q, buildSurveyAssistantTaskContext as r, MAX_SURVEY_ASSISTANT_OPERATIONS_PER_PROPOSAL as s, auditSurvey as t, validateSurveyOperations as u, buildEmbeddedFormDetails as v, findDirectChildrenByFullKey as w, buildSurveyTranslations as x, buildItemConfigDetails as y, parseCursor as z };
4856
+
4857
+ //# sourceMappingURL=engine-sPvuixWg.mjs.map