@case-framework/survey-core 0.8.0 → 0.9.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.
- package/README.md +126 -244
- package/build/answer-types-DH0gv0Ea.d.mts +20 -0
- package/build/answer-types-DH0gv0Ea.d.mts.map +1 -0
- package/build/answer-types-Dtm6MqZQ.mjs +19 -0
- package/build/answer-types-Dtm6MqZQ.mjs.map +1 -0
- package/build/editor/colors.d.mts +11 -2
- package/build/editor/colors.d.mts.map +1 -0
- package/build/editor.d.mts +72913 -266
- package/build/editor.d.mts.map +1 -1
- package/build/editor.mjs +1268 -1001
- package/build/editor.mjs.map +1 -1
- package/build/index.d.mts +2 -3
- package/build/index.mjs +1620 -824
- package/build/index.mjs.map +1 -1
- package/build/keyed-object-D1yh_W5v.mjs +45 -0
- package/build/keyed-object-D1yh_W5v.mjs.map +1 -0
- package/build/localization-1lzNpyFX.mjs +1373 -0
- package/build/localization-1lzNpyFX.mjs.map +1 -0
- package/build/package.json +9 -3
- package/build/projection-DrLPFroE.mjs +2344 -0
- package/build/projection-DrLPFroE.mjs.map +1 -0
- package/build/registry-BOXcyWfX.d.mts +54292 -0
- package/build/registry-BOXcyWfX.d.mts.map +1 -0
- package/build/schema.d.mts +3 -0
- package/build/schema.mjs +4 -0
- package/package.json +9 -3
- package/build/index-SDbZ5WQM.d.mts +0 -2208
- package/build/index-SDbZ5WQM.d.mts.map +0 -1
- package/build/item-colors-DgoJeSCQ.d.mts +0 -12
- package/build/item-colors-DgoJeSCQ.d.mts.map +0 -1
- package/build/projection-Bl6qCinx.mjs +0 -3930
- package/build/projection-Bl6qCinx.mjs.map +0 -1
|
@@ -0,0 +1,2344 @@
|
|
|
1
|
+
import { $ as inspectAnnotated, B as ValueType, Ct as calendarDaySchema, Dt as isCalendarDate, Et as formatCalendarDate, G as stringRecord, H as isResponseValue, L as surveyContentSchema, O as itemEditorMetadataSchema, Ot as isCalendarDateUnit, R as DurationUnits, Tt as differenceCalendarDates, U as ownValue, W as setOwnValue, _t as CALENDAR_DATE_UNITS, b as CURRENT_SURVEY_SCHEMA, ct as responseValueSchema, d as validateLocalizationRef, gt as visitExpression, h as getContentPlainText, ht as unreachable, l as readLocalizationContent, m as getAssetUsagesFromContent, mt as transformExpression, ot as expressionSchema, pt as operand, q as annotatedReferences, t as SURVEY_LOCALIZATION_SURFACES, ut as collectExpressionDependencies, vt as addCalendarDate, wt as compareCalendarDates, z as NumberPrecision } from "./localization-1lzNpyFX.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/utils.ts
|
|
4
|
+
function hashString(seed) {
|
|
5
|
+
let hash = 2166136261;
|
|
6
|
+
for (let i = 0; i < seed.length; i++) {
|
|
7
|
+
hash ^= seed.charCodeAt(i);
|
|
8
|
+
hash = Math.imul(hash, 16777619);
|
|
9
|
+
}
|
|
10
|
+
return hash >>> 0;
|
|
11
|
+
}
|
|
12
|
+
function createSeededRandom(seed) {
|
|
13
|
+
let state = hashString(seed);
|
|
14
|
+
return () => {
|
|
15
|
+
state = state + 1831565813 >>> 0;
|
|
16
|
+
let next = state;
|
|
17
|
+
next = Math.imul(next ^ next >>> 15, next | 1);
|
|
18
|
+
next ^= next + Math.imul(next ^ next >>> 7, next | 61);
|
|
19
|
+
return ((next ^ next >>> 14) >>> 0) / 4294967296;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function shuffleArray(values, random = Math.random) {
|
|
23
|
+
const shuffledValues = [...values];
|
|
24
|
+
for (let i = shuffledValues.length - 1; i > 0; i--) {
|
|
25
|
+
const j = Math.floor(random() * (i + 1));
|
|
26
|
+
[shuffledValues[i], shuffledValues[j]] = [shuffledValues[j], shuffledValues[i]];
|
|
27
|
+
}
|
|
28
|
+
return shuffledValues;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Shuffles an array of indices using the Fisher-Yates shuffle algorithm
|
|
32
|
+
* @param length - The length of the array to create indices for
|
|
33
|
+
* @returns A shuffled array of indices from 0 to length-1
|
|
34
|
+
*/
|
|
35
|
+
function shuffleIndices(length, random = Math.random) {
|
|
36
|
+
return shuffleArray(Array.from({ length }, (_, i) => i), random);
|
|
37
|
+
}
|
|
38
|
+
function structuredCloneMethod(obj) {
|
|
39
|
+
if (obj === void 0) return obj;
|
|
40
|
+
if (typeof structuredClone !== "undefined") return structuredClone(obj);
|
|
41
|
+
return JSON.parse(JSON.stringify(obj));
|
|
42
|
+
}
|
|
43
|
+
const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
44
|
+
function generateId() {
|
|
45
|
+
const length = 10;
|
|
46
|
+
const timestamp = Date.now();
|
|
47
|
+
let encodedTimestamp = "";
|
|
48
|
+
let t = timestamp;
|
|
49
|
+
const base = 62;
|
|
50
|
+
while (t > 0) {
|
|
51
|
+
encodedTimestamp = ID_ALPHABET.charAt(t % base) + encodedTimestamp;
|
|
52
|
+
t = Math.floor(t / base);
|
|
53
|
+
}
|
|
54
|
+
let randomPart = "";
|
|
55
|
+
for (let i = 0; i < length; i++) randomPart += ID_ALPHABET.charAt(Math.floor(Math.random() * base));
|
|
56
|
+
return randomPart + encodedTimestamp;
|
|
57
|
+
}
|
|
58
|
+
/** Alphanumeric chars without ambiguous ones (0/O, 1/I) for human-readable coding keys. */
|
|
59
|
+
const CODING_KEY_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
60
|
+
/**
|
|
61
|
+
* Generates a short random coding key suitable for item keys in exports/codebooks.
|
|
62
|
+
* @param length - Key length (default 4)
|
|
63
|
+
* @returns A random string of the specified length.
|
|
64
|
+
*/
|
|
65
|
+
function generateCodingKey(length = 4) {
|
|
66
|
+
const base = 32;
|
|
67
|
+
let key = "";
|
|
68
|
+
for (let i = 0; i < length; i++) key += CODING_KEY_ALPHABET.charAt(Math.floor(Math.random() * base));
|
|
69
|
+
return key;
|
|
70
|
+
}
|
|
71
|
+
/** Resolve a sibling key using the editor's stable numeric suffix convention. */
|
|
72
|
+
function resolveUniqueItemKey(key, siblingKeys) {
|
|
73
|
+
let candidate = key;
|
|
74
|
+
let suffix = 1;
|
|
75
|
+
while (siblingKeys.has(candidate)) candidate = `${key}_${suffix++}`;
|
|
76
|
+
return candidate;
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/expressions/function-signatures.ts
|
|
80
|
+
const comparison = {
|
|
81
|
+
min: 2,
|
|
82
|
+
max: 2,
|
|
83
|
+
required: [0, 1],
|
|
84
|
+
returns: "boolean"
|
|
85
|
+
};
|
|
86
|
+
const numericAggregate = {
|
|
87
|
+
min: 1,
|
|
88
|
+
required: [],
|
|
89
|
+
variadicGaps: true,
|
|
90
|
+
returns: "number"
|
|
91
|
+
};
|
|
92
|
+
const dateAdd = {
|
|
93
|
+
min: 2,
|
|
94
|
+
max: 2,
|
|
95
|
+
required: [0, 1],
|
|
96
|
+
returns: "date"
|
|
97
|
+
};
|
|
98
|
+
/** The exhaustive signature table for every serialized function name. */
|
|
99
|
+
const FUNCTION_SIGNATURES = {
|
|
100
|
+
and: {
|
|
101
|
+
min: 0,
|
|
102
|
+
required: [],
|
|
103
|
+
variadicGaps: true,
|
|
104
|
+
returns: "boolean"
|
|
105
|
+
},
|
|
106
|
+
or: {
|
|
107
|
+
min: 0,
|
|
108
|
+
required: [],
|
|
109
|
+
variadicGaps: true,
|
|
110
|
+
returns: "boolean"
|
|
111
|
+
},
|
|
112
|
+
not: {
|
|
113
|
+
min: 1,
|
|
114
|
+
max: 1,
|
|
115
|
+
required: [0],
|
|
116
|
+
returns: "boolean"
|
|
117
|
+
},
|
|
118
|
+
list_contains: comparison,
|
|
119
|
+
list_count: {
|
|
120
|
+
min: 1,
|
|
121
|
+
max: 2,
|
|
122
|
+
required: [0],
|
|
123
|
+
returns: "number"
|
|
124
|
+
},
|
|
125
|
+
if: {
|
|
126
|
+
min: 2,
|
|
127
|
+
max: 3,
|
|
128
|
+
required: [0, 1],
|
|
129
|
+
returns: { fromArguments: [1, 2] }
|
|
130
|
+
},
|
|
131
|
+
eq: comparison,
|
|
132
|
+
str_eq: comparison,
|
|
133
|
+
gt: comparison,
|
|
134
|
+
gte: comparison,
|
|
135
|
+
lt: comparison,
|
|
136
|
+
lte: comparison,
|
|
137
|
+
in_range: {
|
|
138
|
+
min: 4,
|
|
139
|
+
max: 4,
|
|
140
|
+
required: [
|
|
141
|
+
0,
|
|
142
|
+
1,
|
|
143
|
+
2
|
|
144
|
+
],
|
|
145
|
+
returns: "boolean"
|
|
146
|
+
},
|
|
147
|
+
sum: numericAggregate,
|
|
148
|
+
min: numericAggregate,
|
|
149
|
+
max: numericAggregate,
|
|
150
|
+
date_eq: comparison,
|
|
151
|
+
date_gt: comparison,
|
|
152
|
+
date_gte: comparison,
|
|
153
|
+
date_lt: comparison,
|
|
154
|
+
date_lte: comparison,
|
|
155
|
+
date_add_days: dateAdd,
|
|
156
|
+
date_add_months: dateAdd,
|
|
157
|
+
date_add_years: dateAdd,
|
|
158
|
+
date_diff: {
|
|
159
|
+
min: 3,
|
|
160
|
+
max: 3,
|
|
161
|
+
required: [
|
|
162
|
+
0,
|
|
163
|
+
1,
|
|
164
|
+
2
|
|
165
|
+
],
|
|
166
|
+
returns: "number"
|
|
167
|
+
},
|
|
168
|
+
date_min: {
|
|
169
|
+
min: 1,
|
|
170
|
+
required: [0],
|
|
171
|
+
returns: "date"
|
|
172
|
+
},
|
|
173
|
+
date_max: {
|
|
174
|
+
min: 1,
|
|
175
|
+
required: [0],
|
|
176
|
+
returns: "date"
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
/** Return a diagnostic message when a function has too few or too many arguments. */
|
|
180
|
+
function arityIssue(expression) {
|
|
181
|
+
const signature = FUNCTION_SIGNATURES[expression.functionName];
|
|
182
|
+
if (expression.arguments.length < signature.min || signature.max !== void 0 && expression.arguments.length > signature.max) return `${expression.functionName} expects ${signature.max === signature.min ? signature.min : signature.max === void 0 ? `at least ${signature.min}` : `${signature.min} to ${signature.max}`} arguments`;
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/survey/responses/value-domain.ts
|
|
186
|
+
/** Finite domains describe individual selectable values, including for array slots. */
|
|
187
|
+
function responseValueElements(value) {
|
|
188
|
+
if (!Array.isArray(value.value)) return [value];
|
|
189
|
+
const type = value.type.slice(0, -2);
|
|
190
|
+
return value.value.map((entry) => ({
|
|
191
|
+
...value,
|
|
192
|
+
type,
|
|
193
|
+
value: entry
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
function responseValuesEqual(left, right) {
|
|
197
|
+
return left.type === right.type && JSON.stringify(left.value) === JSON.stringify(right.value) && (!("unit" in left) || "unit" in right && left.unit === right.unit);
|
|
198
|
+
}
|
|
199
|
+
/** No coercion: an export code or label is never a categorical option identity. */
|
|
200
|
+
function invalidResponseDomainValues(value, allowedValues) {
|
|
201
|
+
const domain = allowedValues.flatMap(responseValueElements);
|
|
202
|
+
return responseValueElements(value).filter((entry) => !domain.some((allowed) => responseValuesEqual(entry, allowed)));
|
|
203
|
+
}
|
|
204
|
+
function validateResponseSlotValue(slot, value) {
|
|
205
|
+
if (!isResponseValue(value)) return [`Slot "${slot.slotId}" has an invalid typed value.`];
|
|
206
|
+
if (value.type !== slot.valueType) return [`Slot "${slot.slotId}" requires ${slot.valueType}, received ${value.type}.`];
|
|
207
|
+
if (!slot.allowedValues) return [];
|
|
208
|
+
return invalidResponseDomainValues(value, slot.allowedValues).map((entry) => `Slot "${slot.slotId}" contains invalid value ${JSON.stringify(entry)}. Allowed values: ${JSON.stringify(slot.allowedValues)}.`);
|
|
209
|
+
}
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/expressions/type-inference.ts
|
|
212
|
+
/** Infer expression result types from node shape and the survey's response slot types. */
|
|
213
|
+
/** Infer a node's result type without evaluating it; unknown slot or context types remain undefined. */
|
|
214
|
+
const inferExpressionReturnType = (expression, responseSlotTypes) => expression && visitExpression(expression, {
|
|
215
|
+
const: (node) => node.value?.type,
|
|
216
|
+
responseVariable: (node) => node.variableRef.method === "isDefined" ? "boolean" : ownValue(responseSlotTypes, node.variableRef.slotId),
|
|
217
|
+
ownerState: () => "boolean",
|
|
218
|
+
contextVariable: (node) => node.contextType === "locale" ? "string" : node.contextType === "currentDate" ? "date" : node.contextType === "participantFlag" ? node.asType ?? "string" : node.asType,
|
|
219
|
+
function: (node) => {
|
|
220
|
+
const { returns } = FUNCTION_SIGNATURES[node.functionName];
|
|
221
|
+
if (typeof returns === "string") return returns;
|
|
222
|
+
for (const index of returns.fromArguments) {
|
|
223
|
+
const type = inferExpressionReturnType(operand(node.arguments, index), responseSlotTypes);
|
|
224
|
+
if (type) return type;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/expressions/validation.ts
|
|
230
|
+
/** Report structural and type problems in authored expressions before graph execution. */
|
|
231
|
+
/** Describe operand type mismatches for one function call using known response slot types. */
|
|
232
|
+
function functionTypeIssues(node, types) {
|
|
233
|
+
const args = Array.from(node.arguments, (_, index) => inferExpressionReturnType(operand(node.arguments, index), types));
|
|
234
|
+
const messages = [];
|
|
235
|
+
const requireType = (index, expected) => {
|
|
236
|
+
const actual = args[index];
|
|
237
|
+
if (actual && actual !== expected) messages.push(`${node.functionName} argument ${index + 1} must be ${expected}, got ${actual}.`);
|
|
238
|
+
};
|
|
239
|
+
const all = (expected) => args.forEach((_, index) => requireType(index, expected));
|
|
240
|
+
const same = () => {
|
|
241
|
+
if (args[0] && args[1] && args[0] !== args[1]) messages.push(`${node.functionName} arguments must have the same type, got ${args[0]} and ${args[1]}.`);
|
|
242
|
+
};
|
|
243
|
+
const name = node.functionName;
|
|
244
|
+
switch (name) {
|
|
245
|
+
case "and":
|
|
246
|
+
case "or":
|
|
247
|
+
case "not":
|
|
248
|
+
all("boolean");
|
|
249
|
+
break;
|
|
250
|
+
case "eq":
|
|
251
|
+
case "gt":
|
|
252
|
+
case "gte":
|
|
253
|
+
case "lt":
|
|
254
|
+
case "lte":
|
|
255
|
+
same();
|
|
256
|
+
break;
|
|
257
|
+
case "str_eq":
|
|
258
|
+
all("string");
|
|
259
|
+
break;
|
|
260
|
+
case "sum":
|
|
261
|
+
case "min":
|
|
262
|
+
case "max":
|
|
263
|
+
all("number");
|
|
264
|
+
break;
|
|
265
|
+
case "in_range":
|
|
266
|
+
[
|
|
267
|
+
0,
|
|
268
|
+
1,
|
|
269
|
+
2
|
|
270
|
+
].forEach((index) => requireType(index, "number"));
|
|
271
|
+
requireType(3, "boolean");
|
|
272
|
+
break;
|
|
273
|
+
case "date_eq":
|
|
274
|
+
case "date_gt":
|
|
275
|
+
case "date_gte":
|
|
276
|
+
case "date_lt":
|
|
277
|
+
case "date_lte":
|
|
278
|
+
case "date_min":
|
|
279
|
+
case "date_max":
|
|
280
|
+
all("date");
|
|
281
|
+
break;
|
|
282
|
+
case "date_diff": {
|
|
283
|
+
requireType(0, "date");
|
|
284
|
+
requireType(1, "date");
|
|
285
|
+
requireType(2, "string");
|
|
286
|
+
const unit = operand(node.arguments, 2);
|
|
287
|
+
if (unit?.type === "const" && unit.value?.type === "string" && !isCalendarDateUnit(unit.value.value)) messages.push(`date_diff argument 3 must be one of ${CALENDAR_DATE_UNITS.join(", ")}, got ${JSON.stringify(unit.value.value)}.`);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case "date_add_days":
|
|
291
|
+
case "date_add_months":
|
|
292
|
+
case "date_add_years":
|
|
293
|
+
requireType(0, "date");
|
|
294
|
+
requireType(1, "number");
|
|
295
|
+
break;
|
|
296
|
+
case "list_contains":
|
|
297
|
+
if (args[0] && args[0] !== "string[]" && args[0] !== "reference[]") messages.push(`list_contains argument 1 must be string[] or reference[], got ${args[0]}. Use eq for single-choice reference responses.`);
|
|
298
|
+
if (args[0] === "string[]") requireType(1, "string");
|
|
299
|
+
if (args[0] === "reference[]") requireType(1, "reference");
|
|
300
|
+
break;
|
|
301
|
+
case "list_count":
|
|
302
|
+
if (args[0] && !args[0].endsWith("[]")) messages.push(`list_count argument 1 must be a list, got ${args[0]}.`);
|
|
303
|
+
if (args[1] && !args[1].endsWith("[]")) messages.push(`list_count argument 2 must be a list, got ${args[1]}.`);
|
|
304
|
+
else if (args[0]?.endsWith("[]")) requireType(1, args[0]);
|
|
305
|
+
break;
|
|
306
|
+
case "if":
|
|
307
|
+
requireType(0, "boolean");
|
|
308
|
+
if (args[1] && args[2] && args[1] !== args[2]) messages.push(`if branches must have the same type, got ${args[1]} and ${args[2]}.`);
|
|
309
|
+
break;
|
|
310
|
+
default: unreachable(name);
|
|
311
|
+
}
|
|
312
|
+
return messages;
|
|
313
|
+
}
|
|
314
|
+
/** Locate malformed function calls and unfinished required operands in an expression tree. */
|
|
315
|
+
function expressionIssues(expression) {
|
|
316
|
+
const issues = [];
|
|
317
|
+
transformExpression(expression, {
|
|
318
|
+
const: (node) => node,
|
|
319
|
+
responseVariable: (node) => node,
|
|
320
|
+
ownerState: (node) => node,
|
|
321
|
+
contextVariable: (node) => node,
|
|
322
|
+
function: (node, path) => {
|
|
323
|
+
const signature = FUNCTION_SIGNATURES[node.functionName];
|
|
324
|
+
const arity = arityIssue(node);
|
|
325
|
+
if (arity) issues.push({
|
|
326
|
+
path,
|
|
327
|
+
message: arity
|
|
328
|
+
});
|
|
329
|
+
for (let index = 0; index < node.arguments.length; index++) if (!operand(node.arguments, index) && (signature.required.includes(index) || !signature.variadicGaps && signature.max === void 0)) issues.push({
|
|
330
|
+
path: [
|
|
331
|
+
...path,
|
|
332
|
+
"arguments",
|
|
333
|
+
index
|
|
334
|
+
],
|
|
335
|
+
message: `Operand ${index + 1} of ${node.functionName} is unfinished`
|
|
336
|
+
});
|
|
337
|
+
return node;
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
return issues;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Locate result, operand, and context key type errors. When option domains are supplied,
|
|
344
|
+
* also flag comparisons with option identities outside the response slot's domain.
|
|
345
|
+
*/
|
|
346
|
+
function expressionTypeIssues(expression, types, expectedReturnType, domains) {
|
|
347
|
+
const issues = [];
|
|
348
|
+
transformExpression(expression, {
|
|
349
|
+
const: (node) => node,
|
|
350
|
+
responseVariable: (node) => node,
|
|
351
|
+
ownerState: (node) => node,
|
|
352
|
+
contextVariable: (node, path) => {
|
|
353
|
+
const keyType = inferExpressionReturnType(node.key, types);
|
|
354
|
+
if (keyType && keyType !== "string") issues.push({
|
|
355
|
+
path: [...path, "key"],
|
|
356
|
+
message: `Context keys must be string, got ${keyType}.`
|
|
357
|
+
});
|
|
358
|
+
return node;
|
|
359
|
+
},
|
|
360
|
+
function: (node, path) => {
|
|
361
|
+
for (const message of functionTypeIssues(node, types)) issues.push({
|
|
362
|
+
path,
|
|
363
|
+
message
|
|
364
|
+
});
|
|
365
|
+
if ([
|
|
366
|
+
"eq",
|
|
367
|
+
"list_contains",
|
|
368
|
+
"list_count"
|
|
369
|
+
].includes(node.functionName)) for (const [sourceIndex, valueIndex] of [[0, 1], [1, 0]]) {
|
|
370
|
+
const source = operand(node.arguments, sourceIndex), value = operand(node.arguments, valueIndex);
|
|
371
|
+
if (source?.type !== "responseVariable" || source.variableRef.method !== "get" || value?.type !== "const" || !value.value) continue;
|
|
372
|
+
const allowed = domains?.get(source.variableRef.slotId);
|
|
373
|
+
for (const invalid of allowed ? invalidResponseDomainValues(value.value, allowed) : []) issues.push({
|
|
374
|
+
path: [
|
|
375
|
+
...path,
|
|
376
|
+
"arguments",
|
|
377
|
+
valueIndex
|
|
378
|
+
],
|
|
379
|
+
message: `Slot '${source.variableRef.slotId}' comparison contains an invalid value ${JSON.stringify(invalid.value)}; use a declared option identity, not an export code.`,
|
|
380
|
+
kind: "reference"
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return node;
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
const actual = inferExpressionReturnType(expression, types);
|
|
387
|
+
if (expectedReturnType && actual && actual !== expectedReturnType) issues.push({
|
|
388
|
+
path: [],
|
|
389
|
+
message: `Must return ${expectedReturnType}, but this expression returns ${actual}.`
|
|
390
|
+
});
|
|
391
|
+
return issues;
|
|
392
|
+
}
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/expressions/evaluator.ts
|
|
395
|
+
/** Evaluate canonical expressions against current answers, owner state, and session context. */
|
|
396
|
+
/** Raised when evaluation encounters an invalid function call or incompatible runtime value. */
|
|
397
|
+
var ExpressionEvaluationError = class extends Error {};
|
|
398
|
+
const boolean = (value) => ({
|
|
399
|
+
type: "boolean",
|
|
400
|
+
value
|
|
401
|
+
});
|
|
402
|
+
const number = (value) => ({
|
|
403
|
+
type: "number",
|
|
404
|
+
value
|
|
405
|
+
});
|
|
406
|
+
function evaluatedDates(values) {
|
|
407
|
+
const dates = [];
|
|
408
|
+
for (const value of values) {
|
|
409
|
+
if (value?.type !== "date" || !isCalendarDate(value.value)) return void 0;
|
|
410
|
+
dates.push(value.value);
|
|
411
|
+
}
|
|
412
|
+
return dates.length ? dates : void 0;
|
|
413
|
+
}
|
|
414
|
+
function comparableDates(values) {
|
|
415
|
+
const dates = evaluatedDates(values);
|
|
416
|
+
if (!dates || dates.some((date) => date.length !== dates[0].length)) return void 0;
|
|
417
|
+
return dates;
|
|
418
|
+
}
|
|
419
|
+
/** Evaluate the canonical tree; missing data returns undefined, while invalid calls can throw. */
|
|
420
|
+
function evaluateExpression(node, context) {
|
|
421
|
+
if (!node) return void 0;
|
|
422
|
+
const evaluate = (child) => evaluateExpression(child, context);
|
|
423
|
+
return visitExpression(node, {
|
|
424
|
+
const: (expression) => expression.value,
|
|
425
|
+
responseVariable: (expression) => {
|
|
426
|
+
const answer = context.answer(expression.variableRef.slotId);
|
|
427
|
+
return expression.variableRef.method === "get" ? answer : boolean(answer !== void 0);
|
|
428
|
+
},
|
|
429
|
+
ownerState: (expression) => boolean(context.disabled(expression.targetRef.ownerId)),
|
|
430
|
+
contextVariable: (expression) => {
|
|
431
|
+
if (expression.contextType === "locale") return context.locale === void 0 ? void 0 : {
|
|
432
|
+
type: "string",
|
|
433
|
+
value: context.locale
|
|
434
|
+
};
|
|
435
|
+
if (expression.contextType === "currentDate") return context.currentDate === void 0 ? void 0 : {
|
|
436
|
+
type: "date",
|
|
437
|
+
value: context.currentDate
|
|
438
|
+
};
|
|
439
|
+
const key = evaluate(expression.key);
|
|
440
|
+
if (key?.type !== "string") return void 0;
|
|
441
|
+
if (expression.contextType === "participantFlag") {
|
|
442
|
+
const value = ownValue(context.participantFlags, key.value);
|
|
443
|
+
if (expression.asType === "boolean") return boolean(value !== void 0);
|
|
444
|
+
if (value === void 0) return void 0;
|
|
445
|
+
if (expression.asType === "number") return Number.isNaN(parseFloat(value)) ? void 0 : number(parseFloat(value));
|
|
446
|
+
if (expression.asType === "date") return isCalendarDate(value) ? {
|
|
447
|
+
type: "date",
|
|
448
|
+
value
|
|
449
|
+
} : void 0;
|
|
450
|
+
return {
|
|
451
|
+
type: "string",
|
|
452
|
+
value
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const value = ownValue(context.customValues, key.value);
|
|
456
|
+
if (value && expression.asType && value.type !== expression.asType) throw new ExpressionEvaluationError(`Context value '${key.value}' does not have expected type ${expression.asType}`);
|
|
457
|
+
return value;
|
|
458
|
+
},
|
|
459
|
+
function: (expression) => evaluateFunction(expression, evaluate)
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function evaluateFunction(expression, evaluate) {
|
|
463
|
+
const arity = arityIssue(expression);
|
|
464
|
+
if (arity) throw new ExpressionEvaluationError(arity);
|
|
465
|
+
const arg = (index) => evaluate(operand(expression.arguments, index));
|
|
466
|
+
const all = () => Array.from(expression.arguments, (_, index) => arg(index));
|
|
467
|
+
const name = expression.functionName;
|
|
468
|
+
switch (name) {
|
|
469
|
+
case "and": return boolean(expression.arguments.every((_, index) => arg(index)?.value === true));
|
|
470
|
+
case "or": return boolean(expression.arguments.some((_, index) => arg(index)?.value === true));
|
|
471
|
+
case "not": {
|
|
472
|
+
const value = arg(0);
|
|
473
|
+
if (value === void 0) return void 0;
|
|
474
|
+
if (value.type !== "boolean") throw new ExpressionEvaluationError("not expects a boolean operand");
|
|
475
|
+
return boolean(!value.value);
|
|
476
|
+
}
|
|
477
|
+
case "list_contains": {
|
|
478
|
+
const list = arg(0), value = arg(1);
|
|
479
|
+
if (!list || !value) return boolean(false);
|
|
480
|
+
if (list.type !== "reference[]" && list.type !== "string[]" || value.type !== "reference" && value.type !== "string") throw new ExpressionEvaluationError("list_contains expects a string/reference array and a string/reference value");
|
|
481
|
+
return boolean(list.value.includes(value.value));
|
|
482
|
+
}
|
|
483
|
+
case "list_count": {
|
|
484
|
+
const list = arg(0);
|
|
485
|
+
if (!list) return number(0);
|
|
486
|
+
if (!Array.isArray(list.value)) throw new ExpressionEvaluationError("list_count expects a list operand");
|
|
487
|
+
if (!operand(expression.arguments, 1)) return number(list.value.length);
|
|
488
|
+
const filter = arg(1);
|
|
489
|
+
if (!filter) return number(0);
|
|
490
|
+
if (filter.type !== list.type) throw new ExpressionEvaluationError("list_count expects a filter of the list's type");
|
|
491
|
+
const allowed = responseValueElements(filter);
|
|
492
|
+
return number(responseValueElements(list).filter((entry) => allowed.some((value) => responseValuesEqual(entry, value))).length);
|
|
493
|
+
}
|
|
494
|
+
case "if": {
|
|
495
|
+
const condition = arg(0);
|
|
496
|
+
if (condition === void 0) return void 0;
|
|
497
|
+
if (condition.type !== "boolean") throw new ExpressionEvaluationError("if expects a boolean condition");
|
|
498
|
+
return arg(condition.value ? 1 : 2);
|
|
499
|
+
}
|
|
500
|
+
case "eq": {
|
|
501
|
+
const left = arg(0), right = arg(1);
|
|
502
|
+
if (!left || !right) return boolean(false);
|
|
503
|
+
if (left.type !== right.type) throw new ExpressionEvaluationError("eq expects matching operand types");
|
|
504
|
+
return boolean(left.value === right.value);
|
|
505
|
+
}
|
|
506
|
+
case "str_eq": {
|
|
507
|
+
const left = arg(0), right = arg(1);
|
|
508
|
+
if (!left || !right) return boolean(false);
|
|
509
|
+
if (left.type !== right.type) throw new ExpressionEvaluationError("str_eq expects matching operand types");
|
|
510
|
+
if (left.type !== "string") throw new ExpressionEvaluationError("str_eq expects string operands");
|
|
511
|
+
return boolean(left.value === right.value);
|
|
512
|
+
}
|
|
513
|
+
case "gt": {
|
|
514
|
+
const left = arg(0), right = arg(1);
|
|
515
|
+
if (!left || !right) return boolean(false);
|
|
516
|
+
if (left.type !== right.type) throw new ExpressionEvaluationError("gt expects matching operand types");
|
|
517
|
+
return boolean(left.value > right.value);
|
|
518
|
+
}
|
|
519
|
+
case "gte": {
|
|
520
|
+
const left = arg(0), right = arg(1);
|
|
521
|
+
if (!left || !right) return boolean(false);
|
|
522
|
+
if (left.type !== right.type) throw new ExpressionEvaluationError("gte expects matching operand types");
|
|
523
|
+
return boolean(left.value >= right.value);
|
|
524
|
+
}
|
|
525
|
+
case "lt": {
|
|
526
|
+
const left = arg(0), right = arg(1);
|
|
527
|
+
if (!left || !right) return boolean(false);
|
|
528
|
+
if (left.type !== right.type) throw new ExpressionEvaluationError("lt expects matching operand types");
|
|
529
|
+
return boolean(left.value < right.value);
|
|
530
|
+
}
|
|
531
|
+
case "lte": {
|
|
532
|
+
const left = arg(0), right = arg(1);
|
|
533
|
+
if (!left || !right) return boolean(false);
|
|
534
|
+
if (left.type !== right.type) throw new ExpressionEvaluationError("lte expects matching operand types");
|
|
535
|
+
return boolean(left.value <= right.value);
|
|
536
|
+
}
|
|
537
|
+
case "in_range": {
|
|
538
|
+
const value = arg(0), min = arg(1), max = arg(2), inclusive = arg(3)?.value === true;
|
|
539
|
+
if (!value || !min || !max) return boolean(false);
|
|
540
|
+
if (value.type !== "number" || min.type !== "number" || max.type !== "number") throw new ExpressionEvaluationError("in_range expects numeric bounds and value");
|
|
541
|
+
return boolean(inclusive ? value.value >= min.value && value.value <= max.value : value.value > min.value && value.value < max.value);
|
|
542
|
+
}
|
|
543
|
+
case "sum": {
|
|
544
|
+
let result = 0;
|
|
545
|
+
for (const value of all()) {
|
|
546
|
+
if (!value) continue;
|
|
547
|
+
if (value.type !== "number") throw new ExpressionEvaluationError("sum expects numeric operands");
|
|
548
|
+
result += value.value;
|
|
549
|
+
}
|
|
550
|
+
return number(result);
|
|
551
|
+
}
|
|
552
|
+
case "min": {
|
|
553
|
+
let result = Infinity;
|
|
554
|
+
for (const value of all()) {
|
|
555
|
+
if (!value) continue;
|
|
556
|
+
if (value.type !== "number") throw new ExpressionEvaluationError("min expects numeric operands");
|
|
557
|
+
result = Math.min(result, value.value);
|
|
558
|
+
}
|
|
559
|
+
return number(result);
|
|
560
|
+
}
|
|
561
|
+
case "max": {
|
|
562
|
+
let result = -Infinity;
|
|
563
|
+
for (const value of all()) {
|
|
564
|
+
if (!value) continue;
|
|
565
|
+
if (value.type !== "number") throw new ExpressionEvaluationError("max expects numeric operands");
|
|
566
|
+
result = Math.max(result, value.value);
|
|
567
|
+
}
|
|
568
|
+
return number(result);
|
|
569
|
+
}
|
|
570
|
+
case "date_add_days": {
|
|
571
|
+
const date = arg(0), amount = arg(1);
|
|
572
|
+
if (date?.type !== "date" || amount?.type !== "number") return void 0;
|
|
573
|
+
const value = addCalendarDate(date.value, amount.value, "days");
|
|
574
|
+
return value === void 0 ? void 0 : {
|
|
575
|
+
type: "date",
|
|
576
|
+
value
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
case "date_add_months": {
|
|
580
|
+
const date = arg(0), amount = arg(1);
|
|
581
|
+
if (date?.type !== "date" || amount?.type !== "number") return void 0;
|
|
582
|
+
const value = addCalendarDate(date.value, amount.value, "months");
|
|
583
|
+
return value === void 0 ? void 0 : {
|
|
584
|
+
type: "date",
|
|
585
|
+
value
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
case "date_add_years": {
|
|
589
|
+
const date = arg(0), amount = arg(1);
|
|
590
|
+
if (date?.type !== "date" || amount?.type !== "number") return void 0;
|
|
591
|
+
const value = addCalendarDate(date.value, amount.value, "years");
|
|
592
|
+
return value === void 0 ? void 0 : {
|
|
593
|
+
type: "date",
|
|
594
|
+
value
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
case "date_diff": {
|
|
598
|
+
const a = arg(0), b = arg(1), unit = arg(2);
|
|
599
|
+
if (unit === void 0) return void 0;
|
|
600
|
+
if (unit.type !== "string" || !isCalendarDateUnit(unit.value)) throw new ExpressionEvaluationError(`date_diff unit must be one of ${CALENDAR_DATE_UNITS.join(", ")}`);
|
|
601
|
+
if (a?.type !== "date" || b?.type !== "date") return void 0;
|
|
602
|
+
const value = differenceCalendarDates(a.value, b.value, unit.value);
|
|
603
|
+
return value === void 0 ? void 0 : number(value);
|
|
604
|
+
}
|
|
605
|
+
case "date_eq": {
|
|
606
|
+
const dates = evaluatedDates(all());
|
|
607
|
+
if (!dates) return void 0;
|
|
608
|
+
return boolean(dates[0] === dates[1]);
|
|
609
|
+
}
|
|
610
|
+
case "date_gt": {
|
|
611
|
+
const dates = comparableDates(all());
|
|
612
|
+
if (!dates) return void 0;
|
|
613
|
+
const comparison = compareCalendarDates(dates[0], dates[1]);
|
|
614
|
+
return comparison === void 0 ? void 0 : boolean(comparison > 0);
|
|
615
|
+
}
|
|
616
|
+
case "date_gte": {
|
|
617
|
+
const dates = comparableDates(all());
|
|
618
|
+
if (!dates) return void 0;
|
|
619
|
+
const comparison = compareCalendarDates(dates[0], dates[1]);
|
|
620
|
+
return comparison === void 0 ? void 0 : boolean(comparison >= 0);
|
|
621
|
+
}
|
|
622
|
+
case "date_lt": {
|
|
623
|
+
const dates = comparableDates(all());
|
|
624
|
+
if (!dates) return void 0;
|
|
625
|
+
const comparison = compareCalendarDates(dates[0], dates[1]);
|
|
626
|
+
return comparison === void 0 ? void 0 : boolean(comparison < 0);
|
|
627
|
+
}
|
|
628
|
+
case "date_lte": {
|
|
629
|
+
const dates = comparableDates(all());
|
|
630
|
+
if (!dates) return void 0;
|
|
631
|
+
const comparison = compareCalendarDates(dates[0], dates[1]);
|
|
632
|
+
return comparison === void 0 ? void 0 : boolean(comparison <= 0);
|
|
633
|
+
}
|
|
634
|
+
case "date_min": {
|
|
635
|
+
const dates = comparableDates(all());
|
|
636
|
+
if (!dates) return void 0;
|
|
637
|
+
return {
|
|
638
|
+
type: "date",
|
|
639
|
+
value: dates.reduce((a, b) => a < b ? a : b)
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
case "date_max": {
|
|
643
|
+
const dates = comparableDates(all());
|
|
644
|
+
if (!dates) return void 0;
|
|
645
|
+
return {
|
|
646
|
+
type: "date",
|
|
647
|
+
value: dates.reduce((a, b) => a > b ? a : b)
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
default: return unreachable(name);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region src/survey/responses/variable-naming.ts
|
|
655
|
+
const RESPONSE_VARIABLE_NAME_MAX_LENGTH = 32;
|
|
656
|
+
const isValidResponseVariableName = (name) => name.length <= 32 && /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(name);
|
|
657
|
+
/** Generate an initial suggestion; explicit rename operations validate instead of coercing. */
|
|
658
|
+
function suggestResponseVariableName(text, fallback = "response") {
|
|
659
|
+
const normalized = text.replace(/([a-z0-9])([A-Z])/g, "$1_$2").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
660
|
+
return ((/^[a-z]/.test(normalized) ? normalized : `${fallback}_${normalized}`) || fallback).slice(0, 32).replace(/_+$/g, "");
|
|
661
|
+
}
|
|
662
|
+
function allocateResponseVariableName(base, used, maxLength = 32) {
|
|
663
|
+
if (!isValidResponseVariableName(base)) throw new Error(`Invalid response variable name "${base}"; use lowercase snake_case, starting with a letter, at most 32 characters`);
|
|
664
|
+
if (base.length <= maxLength && !used.has(base)) return base;
|
|
665
|
+
const indexed = /^(.*)_([1-9][0-9]*)$/.exec(base);
|
|
666
|
+
const stemBase = indexed ? indexed[1] : base;
|
|
667
|
+
const start = indexed ? BigInt(indexed[2]) + 1n : 2n;
|
|
668
|
+
for (let n = start;; n++) {
|
|
669
|
+
const suffix = `_${n}`;
|
|
670
|
+
const stem = stemBase.slice(0, Math.max(0, maxLength - suffix.length)).replace(/_+$/g, "");
|
|
671
|
+
if (!stem) throw new Error("The variable naming suffix leaves no room for a name");
|
|
672
|
+
const name = `${stem}${suffix}`;
|
|
673
|
+
if (!used.has(name)) return name;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/survey/responses/slot-registry.ts
|
|
678
|
+
function deepFreeze(value) {
|
|
679
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
680
|
+
Object.freeze(value);
|
|
681
|
+
Object.values(value).forEach(deepFreeze);
|
|
682
|
+
}
|
|
683
|
+
return value;
|
|
684
|
+
}
|
|
685
|
+
/** A detached, derived index. Ambiguous identities never resolve to an arbitrary owner. */
|
|
686
|
+
var ResponseSlotRegistry = class {
|
|
687
|
+
slots = /* @__PURE__ */ new Map();
|
|
688
|
+
frozenDefinitions;
|
|
689
|
+
diagnostics = [];
|
|
690
|
+
constructor(declarations) {
|
|
691
|
+
const seen = /* @__PURE__ */ new Set();
|
|
692
|
+
for (const declaration of declarations) for (const definition of declaration.slots) {
|
|
693
|
+
const slot = deepFreeze(structuredClone({
|
|
694
|
+
...definition,
|
|
695
|
+
itemId: declaration.itemId,
|
|
696
|
+
elementId: declaration.elementId
|
|
697
|
+
}));
|
|
698
|
+
if (!slot.slotId.trim() || !slot.elementId.trim() || !slot.itemId.trim() || !Object.values(ValueType).includes(slot.valueType)) {
|
|
699
|
+
this.diagnostics.push({
|
|
700
|
+
code: "invalid-slot",
|
|
701
|
+
slotId: slot.slotId,
|
|
702
|
+
itemId: slot.itemId,
|
|
703
|
+
message: "Response slots require nonempty identities and a supported value type."
|
|
704
|
+
});
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (seen.has(slot.slotId)) {
|
|
708
|
+
this.slots.delete(slot.slotId);
|
|
709
|
+
this.diagnostics.push({
|
|
710
|
+
code: "duplicate-slot-id",
|
|
711
|
+
slotId: slot.slotId,
|
|
712
|
+
itemId: slot.itemId,
|
|
713
|
+
message: `Slot "${slot.slotId}" is declared more than once. Allocate unique persistent identities before using this schema.`
|
|
714
|
+
});
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
seen.add(slot.slotId);
|
|
718
|
+
this.slots.set(slot.slotId, slot);
|
|
719
|
+
const elementType = slot.valueType.endsWith("[]") ? slot.valueType.slice(0, -2) : slot.valueType;
|
|
720
|
+
if (slot.allowedValues?.some((value) => !isResponseValue(value) || responseValueElements(value).some((entry) => entry.type !== elementType))) this.diagnostics.push({
|
|
721
|
+
code: "invalid-domain",
|
|
722
|
+
slotId: slot.slotId,
|
|
723
|
+
itemId: slot.itemId,
|
|
724
|
+
message: `The allowed values for slot "${slot.slotId}" must have type ${elementType}.`
|
|
725
|
+
});
|
|
726
|
+
if (slot.referenceValueScores && (slot.valueType !== ValueType.reference && slot.valueType !== ValueType.referenceArray || Object.entries(slot.referenceValueScores).some(([id, score]) => !Number.isFinite(score) || slot.allowedValues && !slot.allowedValues.some((value) => value.type === ValueType.reference && value.value === id)))) this.diagnostics.push({
|
|
727
|
+
code: "invalid-domain",
|
|
728
|
+
slotId: slot.slotId,
|
|
729
|
+
itemId: slot.itemId,
|
|
730
|
+
message: `Scores for slot "${slot.slotId}" must map allowed option IDs to finite numbers.`
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
const names = /* @__PURE__ */ new Map();
|
|
734
|
+
for (const slot of this.slots.values()) {
|
|
735
|
+
if (typeof slot.variableName !== "string" || !isValidResponseVariableName(slot.variableName)) this.diagnostics.push({
|
|
736
|
+
code: "invalid-variable-name",
|
|
737
|
+
slotId: slot.slotId,
|
|
738
|
+
itemId: slot.itemId,
|
|
739
|
+
message: `Variable "${slot.variableName}" must use lowercase snake_case, start with a letter and contain at most 32 characters.`
|
|
740
|
+
});
|
|
741
|
+
const previous = names.get(slot.variableName);
|
|
742
|
+
if (previous) this.diagnostics.push({
|
|
743
|
+
code: "duplicate-variable-name",
|
|
744
|
+
slotId: slot.slotId,
|
|
745
|
+
itemId: slot.itemId,
|
|
746
|
+
message: `Variable name "${slot.variableName}" is already used by slot "${previous}".`
|
|
747
|
+
});
|
|
748
|
+
names.set(slot.variableName, slot.slotId);
|
|
749
|
+
}
|
|
750
|
+
for (const slot of this.slots.values()) for (const dependency of slot.computed?.sourceSlotIds ?? []) if (!this.slots.has(dependency)) this.diagnostics.push({
|
|
751
|
+
code: "unknown-dependency",
|
|
752
|
+
slotId: slot.slotId,
|
|
753
|
+
itemId: slot.itemId,
|
|
754
|
+
message: `Computed slot "${slot.slotId}" depends on unknown or ambiguous slot "${dependency}".`
|
|
755
|
+
});
|
|
756
|
+
const active = /* @__PURE__ */ new Set();
|
|
757
|
+
const visited = /* @__PURE__ */ new Set();
|
|
758
|
+
const visit = (id) => {
|
|
759
|
+
if (active.has(id)) {
|
|
760
|
+
this.diagnostics.push({
|
|
761
|
+
code: "computed-cycle",
|
|
762
|
+
slotId: id,
|
|
763
|
+
message: `Computed slot "${id}" participates in a dependency cycle.`
|
|
764
|
+
});
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (visited.has(id)) return;
|
|
768
|
+
active.add(id);
|
|
769
|
+
for (const dependency of this.slots.get(id)?.computed?.sourceSlotIds ?? []) visit(dependency);
|
|
770
|
+
active.delete(id);
|
|
771
|
+
visited.add(id);
|
|
772
|
+
};
|
|
773
|
+
for (const id of this.slots.keys()) visit(id);
|
|
774
|
+
}
|
|
775
|
+
/** Slots are deeply frozen and shared; copy one before deriving a changed definition. */
|
|
776
|
+
get(slotId) {
|
|
777
|
+
return this.slots.get(slotId);
|
|
778
|
+
}
|
|
779
|
+
get definitions() {
|
|
780
|
+
return this.frozenDefinitions ??= Object.freeze([...this.slots.values()]);
|
|
781
|
+
}
|
|
782
|
+
assertValid() {
|
|
783
|
+
if (this.diagnostics.length) throw new Error(this.diagnostics.map((entry) => entry.message).join("\n"));
|
|
784
|
+
}
|
|
785
|
+
validateAnswers(answers) {
|
|
786
|
+
const diagnostics = [...this.diagnostics];
|
|
787
|
+
for (const [slotId, value] of Object.entries(answers)) diagnostics.push(...this.validateAnswer(slotId, value));
|
|
788
|
+
return diagnostics;
|
|
789
|
+
}
|
|
790
|
+
/** Value diagnostics only; unrelated registry errors belong to the document. */
|
|
791
|
+
validateAnswer(slotId, value) {
|
|
792
|
+
const diagnostics = [];
|
|
793
|
+
const slot = this.slots.get(slotId);
|
|
794
|
+
if (!slot) {
|
|
795
|
+
diagnostics.push({
|
|
796
|
+
code: "unknown-slot",
|
|
797
|
+
slotId,
|
|
798
|
+
message: `Answer references unknown or ambiguous slot "${slotId}".`
|
|
799
|
+
});
|
|
800
|
+
return diagnostics;
|
|
801
|
+
}
|
|
802
|
+
const issues = slot.computed ? [`Computed slot "${slotId}" cannot contain a stored answer.`] : validateResponseSlotValue(slot, value);
|
|
803
|
+
if (isResponseValue(value) && value.type === "reference[]" && new Set(value.value).size !== value.value.length) issues.push("A selection cannot contain duplicate options");
|
|
804
|
+
for (const message of issues) diagnostics.push({
|
|
805
|
+
code: "invalid-answer",
|
|
806
|
+
slotId,
|
|
807
|
+
itemId: slot.itemId,
|
|
808
|
+
message
|
|
809
|
+
});
|
|
810
|
+
return diagnostics;
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
//#endregion
|
|
814
|
+
//#region src/survey/item-display-name.ts
|
|
815
|
+
function getCompositionLabel(types, locale, elementLabels = /* @__PURE__ */ new Map()) {
|
|
816
|
+
const language = locale.split("-")[0];
|
|
817
|
+
const labels = {
|
|
818
|
+
en: {
|
|
819
|
+
group: "Group",
|
|
820
|
+
"page-break": "Page break",
|
|
821
|
+
empty: "Empty item"
|
|
822
|
+
},
|
|
823
|
+
nl: {
|
|
824
|
+
group: "Groep",
|
|
825
|
+
"page-break": "Pagina-einde",
|
|
826
|
+
empty: "Leeg item"
|
|
827
|
+
},
|
|
828
|
+
de: {
|
|
829
|
+
group: "Gruppe",
|
|
830
|
+
"page-break": "Seitenumbruch",
|
|
831
|
+
empty: "Leeres Element"
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
const dictionary = labels[language] ?? labels.en;
|
|
835
|
+
return [...new Set(types)].map((type) => {
|
|
836
|
+
const labels = elementLabels.get(type);
|
|
837
|
+
return labels?.[locale] ?? labels?.[language] ?? labels?.en ?? dictionary[type] ?? type;
|
|
838
|
+
}).filter(Boolean).join(" + ") || dictionary.empty;
|
|
839
|
+
}
|
|
840
|
+
//#endregion
|
|
841
|
+
//#region src/survey/composition/document.ts
|
|
842
|
+
const isAdmissibleInDraft = (diagnostic) => diagnostic.severity !== "error" || diagnostic.kind === "reference";
|
|
843
|
+
const blockingFor = (diagnostics, admission) => diagnostics.filter((diagnostic) => admission === "draft" ? !isAdmissibleInDraft(diagnostic) : diagnostic.severity === "error");
|
|
844
|
+
/**
|
|
845
|
+
* The survey's languages with the default first, so fallbacks never depend on JSON key order.
|
|
846
|
+
* Hosts that hold unparsed survey JSON use this to pick the same language as the runtime.
|
|
847
|
+
*/
|
|
848
|
+
function surveyLocales(survey) {
|
|
849
|
+
const locales = Object.keys(survey.translations ?? {});
|
|
850
|
+
const defaultLocale = survey.defaultLocale;
|
|
851
|
+
return defaultLocale && locales.includes(defaultLocale) ? [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)] : locales;
|
|
852
|
+
}
|
|
853
|
+
var DefinitionError = class extends Error {
|
|
854
|
+
diagnostics;
|
|
855
|
+
constructor(diagnostics) {
|
|
856
|
+
super(diagnostics.map((diagnostic) => `${JSON.stringify(diagnostic.path)}: ${diagnostic.message}`).join("\n"));
|
|
857
|
+
this.diagnostics = diagnostics;
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
861
|
+
const key = (path) => JSON.stringify(path);
|
|
862
|
+
const prefix = (parent, child) => parent.length <= child.length && parent.every((part, i) => part === child[i]);
|
|
863
|
+
/** Flow membership and assets stay structural; other references may dangle in a draft. */
|
|
864
|
+
const DRAFT_REFERENCE_TARGETS = /* @__PURE__ */ new Set([
|
|
865
|
+
"slot",
|
|
866
|
+
"state",
|
|
867
|
+
"option",
|
|
868
|
+
"owner",
|
|
869
|
+
"item"
|
|
870
|
+
]);
|
|
871
|
+
const isFlowMembership = (path) => path[0] === "surveyItems" && path[2] === "config" && path[3] === "items";
|
|
872
|
+
function validateReferences(references, index, report) {
|
|
873
|
+
for (const reference of references) {
|
|
874
|
+
const identity = index.identities.get(reference.id);
|
|
875
|
+
if (!(reference.target === "slot" ? !!index.slots.get(reference.id) : reference.target === "state" ? [
|
|
876
|
+
"item",
|
|
877
|
+
"section",
|
|
878
|
+
"element",
|
|
879
|
+
"component"
|
|
880
|
+
].includes(index.owners.get(reference.id)?.kind ?? "") : reference.target === "owner" ? index.owners.has(reference.id) : reference.target === "option" ? identity?.kind === "component" : identity?.kind === reference.target)) report("invalid-reference", reference.path, `Unknown ${reference.target} reference '${reference.id}'`, "error", DRAFT_REFERENCE_TARGETS.has(reference.target) && !isFlowMembership(reference.path) ? "reference" : void 0);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
function validateLocalization(data, index, report) {
|
|
884
|
+
const defaultLocale = data.defaultLocale;
|
|
885
|
+
if (defaultLocale !== void 0 && !Object.hasOwn(data.translations ?? {}, defaultLocale)) report("default-locale", ["defaultLocale"], `Default locale '${defaultLocale}' has no translations`);
|
|
886
|
+
const references = [];
|
|
887
|
+
const collectAssets = (content, path) => {
|
|
888
|
+
for (const usage of getAssetUsagesFromContent(content)) references.push({
|
|
889
|
+
target: "asset",
|
|
890
|
+
id: usage.assetId,
|
|
891
|
+
path: [
|
|
892
|
+
...path,
|
|
893
|
+
"doc",
|
|
894
|
+
"blocks",
|
|
895
|
+
usage.blockIndex,
|
|
896
|
+
"source",
|
|
897
|
+
"assetId"
|
|
898
|
+
]
|
|
899
|
+
});
|
|
900
|
+
};
|
|
901
|
+
for (const [locale, translation] of Object.entries(data.translations ?? {})) {
|
|
902
|
+
const path = ["translations", locale];
|
|
903
|
+
for (const surface of SURVEY_LOCALIZATION_SURFACES) for (const [key, content] of Object.entries(translation[surface] ?? {})) collectAssets(content, [
|
|
904
|
+
...path,
|
|
905
|
+
surface,
|
|
906
|
+
key
|
|
907
|
+
]);
|
|
908
|
+
for (const [id, roles] of Object.entries(translation.content)) {
|
|
909
|
+
references.push({
|
|
910
|
+
target: "owner",
|
|
911
|
+
id,
|
|
912
|
+
path: [
|
|
913
|
+
...path,
|
|
914
|
+
"content",
|
|
915
|
+
id
|
|
916
|
+
]
|
|
917
|
+
});
|
|
918
|
+
for (const [role, content] of Object.entries(roles)) collectAssets(content, [
|
|
919
|
+
...path,
|
|
920
|
+
"content",
|
|
921
|
+
id,
|
|
922
|
+
role
|
|
923
|
+
]);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
validateReferences(references, index, report);
|
|
927
|
+
for (const [locale, translation] of Object.entries(data.translations ?? {})) for (const [id, roles] of Object.entries(translation.content)) {
|
|
928
|
+
const owner = index.owners.get(id);
|
|
929
|
+
for (const role of Object.keys(roles)) if (!owner?.roles.some((entry) => entry.role === role)) report("content-role", [
|
|
930
|
+
"translations",
|
|
931
|
+
locale,
|
|
932
|
+
"content",
|
|
933
|
+
id,
|
|
934
|
+
role
|
|
935
|
+
], `Owner '${id}' does not declare content role '${role}'`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
function sameJson(left, right) {
|
|
939
|
+
if (left === right) return true;
|
|
940
|
+
if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((entry, index) => sameJson(entry, right[index]));
|
|
941
|
+
if (!isRecord(left) || !isRecord(right)) return false;
|
|
942
|
+
if (![Object.prototype, null].includes(Object.getPrototypeOf(left)) || ![Object.prototype, null].includes(Object.getPrototypeOf(right))) return false;
|
|
943
|
+
const keys = Object.keys(left).filter((key) => left[key] !== void 0);
|
|
944
|
+
return keys.length === Object.keys(right).filter((key) => right[key] !== void 0).length && keys.every((key) => Object.hasOwn(right, key) && sameJson(left[key], right[key]));
|
|
945
|
+
}
|
|
946
|
+
function canReuseStructureIndexes(left, right) {
|
|
947
|
+
const { translations: _leftTranslations, defaultLocale: _leftDefault, surveyItems: leftItems, ...leftFields } = left;
|
|
948
|
+
const { translations: _rightTranslations, defaultLocale: _rightDefault, surveyItems: rightItems, ...rightFields } = right;
|
|
949
|
+
return sameJson(leftFields, rightFields) && leftItems.length === rightItems.length && leftItems.every((item, index) => {
|
|
950
|
+
const { editorMetadata: _leftMetadata, ...leftItem } = item;
|
|
951
|
+
const { editorMetadata: _rightMetadata, ...rightItem } = rightItems[index];
|
|
952
|
+
return sameJson(leftItem, rightItem);
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
/** Fail before recursive parsers encounter cyclic input or exceed the common import budget. */
|
|
956
|
+
function checkDocumentBudget(input, maxNodes = 1e5, maxDepth = 128) {
|
|
957
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
958
|
+
const stack = [{
|
|
959
|
+
value: input,
|
|
960
|
+
depth: 0
|
|
961
|
+
}];
|
|
962
|
+
let nodes = 0;
|
|
963
|
+
while (stack.length) {
|
|
964
|
+
const entry = stack.pop();
|
|
965
|
+
if (typeof entry.value !== "object" || entry.value === null) continue;
|
|
966
|
+
if (entry.exit) {
|
|
967
|
+
ancestors.delete(entry.value);
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
if (++nodes > maxNodes || entry.depth > maxDepth) throw new Error("Survey exceeds the document node/depth budget");
|
|
971
|
+
if (ancestors.has(entry.value)) throw new Error("Survey input contains an object cycle");
|
|
972
|
+
ancestors.add(entry.value);
|
|
973
|
+
stack.push({
|
|
974
|
+
...entry,
|
|
975
|
+
exit: true
|
|
976
|
+
});
|
|
977
|
+
for (const value of Object.values(entry.value)) stack.push({
|
|
978
|
+
value,
|
|
979
|
+
depth: entry.depth + 1
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/** One parsed revision, one owner/identity index, and the existing shared response catalog. */
|
|
984
|
+
var SurveyStructure = class {
|
|
985
|
+
registry;
|
|
986
|
+
data;
|
|
987
|
+
owners = /* @__PURE__ */ new Map();
|
|
988
|
+
identities = /* @__PURE__ */ new Map();
|
|
989
|
+
elements = /* @__PURE__ */ new Map();
|
|
990
|
+
bodies = /* @__PURE__ */ new Map();
|
|
991
|
+
expressions = [];
|
|
992
|
+
diagnostics = [];
|
|
993
|
+
slots;
|
|
994
|
+
rootId;
|
|
995
|
+
flowItems;
|
|
996
|
+
ownerPaths = /* @__PURE__ */ new Map();
|
|
997
|
+
constructor(data, registry, admission) {
|
|
998
|
+
this.registry = registry;
|
|
999
|
+
this.data = data;
|
|
1000
|
+
const report = (code, path, message, severity = "error", kind) => this.diagnostics.push({
|
|
1001
|
+
code,
|
|
1002
|
+
path,
|
|
1003
|
+
message,
|
|
1004
|
+
severity,
|
|
1005
|
+
...kind ? { kind } : {}
|
|
1006
|
+
});
|
|
1007
|
+
const nearestOwner = (path) => {
|
|
1008
|
+
for (let length = path.length; length >= 0; length--) {
|
|
1009
|
+
const owner = this.ownerPaths.get(key(path.slice(0, length)));
|
|
1010
|
+
if (owner) return owner;
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
const visits = inspectAnnotated(registry.surveySchema, this.data);
|
|
1014
|
+
const bodies = [];
|
|
1015
|
+
for (const visit of visits) {
|
|
1016
|
+
const { annotation, value, path } = visit;
|
|
1017
|
+
if (annotation.kind === "expression") this.expressions.push({
|
|
1018
|
+
expression: expressionSchema.parse(value),
|
|
1019
|
+
path,
|
|
1020
|
+
...annotation.expectedReturnType ? { expectedReturnType: annotation.expectedReturnType } : {}
|
|
1021
|
+
});
|
|
1022
|
+
if (annotation.kind === "owner") {
|
|
1023
|
+
if (!isRecord(value) || typeof value.id !== "string") throw new Error("Content owners require an annotated id");
|
|
1024
|
+
const parent = nearestOwner(path.slice(0, -1));
|
|
1025
|
+
const owner = {
|
|
1026
|
+
id: value.id,
|
|
1027
|
+
kind: "component",
|
|
1028
|
+
path,
|
|
1029
|
+
roles: annotation.roles,
|
|
1030
|
+
parentId: parent?.id,
|
|
1031
|
+
itemId: parent?.itemId ?? value.id,
|
|
1032
|
+
elementId: value.kind === "element" ? value.id : parent?.elementId,
|
|
1033
|
+
visibility: value.visibility === void 0 ? void 0 : expressionSchema.parse(value.visibility),
|
|
1034
|
+
disabled: value.disabled === void 0 ? void 0 : expressionSchema.parse(value.disabled)
|
|
1035
|
+
};
|
|
1036
|
+
if (!this.owners.has(owner.id)) this.owners.set(owner.id, owner);
|
|
1037
|
+
this.ownerPaths.set(key(path), owner);
|
|
1038
|
+
}
|
|
1039
|
+
if (annotation.kind === "identity") {
|
|
1040
|
+
if (typeof value !== "string") throw new Error("Identity annotations require string values");
|
|
1041
|
+
const owner = nearestOwner(path);
|
|
1042
|
+
if (owner && path.length === owner.path.length + 1 && path.at(-1) === "id") owner.kind = annotation.identity;
|
|
1043
|
+
const identity = {
|
|
1044
|
+
id: value,
|
|
1045
|
+
kind: annotation.identity,
|
|
1046
|
+
ownerId: owner?.id,
|
|
1047
|
+
path,
|
|
1048
|
+
slotAlias: annotation.slotAlias
|
|
1049
|
+
};
|
|
1050
|
+
if (this.identities.has(value)) report("duplicate-identity", path, `Identity '${value}' is declared more than once`);
|
|
1051
|
+
else this.identities.set(value, identity);
|
|
1052
|
+
}
|
|
1053
|
+
if (annotation.kind === "body") {
|
|
1054
|
+
const owner = nearestOwner(path);
|
|
1055
|
+
if (!owner) report("body-owner", path, "A body requires a declared content owner");
|
|
1056
|
+
else if (this.bodies.has(owner.id)) {
|
|
1057
|
+
if (key(this.bodies.get(owner.id).path) !== key(path)) report("body-owner", path, "A content owner can own only one body");
|
|
1058
|
+
else if (annotation.followUp) this.bodies.set(owner.id, {
|
|
1059
|
+
ownerId: owner.id,
|
|
1060
|
+
path,
|
|
1061
|
+
followUp: true
|
|
1062
|
+
});
|
|
1063
|
+
} else this.bodies.set(owner.id, {
|
|
1064
|
+
ownerId: owner.id,
|
|
1065
|
+
path,
|
|
1066
|
+
followUp: annotation.followUp
|
|
1067
|
+
});
|
|
1068
|
+
bodies.push({
|
|
1069
|
+
path,
|
|
1070
|
+
followUp: annotation.followUp,
|
|
1071
|
+
triggerId: annotation.followUp ? owner?.id : void 0
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
for (const owner of this.owners.values()) {
|
|
1076
|
+
let sections = owner.kind === "section" ? 1 : 0;
|
|
1077
|
+
let parent = owner.parentId && this.owners.get(owner.parentId);
|
|
1078
|
+
const seen = /* @__PURE__ */ new Set([owner.id]);
|
|
1079
|
+
while (parent) {
|
|
1080
|
+
if (seen.has(parent.id)) break;
|
|
1081
|
+
seen.add(parent.id);
|
|
1082
|
+
if (parent.kind === "section") sections++;
|
|
1083
|
+
parent = parent.parentId && this.owners.get(parent.parentId);
|
|
1084
|
+
}
|
|
1085
|
+
if (sections > 1) report("section-depth", owner.path, "Only one semantic section is allowed along an ownership path");
|
|
1086
|
+
owner.followUpTriggerId = bodies.filter((body) => body.followUp && prefix(body.path, owner.path)).at(-1)?.triggerId;
|
|
1087
|
+
}
|
|
1088
|
+
for (const body of bodies.filter((candidate) => candidate.followUp)) {
|
|
1089
|
+
if (bodies.some((parent) => parent.followUp && parent !== body && prefix(parent.path, body.path))) report("follow-up-depth", body.path, "Follow-ups cannot contain another follow-up, including an empty body");
|
|
1090
|
+
const trigger = body.triggerId && this.owners.get(body.triggerId);
|
|
1091
|
+
if (!trigger || trigger.kind !== "component") report("follow-up-trigger", body.path, "A follow-up must belong to a declared trigger component");
|
|
1092
|
+
if (bodies.some((other) => other !== body && other.followUp && other.triggerId === body.triggerId)) report("follow-up-trigger", body.path, "A trigger can own only one follow-up body");
|
|
1093
|
+
}
|
|
1094
|
+
const declarations = [];
|
|
1095
|
+
for (const item of this.data.surveyItems) {
|
|
1096
|
+
if (item.itemType !== "content") continue;
|
|
1097
|
+
for (const owner of this.owners.values()) {
|
|
1098
|
+
if (owner.itemId !== item.id || owner.kind !== "element") continue;
|
|
1099
|
+
let raw = this.data;
|
|
1100
|
+
for (const segment of owner.path) if (Array.isArray(raw) && typeof segment === "number") raw = raw[segment];
|
|
1101
|
+
else if (isRecord(raw)) raw = ownValue(raw, String(segment));
|
|
1102
|
+
else throw new Error("Invalid derived element path");
|
|
1103
|
+
const node = registry.elementSchema.parse(raw);
|
|
1104
|
+
const facts = registry.get(node.elementType).inspect(node);
|
|
1105
|
+
this.elements.set(node.id, {
|
|
1106
|
+
node,
|
|
1107
|
+
facts,
|
|
1108
|
+
itemId: item.id,
|
|
1109
|
+
path: owner.path
|
|
1110
|
+
});
|
|
1111
|
+
declarations.push({
|
|
1112
|
+
itemId: item.id,
|
|
1113
|
+
elementId: node.id,
|
|
1114
|
+
slots: facts.slots.map((slot) => ({
|
|
1115
|
+
...slot,
|
|
1116
|
+
exportSettings: ownValue(node.exportSettings, slot.slotId)
|
|
1117
|
+
}))
|
|
1118
|
+
});
|
|
1119
|
+
for (const slot of facts.slots) {
|
|
1120
|
+
const identity = this.identities.get(slot.slotId);
|
|
1121
|
+
const slotOwner = identity?.ownerId ? this.owners.get(identity.ownerId) : void 0;
|
|
1122
|
+
if (!identity || identity.kind !== "slot" && !(identity.kind === "component" && identity.slotAlias) || slotOwner?.elementId !== node.id) report("slot-identity", owner.path, `Slot '${slot.slotId}' must be declared by its element schema (or explicitly alias its own component)`);
|
|
1123
|
+
}
|
|
1124
|
+
const ownComponent = (id) => this.owners.get(id)?.elementId === node.id;
|
|
1125
|
+
const storedSlots = facts.slots.filter((slot) => !slot.computed);
|
|
1126
|
+
const boundSlots = /* @__PURE__ */ new Set();
|
|
1127
|
+
for (const binding of facts.bindings) {
|
|
1128
|
+
if (!storedSlots.some((slot) => slot.slotId === binding.slotId) || boundSlots.has(binding.slotId)) report("slot-binding", owner.path, `Invalid or duplicate input binding '${binding.slotId}'`);
|
|
1129
|
+
boundSlots.add(binding.slotId);
|
|
1130
|
+
for (const id of [
|
|
1131
|
+
binding.controlId,
|
|
1132
|
+
...binding.visibilityIds ?? [],
|
|
1133
|
+
...binding.selection?.options ?? []
|
|
1134
|
+
]) if (!ownComponent(id)) report("component-binding", owner.path, `Component '${id}' does not belong to element '${node.id}'`);
|
|
1135
|
+
if (binding.labelRefs) {
|
|
1136
|
+
if (!binding.labelRefs.length) report("control-label", owner.path, "Declared control labels must not be empty");
|
|
1137
|
+
for (const ref of binding.labelRefs) if (!ownComponent(ref.ownerId) || !this.owners.get(ref.ownerId)?.roles.some(({ role }) => role === ref.role)) report("control-label", owner.path, `Control label ${ref.ownerId}.${ref.role} is not an owned content role`);
|
|
1138
|
+
}
|
|
1139
|
+
if (binding.selection && new Set(binding.selection.options).size !== binding.selection.options.length) report("component-binding", owner.path, "Selection options must be unique");
|
|
1140
|
+
}
|
|
1141
|
+
for (const slot of storedSlots) if (!boundSlots.has(slot.slotId)) report("slot-binding", owner.path, `Stored slot '${slot.slotId}' requires a control binding`);
|
|
1142
|
+
for (const [child, parents] of Object.entries(facts.componentParents)) if (!ownComponent(child) || parents.some((id) => !ownComponent(id))) report("component-binding", owner.path, "Component inheritance must stay within its declaring element");
|
|
1143
|
+
for (const binding of facts.followUps) {
|
|
1144
|
+
if (!ownComponent(binding.triggerId)) report("follow-up-activation", owner.path, "Follow-up trigger must belong to its declaring element");
|
|
1145
|
+
if ("selectionSlotId" in binding && !facts.bindings.some((input) => input.slotId === binding.selectionSlotId && input.selection?.options.includes(binding.triggerId))) report("follow-up-activation", owner.path, "Selection activation requires an option of its declared input");
|
|
1146
|
+
}
|
|
1147
|
+
for (const id of Object.keys(node.exportSettings ?? {})) if (!facts.slots.some((slot) => slot.slotId === id)) report("slot-settings-owner", owner.path, `Settings target '${id}' is not owned by this element`);
|
|
1148
|
+
for (const id of Object.keys(node.prefills ?? {})) if (!facts.slots.some((slot) => slot.slotId === id && !slot.computed)) report("prefill-owner", owner.path, `Prefill target '${id}' is not a writable slot owned by this element`);
|
|
1149
|
+
for (const body of bodies.filter((body) => body.followUp && body.triggerId && this.owners.get(body.triggerId)?.elementId === node.id)) {
|
|
1150
|
+
if (!facts.fullWidth) report("follow-up-width", body.path, "Follow-up presentations require a full row");
|
|
1151
|
+
if (!facts.followUps.some((binding) => binding.triggerId === body.triggerId)) report("follow-up-activation", body.path, "Follow-up activation must be declared by its element domain");
|
|
1152
|
+
}
|
|
1153
|
+
for (const binding of facts.followUps) if (!bodies.some((body) => body.followUp && body.triggerId === binding.triggerId)) report("follow-up-activation", owner.path, "Activation references a trigger without a follow-up body");
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
this.slots = new ResponseSlotRegistry(declarations);
|
|
1157
|
+
for (const diagnostic of this.slots.diagnostics) report(diagnostic.code, [], diagnostic.message);
|
|
1158
|
+
for (const element of this.elements.values()) for (const [slotId, candidates] of Object.entries(element.node.prefills ?? {})) candidates.forEach((candidate, index) => {
|
|
1159
|
+
const path = [
|
|
1160
|
+
...element.path,
|
|
1161
|
+
"prefills",
|
|
1162
|
+
slotId,
|
|
1163
|
+
index,
|
|
1164
|
+
"source"
|
|
1165
|
+
];
|
|
1166
|
+
if (candidate.source.type === "static") for (const issue of this.slots.validateAnswer(slotId, candidate.source.value)) report("prefill-domain", [...path, "value"], issue.message);
|
|
1167
|
+
if (candidate.source.type === "templateValue" && !ownValue(this.data.templateValues, candidate.source.key)) report("prefill-template", [...path, "key"], `Unknown prefill template '${candidate.source.key}'`);
|
|
1168
|
+
});
|
|
1169
|
+
validateReferences(annotatedReferences(visits).filter((reference) => reference.path[0] !== "translations"), this, report);
|
|
1170
|
+
validateLocalization(this.data, this, report);
|
|
1171
|
+
const items = new Map(this.data.surveyItems.map((item) => [item.id, item]));
|
|
1172
|
+
this.flowItems = items;
|
|
1173
|
+
const roots = this.data.surveyItems.filter((item) => item.itemType === "group" && item.config.isRoot);
|
|
1174
|
+
this.rootId = roots[0]?.id ?? "";
|
|
1175
|
+
if (roots.length !== 1) report("flow-root", ["surveyItems"], "The survey must contain exactly one root group");
|
|
1176
|
+
const parents = /* @__PURE__ */ new Map();
|
|
1177
|
+
for (const item of items.values()) if (item.itemType === "group") for (const child of item.config.items) {
|
|
1178
|
+
if (parents.has(child)) report("flow-parent", [], `Flow item '${child}' has more than one parent`);
|
|
1179
|
+
parents.set(child, item.id);
|
|
1180
|
+
const owner = this.owners.get(child);
|
|
1181
|
+
if (owner) owner.parentId = item.id;
|
|
1182
|
+
}
|
|
1183
|
+
for (const item of items.values()) {
|
|
1184
|
+
const chain = /* @__PURE__ */ new Set();
|
|
1185
|
+
let id = item.id;
|
|
1186
|
+
while (id) {
|
|
1187
|
+
if (chain.has(id)) {
|
|
1188
|
+
report("flow-cycle", [], `Flow cycle through '${id}'`);
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1191
|
+
chain.add(id);
|
|
1192
|
+
id = parents.get(id);
|
|
1193
|
+
}
|
|
1194
|
+
if (item.id === this.rootId && parents.has(item.id)) report("flow-root", [], "The root group cannot have a parent");
|
|
1195
|
+
if (item.id !== this.rootId && !parents.has(item.id)) report("flow-orphan", [], `Flow item '${item.id}' has no parent`);
|
|
1196
|
+
if (item.itemType === "content") for (const rule of item.config.validationRules ?? []) for (const slotId of rule.targets) {
|
|
1197
|
+
const target = this.slots.get(slotId);
|
|
1198
|
+
if (target && target.itemId !== item.id) report("rule-scope", [], `Rule '${rule.id}' targets a slot outside its content item`);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (blockingFor(this.diagnostics, admission).length) throw new DefinitionError(this.diagnostics);
|
|
1202
|
+
for (const value of this.owners.values()) freezeDocument(value);
|
|
1203
|
+
for (const value of this.identities.values()) freezeDocument(value);
|
|
1204
|
+
for (const value of this.elements.values()) freezeDocument(value);
|
|
1205
|
+
for (const value of this.bodies.values()) freezeDocument(value);
|
|
1206
|
+
freezeDocument(this.expressions);
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
function freezeDocument(value) {
|
|
1210
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
1211
|
+
Object.freeze(value);
|
|
1212
|
+
Object.values(value).forEach(freezeDocument);
|
|
1213
|
+
}
|
|
1214
|
+
return value;
|
|
1215
|
+
}
|
|
1216
|
+
/** A detached revision. Translation publication shares immutable structural indexes. */
|
|
1217
|
+
var Survey = class Survey {
|
|
1218
|
+
data;
|
|
1219
|
+
registry;
|
|
1220
|
+
structure;
|
|
1221
|
+
itemIndex;
|
|
1222
|
+
definitionDiagnostics;
|
|
1223
|
+
constructor(data, registry, structure, itemIndex = structure.flowItems, definitionDiagnostics = structure.diagnostics) {
|
|
1224
|
+
this.data = data;
|
|
1225
|
+
this.registry = registry;
|
|
1226
|
+
this.structure = structure;
|
|
1227
|
+
this.itemIndex = itemIndex;
|
|
1228
|
+
this.definitionDiagnostics = definitionDiagnostics;
|
|
1229
|
+
freezeDocument(data);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Parse and index one revision. `previous` lets an unchanged structure share its indexes.
|
|
1233
|
+
* Editors, history, editor files and assistant drafts use `admission: "draft"`; everything that
|
|
1234
|
+
* executes, submits or releases a survey keeps the strict default.
|
|
1235
|
+
*/
|
|
1236
|
+
static parse(input, registry, options = {}) {
|
|
1237
|
+
const { previous, admission = "strict" } = options;
|
|
1238
|
+
checkDocumentBudget(input);
|
|
1239
|
+
const data = registry.surveySchema.parse(input);
|
|
1240
|
+
checkDocumentBudget(data);
|
|
1241
|
+
if (previous?.registry === registry && canReuseStructureIndexes(data, previous.data)) {
|
|
1242
|
+
const diagnostics = previous.structure.diagnostics.filter((diagnostic) => !diagnostic.path.length || diagnostic.path[0] !== "translations" && diagnostic.path[0] !== "defaultLocale");
|
|
1243
|
+
const report = (code, path, message, severity = "error", kind) => diagnostics.push({
|
|
1244
|
+
code,
|
|
1245
|
+
path,
|
|
1246
|
+
message,
|
|
1247
|
+
severity,
|
|
1248
|
+
...kind ? { kind } : {}
|
|
1249
|
+
});
|
|
1250
|
+
validateLocalization(data, previous.structure, report);
|
|
1251
|
+
if (blockingFor(diagnostics, admission).length) throw new DefinitionError(diagnostics);
|
|
1252
|
+
return new Survey(data, registry, previous.structure, new Map(data.surveyItems.map((item) => [item.id, item])), diagnostics);
|
|
1253
|
+
}
|
|
1254
|
+
const structure = new SurveyStructure(data, registry, admission);
|
|
1255
|
+
return new Survey(structure.data, registry, structure);
|
|
1256
|
+
}
|
|
1257
|
+
static blank(registry) {
|
|
1258
|
+
return Survey.parse({
|
|
1259
|
+
$schema: CURRENT_SURVEY_SCHEMA,
|
|
1260
|
+
schemaVersion: 3,
|
|
1261
|
+
surveyItems: [{
|
|
1262
|
+
id: generateId(),
|
|
1263
|
+
itemType: "group",
|
|
1264
|
+
config: {
|
|
1265
|
+
isRoot: true,
|
|
1266
|
+
items: []
|
|
1267
|
+
}
|
|
1268
|
+
}]
|
|
1269
|
+
}, registry);
|
|
1270
|
+
}
|
|
1271
|
+
get rootId() {
|
|
1272
|
+
return this.structure.rootId;
|
|
1273
|
+
}
|
|
1274
|
+
get locales() {
|
|
1275
|
+
return surveyLocales(this.data);
|
|
1276
|
+
}
|
|
1277
|
+
get defaultLocale() {
|
|
1278
|
+
return this.locales[0];
|
|
1279
|
+
}
|
|
1280
|
+
get surveyKey() {
|
|
1281
|
+
return this.data.metadata?.surveyKey;
|
|
1282
|
+
}
|
|
1283
|
+
get metadata() {
|
|
1284
|
+
return this.data.metadata;
|
|
1285
|
+
}
|
|
1286
|
+
get maxItemsPerPage() {
|
|
1287
|
+
return this.data.maxItemsPerPage;
|
|
1288
|
+
}
|
|
1289
|
+
get rootItem() {
|
|
1290
|
+
const root = this.getItem(this.rootId);
|
|
1291
|
+
if (root?.itemType !== "group") throw new Error("Validated survey is missing its root group");
|
|
1292
|
+
return root;
|
|
1293
|
+
}
|
|
1294
|
+
getItemPath(itemId) {
|
|
1295
|
+
if (!this.getItem(itemId)) throw new Error(`Item ${itemId} not found`);
|
|
1296
|
+
const path = [];
|
|
1297
|
+
let parent = this.getParentItem(itemId);
|
|
1298
|
+
while (parent) {
|
|
1299
|
+
path.unshift(parent.id);
|
|
1300
|
+
parent = this.getParentItem(parent.id);
|
|
1301
|
+
}
|
|
1302
|
+
return path;
|
|
1303
|
+
}
|
|
1304
|
+
getChildrenItems(parentId) {
|
|
1305
|
+
const parent = this.getItem(parentId);
|
|
1306
|
+
return parent?.itemType === "group" ? parent.config.items.map((id) => this.getItem(id)) : [];
|
|
1307
|
+
}
|
|
1308
|
+
getSiblings(itemId) {
|
|
1309
|
+
const parent = this.getParentItem(itemId);
|
|
1310
|
+
return parent ? this.getChildrenItems(parent.id) : [];
|
|
1311
|
+
}
|
|
1312
|
+
resolveItemEditorName(itemId, locale = this.locales[0] ?? "en") {
|
|
1313
|
+
const item = this.getItem(itemId);
|
|
1314
|
+
if (!item) return getCompositionLabel([], locale);
|
|
1315
|
+
const explicit = item.metadata?.itemLabel?.trim();
|
|
1316
|
+
if (explicit) return explicit;
|
|
1317
|
+
const content = ownValue(this.data.translations, locale)?.content;
|
|
1318
|
+
const summary = (value) => getContentPlainText(value).replace(/\s+/g, " ").trim();
|
|
1319
|
+
const title = summary(ownValue(content, itemId)?.title);
|
|
1320
|
+
if (title) return title;
|
|
1321
|
+
const elements = [...this.elements.values()].filter((element) => element.itemId === itemId);
|
|
1322
|
+
for (const element of elements) {
|
|
1323
|
+
const roles = ownValue(content, element.node.id);
|
|
1324
|
+
const wording = summary(roles?.label) || summary(roles?.body);
|
|
1325
|
+
if (wording) return wording;
|
|
1326
|
+
}
|
|
1327
|
+
return getCompositionLabel(item.itemType === "content" ? elements.map(({ node }) => node.elementType) : [item.itemType], locale, this.registry.labels);
|
|
1328
|
+
}
|
|
1329
|
+
getItemDisplayPath(itemId, locale = this.locales[0] ?? "en") {
|
|
1330
|
+
if (!this.getItem(itemId)) return void 0;
|
|
1331
|
+
const path = this.getItemPath(itemId), name = this.resolveItemEditorName(itemId, locale);
|
|
1332
|
+
const namePath = path.map((id) => this.resolveItemEditorName(id, locale));
|
|
1333
|
+
return {
|
|
1334
|
+
itemId,
|
|
1335
|
+
name,
|
|
1336
|
+
path,
|
|
1337
|
+
namePath,
|
|
1338
|
+
breadcrumb: [...namePath, name].join(" / "),
|
|
1339
|
+
parentBreadcrumb: namePath.join(" / ")
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
getItemEditorBreadcrumb(itemId, locale = this.locales[0] ?? "en") {
|
|
1343
|
+
const path = this.getItemDisplayPath(itemId, locale);
|
|
1344
|
+
if (!path) return void 0;
|
|
1345
|
+
return [...path.namePath.slice(path.path[0] === this.rootId ? 1 : 0), path.name].join(" / ");
|
|
1346
|
+
}
|
|
1347
|
+
get itemOutline() {
|
|
1348
|
+
const result = [];
|
|
1349
|
+
const visit = (item) => {
|
|
1350
|
+
result.push(this.getItemDisplayPath(item.id));
|
|
1351
|
+
this.getChildrenItems(item.id).forEach(visit);
|
|
1352
|
+
};
|
|
1353
|
+
visit(this.rootItem);
|
|
1354
|
+
return result;
|
|
1355
|
+
}
|
|
1356
|
+
getTemplateValue(key) {
|
|
1357
|
+
return ownValue(this.data.templateValues, key);
|
|
1358
|
+
}
|
|
1359
|
+
getTemplateValueKeys() {
|
|
1360
|
+
return Object.keys(this.data.templateValues ?? {});
|
|
1361
|
+
}
|
|
1362
|
+
getAsset(assetId) {
|
|
1363
|
+
return ownValue(this.data.assets, assetId);
|
|
1364
|
+
}
|
|
1365
|
+
getAssets() {
|
|
1366
|
+
return new Map(Object.entries(this.data.assets ?? {}));
|
|
1367
|
+
}
|
|
1368
|
+
getAssetIds() {
|
|
1369
|
+
return Object.keys(this.data.assets ?? {});
|
|
1370
|
+
}
|
|
1371
|
+
hasAsset(assetId) {
|
|
1372
|
+
return Object.hasOwn(this.data.assets ?? {}, assetId);
|
|
1373
|
+
}
|
|
1374
|
+
getAssetUsages(filterAssetId) {
|
|
1375
|
+
const usages = [];
|
|
1376
|
+
const collect = (content, ref, locale) => {
|
|
1377
|
+
for (const usage of getAssetUsagesFromContent(content)) {
|
|
1378
|
+
if (filterAssetId && usage.assetId !== filterAssetId) continue;
|
|
1379
|
+
const itemId = ref.scope === "content" ? this.owners.get(ref.ownerId).itemId : this.rootId;
|
|
1380
|
+
usages.push({
|
|
1381
|
+
...usage,
|
|
1382
|
+
ref,
|
|
1383
|
+
locale,
|
|
1384
|
+
itemId
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
for (const [locale, translation] of Object.entries(this.data.translations ?? {})) {
|
|
1389
|
+
for (const surface of SURVEY_LOCALIZATION_SURFACES) for (const [contentKey, content] of Object.entries(translation[surface] ?? {})) collect(content, {
|
|
1390
|
+
scope: "survey",
|
|
1391
|
+
surface,
|
|
1392
|
+
contentKey
|
|
1393
|
+
}, locale);
|
|
1394
|
+
for (const [ownerId, roles] of Object.entries(translation.content)) for (const [role, content] of Object.entries(roles)) collect(content, {
|
|
1395
|
+
scope: "content",
|
|
1396
|
+
ownerId,
|
|
1397
|
+
role
|
|
1398
|
+
}, locale);
|
|
1399
|
+
}
|
|
1400
|
+
return usages;
|
|
1401
|
+
}
|
|
1402
|
+
get flowItems() {
|
|
1403
|
+
return this.itemIndex;
|
|
1404
|
+
}
|
|
1405
|
+
get owners() {
|
|
1406
|
+
return this.structure.owners;
|
|
1407
|
+
}
|
|
1408
|
+
get identities() {
|
|
1409
|
+
return this.structure.identities;
|
|
1410
|
+
}
|
|
1411
|
+
get elements() {
|
|
1412
|
+
return this.structure.elements;
|
|
1413
|
+
}
|
|
1414
|
+
get expressions() {
|
|
1415
|
+
return this.structure.expressions;
|
|
1416
|
+
}
|
|
1417
|
+
get bodies() {
|
|
1418
|
+
return this.structure.bodies;
|
|
1419
|
+
}
|
|
1420
|
+
get slots() {
|
|
1421
|
+
return this.structure.slots;
|
|
1422
|
+
}
|
|
1423
|
+
get diagnostics() {
|
|
1424
|
+
return this.definitionDiagnostics;
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Document-index errors that prevent running, submitting or releasing this revision. Graph
|
|
1428
|
+
* compilation can add more; use getBrokenReferences for the complete set of broken references.
|
|
1429
|
+
*/
|
|
1430
|
+
get blockingDiagnostics() {
|
|
1431
|
+
return this.definitionDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
1432
|
+
}
|
|
1433
|
+
getItem(itemId) {
|
|
1434
|
+
return this.itemIndex.get(itemId);
|
|
1435
|
+
}
|
|
1436
|
+
getParentItem(itemId) {
|
|
1437
|
+
if (!this.getItem(itemId)) return void 0;
|
|
1438
|
+
const parentId = this.owners.get(itemId)?.parentId;
|
|
1439
|
+
const parent = parentId ? this.getItem(parentId) : void 0;
|
|
1440
|
+
return parent?.itemType === "group" ? parent : void 0;
|
|
1441
|
+
}
|
|
1442
|
+
/** Inclusive ancestry, as used by the outline's subtree/drop guards. */
|
|
1443
|
+
isDescendantOf(itemId, ancestorId) {
|
|
1444
|
+
let item = this.getItem(itemId);
|
|
1445
|
+
while (item) {
|
|
1446
|
+
if (item.id === ancestorId) return true;
|
|
1447
|
+
item = this.getParentItem(item.id);
|
|
1448
|
+
}
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
serialize() {
|
|
1452
|
+
return structuredClone(this.data);
|
|
1453
|
+
}
|
|
1454
|
+
/** Editor timestamps cannot affect identity, conditions or responses; share their indexes. */
|
|
1455
|
+
withItemEditorMetadata(updates) {
|
|
1456
|
+
if (!updates.size) return this;
|
|
1457
|
+
const items = new Map(this.itemIndex);
|
|
1458
|
+
for (const [id, metadata] of updates) {
|
|
1459
|
+
const item = items.get(id);
|
|
1460
|
+
if (!item) throw new Error(`Unknown item '${id}'`);
|
|
1461
|
+
items.set(id, {
|
|
1462
|
+
...item,
|
|
1463
|
+
editorMetadata: itemEditorMetadataSchema.parse(metadata)
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
const data = {
|
|
1467
|
+
...this.data,
|
|
1468
|
+
surveyItems: this.data.surveyItems.map((item) => items.get(item.id))
|
|
1469
|
+
};
|
|
1470
|
+
checkDocumentBudget(data);
|
|
1471
|
+
return new Survey(data, this.registry, this.structure, items, this.definitionDiagnostics);
|
|
1472
|
+
}
|
|
1473
|
+
/** Sets the default language without rebuilding structural indexes. */
|
|
1474
|
+
withDefaultLocale(locale) {
|
|
1475
|
+
if (!Object.hasOwn(this.data.translations ?? {}, locale)) throw new Error(`Default locale '${locale}' has no translations`);
|
|
1476
|
+
return new Survey({
|
|
1477
|
+
...this.data,
|
|
1478
|
+
defaultLocale: locale
|
|
1479
|
+
}, this.registry, this.structure, this.itemIndex, this.definitionDiagnostics);
|
|
1480
|
+
}
|
|
1481
|
+
withLocalizationContent(ref, locale, content) {
|
|
1482
|
+
validateLocalizationRef(this, ref);
|
|
1483
|
+
if (!locale.trim()) throw new Error("A locale is required");
|
|
1484
|
+
if (content === void 0 && readLocalizationContent(this, ref, locale) === void 0) return this;
|
|
1485
|
+
const translations = { ...this.data.translations };
|
|
1486
|
+
const old = ownValue(translations, locale) ?? { content: {} };
|
|
1487
|
+
const translation = {
|
|
1488
|
+
...old,
|
|
1489
|
+
content: { ...old.content }
|
|
1490
|
+
};
|
|
1491
|
+
let fields;
|
|
1492
|
+
if (ref.scope === "content") {
|
|
1493
|
+
fields = { ...ownValue(translation.content, ref.ownerId) };
|
|
1494
|
+
if (content === void 0) delete fields[ref.role];
|
|
1495
|
+
else setOwnValue(fields, ref.role, content);
|
|
1496
|
+
if (Object.keys(fields).length) setOwnValue(translation.content, ref.ownerId, fields);
|
|
1497
|
+
else delete translation.content[ref.ownerId];
|
|
1498
|
+
} else {
|
|
1499
|
+
fields = { ...translation[ref.surface] };
|
|
1500
|
+
if (content === void 0) delete fields[ref.contentKey];
|
|
1501
|
+
else setOwnValue(fields, ref.contentKey, content);
|
|
1502
|
+
translation[ref.surface] = fields;
|
|
1503
|
+
}
|
|
1504
|
+
setOwnValue(translations, locale, translation);
|
|
1505
|
+
const data = {
|
|
1506
|
+
...this.data,
|
|
1507
|
+
translations
|
|
1508
|
+
};
|
|
1509
|
+
checkDocumentBudget(data);
|
|
1510
|
+
if (content !== void 0) {
|
|
1511
|
+
const parsed = surveyContentSchema.parse(content);
|
|
1512
|
+
for (const asset of getAssetUsagesFromContent(parsed)) if (!ownValue(this.data.assets, asset.assetId)) throw new Error(`Unknown content asset '${asset.assetId}'`);
|
|
1513
|
+
setOwnValue(fields, ref.scope === "content" ? ref.role : ref.contentKey, parsed);
|
|
1514
|
+
}
|
|
1515
|
+
return new Survey(data, this.registry, this.structure, this.itemIndex, this.definitionDiagnostics);
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
//#endregion
|
|
1519
|
+
//#region src/engine/survey-state-graph.ts
|
|
1520
|
+
const stateNodeKey = (type, id) => JSON.stringify([type, id]);
|
|
1521
|
+
/** Compiled dependencies for one survey definition; evaluate against current answers and context. */
|
|
1522
|
+
var SurveyStateGraph = class {
|
|
1523
|
+
document;
|
|
1524
|
+
nodes = /* @__PURE__ */ new Map();
|
|
1525
|
+
bindings = /* @__PURE__ */ new Map();
|
|
1526
|
+
initializationOrder;
|
|
1527
|
+
/**
|
|
1528
|
+
* Draft-only problems that did not prevent compilation: dangling references (which evaluate as
|
|
1529
|
+
* missing) and unfinished expressions. Always empty for a strict graph.
|
|
1530
|
+
*/
|
|
1531
|
+
admittedDiagnostics;
|
|
1532
|
+
/**
|
|
1533
|
+
* `strict` (default) compiles only executable definitions. `draft` serves editors and editor
|
|
1534
|
+
* previews: unfinished expressions and dangling references are admitted, and a missing answer or
|
|
1535
|
+
* state target reads as missing (unanswered, not disabled, not visible).
|
|
1536
|
+
*/
|
|
1537
|
+
constructor(document, options = {}) {
|
|
1538
|
+
this.document = document;
|
|
1539
|
+
const draft = options.admission === "draft";
|
|
1540
|
+
if (!draft && document.blockingDiagnostics.length) throw new DefinitionError(document.blockingDiagnostics);
|
|
1541
|
+
const diagnostics = [];
|
|
1542
|
+
const admitted = [];
|
|
1543
|
+
const types = Object.fromEntries(document.slots.definitions.map((slot) => [slot.slotId, slot.valueType]));
|
|
1544
|
+
const domains = new Map(document.slots.definitions.flatMap((slot) => slot.allowedValues ? [[slot.slotId, slot.allowedValues]] : []));
|
|
1545
|
+
for (const { expression, path, expectedReturnType } of document.expressions) for (const issue of expressionTypeIssues(expression, types, expectedReturnType, domains)) diagnostics.push({
|
|
1546
|
+
...issue,
|
|
1547
|
+
path: [...path, ...issue.path],
|
|
1548
|
+
code: "expression-type",
|
|
1549
|
+
severity: "error"
|
|
1550
|
+
});
|
|
1551
|
+
for (const element of document.elements.values()) for (const [slotId, candidates] of Object.entries(element.node.prefills ?? {})) candidates.forEach((candidate, index) => {
|
|
1552
|
+
const target = document.slots.get(slotId);
|
|
1553
|
+
const path = [
|
|
1554
|
+
...element.path,
|
|
1555
|
+
"prefills",
|
|
1556
|
+
slotId,
|
|
1557
|
+
index
|
|
1558
|
+
];
|
|
1559
|
+
const check = (expression, expected, at) => {
|
|
1560
|
+
for (const issue of expressionTypeIssues(expression, types, expected, domains)) diagnostics.push({
|
|
1561
|
+
...issue,
|
|
1562
|
+
path: [...at, ...issue.path],
|
|
1563
|
+
code: "prefill-type",
|
|
1564
|
+
severity: "error"
|
|
1565
|
+
});
|
|
1566
|
+
};
|
|
1567
|
+
if (candidate.when) check(candidate.when, "boolean", [...path, "when"]);
|
|
1568
|
+
if (candidate.source.type === "expression") check(candidate.source.expression, target.valueType, [
|
|
1569
|
+
...path,
|
|
1570
|
+
"source",
|
|
1571
|
+
"expression"
|
|
1572
|
+
]);
|
|
1573
|
+
if (candidate.source.type === "templateValue") {
|
|
1574
|
+
const template = ownValue(document.data.templateValues, candidate.source.key);
|
|
1575
|
+
if (template.returnType !== target.valueType) diagnostics.push({
|
|
1576
|
+
code: "prefill-type",
|
|
1577
|
+
severity: "error",
|
|
1578
|
+
path: [...path, "source"],
|
|
1579
|
+
message: `Prefill template produces ${template.returnType}, but '${slotId}' requires ${target.valueType}`
|
|
1580
|
+
});
|
|
1581
|
+
if (template.expression) check(template.expression, template.type === "formatDate" ? "date" : template.returnType, [
|
|
1582
|
+
"templateValues",
|
|
1583
|
+
candidate.source.key,
|
|
1584
|
+
"expression"
|
|
1585
|
+
]);
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
for (const { expression, path } of document.expressions) for (const issue of expressionIssues(expression)) (draft ? admitted : diagnostics).push({
|
|
1589
|
+
...issue,
|
|
1590
|
+
path: [...path, ...issue.path],
|
|
1591
|
+
code: "invalid-expression",
|
|
1592
|
+
severity: "error"
|
|
1593
|
+
});
|
|
1594
|
+
const nodePaths = /* @__PURE__ */ new Map();
|
|
1595
|
+
const slotOwners = new Map(document.slots.definitions.map((slot) => [slot.slotId, slot.elementId]));
|
|
1596
|
+
const add = (type, id, dependencies, evaluate) => {
|
|
1597
|
+
const key = stateNodeKey(type, id);
|
|
1598
|
+
if (this.nodes.has(key)) throw new Error(`Duplicate graph node ${key}`);
|
|
1599
|
+
this.nodes.set(key, {
|
|
1600
|
+
key,
|
|
1601
|
+
dependencies,
|
|
1602
|
+
evaluate
|
|
1603
|
+
});
|
|
1604
|
+
const owner = document.owners.get(id) ?? document.owners.get(slotOwners.get(id) ?? "");
|
|
1605
|
+
if (owner) nodePaths.set(key, owner.path);
|
|
1606
|
+
};
|
|
1607
|
+
const edge = (type, id, reason) => ({
|
|
1608
|
+
key: stateNodeKey(type, id),
|
|
1609
|
+
reason
|
|
1610
|
+
});
|
|
1611
|
+
const dependencies = (expression) => {
|
|
1612
|
+
if (!expression) return [];
|
|
1613
|
+
return collectExpressionDependencies(expression).map((dependency) => dependency.kind === "answer" ? edge("answer", dependency.slotId, "authored answer reference") : edge("disabled", dependency.targetRef.ownerId, "authored state reference"));
|
|
1614
|
+
};
|
|
1615
|
+
const evaluate = (expression, read, input, issues, path) => {
|
|
1616
|
+
try {
|
|
1617
|
+
return evaluateExpression(expression, {
|
|
1618
|
+
...input.context,
|
|
1619
|
+
answer: (id) => {
|
|
1620
|
+
const value = read(stateNodeKey("answer", id));
|
|
1621
|
+
return typeof value === "boolean" ? void 0 : value;
|
|
1622
|
+
},
|
|
1623
|
+
disabled: (id) => read(stateNodeKey("disabled", id)) === true
|
|
1624
|
+
});
|
|
1625
|
+
} catch (error) {
|
|
1626
|
+
if (!(error instanceof ExpressionEvaluationError)) throw error;
|
|
1627
|
+
issues.push({
|
|
1628
|
+
code: "condition-evaluation",
|
|
1629
|
+
path,
|
|
1630
|
+
message: error.message,
|
|
1631
|
+
severity: "warning"
|
|
1632
|
+
});
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
for (const owner of document.owners.values()) {
|
|
1637
|
+
const domainParents = owner.elementId ? ownValue(document.elements.get(owner.elementId)?.facts.componentParents, owner.id) ?? [] : [];
|
|
1638
|
+
const parents = [...owner.parentId ? [owner.parentId] : [], ...domainParents];
|
|
1639
|
+
const inheritedVisibility = parents.map((id) => edge("visibility", id, "visibility inheritance"));
|
|
1640
|
+
const followUp = owner.followUpTriggerId ? [edge("followUpActive", owner.followUpTriggerId, "follow-up activation")] : [];
|
|
1641
|
+
add("visibility", owner.id, [
|
|
1642
|
+
...inheritedVisibility,
|
|
1643
|
+
...followUp,
|
|
1644
|
+
...dependencies(owner.visibility)
|
|
1645
|
+
], (read, input, issues) => {
|
|
1646
|
+
if ([...inheritedVisibility, ...followUp].some((dependency) => read(dependency.key) !== true)) return false;
|
|
1647
|
+
if (!owner.visibility) return true;
|
|
1648
|
+
const result = evaluate(owner.visibility, read, input, issues, [...owner.path, "visibility"]);
|
|
1649
|
+
if (result && result.type !== "boolean") issues.push({
|
|
1650
|
+
code: "condition-type",
|
|
1651
|
+
path: [...owner.path, "visibility"],
|
|
1652
|
+
message: "Visibility must return a boolean",
|
|
1653
|
+
severity: "warning"
|
|
1654
|
+
});
|
|
1655
|
+
return result?.type === "boolean" && result.value;
|
|
1656
|
+
});
|
|
1657
|
+
const inheritedDisabled = parents.map((id) => edge("disabled", id, "disabled inheritance"));
|
|
1658
|
+
add("disabled", owner.id, [...inheritedDisabled, ...dependencies(owner.disabled)], (read, input, issues) => {
|
|
1659
|
+
const inherited = inheritedDisabled.some((dependency) => read(dependency.key) === true);
|
|
1660
|
+
if (!owner.disabled) return inherited;
|
|
1661
|
+
const result = evaluate(owner.disabled, read, input, issues, [...owner.path, "disabled"]);
|
|
1662
|
+
if (!result || result.type !== "boolean") issues.push({
|
|
1663
|
+
code: "condition-type",
|
|
1664
|
+
path: [...owner.path, "disabled"],
|
|
1665
|
+
message: "Disabled condition did not return a boolean; local disabling is false",
|
|
1666
|
+
severity: "warning"
|
|
1667
|
+
});
|
|
1668
|
+
return inherited || result?.type === "boolean" && result.value;
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
for (const element of document.elements.values()) {
|
|
1672
|
+
for (const binding of element.facts.bindings) {
|
|
1673
|
+
if (this.bindings.has(binding.slotId)) throw new Error(`Duplicate control binding for ${binding.slotId}`);
|
|
1674
|
+
this.bindings.set(binding.slotId, binding);
|
|
1675
|
+
}
|
|
1676
|
+
for (const slot of element.facts.slots) {
|
|
1677
|
+
const storedKey = stateNodeKey("storedAnswer", slot.slotId);
|
|
1678
|
+
if (!slot.computed) add("storedAnswer", slot.slotId, [], (_read, input, issues) => {
|
|
1679
|
+
const answer = ownValue(input.answers, slot.slotId);
|
|
1680
|
+
if (answer === void 0) return void 0;
|
|
1681
|
+
const errors = validateResponseSlotValue(slot, answer);
|
|
1682
|
+
if (errors.length) {
|
|
1683
|
+
issues.push({
|
|
1684
|
+
code: "invalid-answer",
|
|
1685
|
+
path: ["answers", slot.slotId],
|
|
1686
|
+
message: errors.join("; "),
|
|
1687
|
+
severity: "error"
|
|
1688
|
+
});
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
return answer;
|
|
1692
|
+
});
|
|
1693
|
+
const binding = this.bindings.get(slot.slotId);
|
|
1694
|
+
const visibility = [binding?.controlId ?? element.node.id, ...binding?.visibilityIds ?? []];
|
|
1695
|
+
const options = binding?.selection?.options ?? [];
|
|
1696
|
+
const sources = slot.computed?.sourceSlotIds ?? [];
|
|
1697
|
+
add("answer", slot.slotId, [
|
|
1698
|
+
...visibility.map((id) => edge("visibility", id, "control applicability")),
|
|
1699
|
+
...options.map((id) => edge("visibility", id, "option projection")),
|
|
1700
|
+
...slot.computed ? sources.map((id) => edge("answer", id, "computed source")) : [{
|
|
1701
|
+
key: storedKey,
|
|
1702
|
+
reason: "retained source"
|
|
1703
|
+
}]
|
|
1704
|
+
], (read) => {
|
|
1705
|
+
if (visibility.some((id) => read(stateNodeKey("visibility", id)) !== true)) return void 0;
|
|
1706
|
+
if (slot.computed) return document.registry.get(element.node.elementType).compute(element.node, slot.slotId, (id) => {
|
|
1707
|
+
const value = read(stateNodeKey("answer", id));
|
|
1708
|
+
return typeof value === "boolean" ? void 0 : value;
|
|
1709
|
+
});
|
|
1710
|
+
const value = read(storedKey);
|
|
1711
|
+
if (typeof value === "boolean" || !value) return void 0;
|
|
1712
|
+
if (!binding?.selection) return value;
|
|
1713
|
+
if (value.type === "reference") return read(stateNodeKey("visibility", value.value)) === true ? value : void 0;
|
|
1714
|
+
if (value.type === "reference[]") {
|
|
1715
|
+
const active = value.value.filter((id) => read(stateNodeKey("visibility", id)) === true);
|
|
1716
|
+
return active.length || value.value.length === 0 ? {
|
|
1717
|
+
...value,
|
|
1718
|
+
value: active
|
|
1719
|
+
} : void 0;
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
for (const followUp of element.facts.followUps) {
|
|
1724
|
+
const trigger = edge("visibility", followUp.triggerId, "trigger visibility");
|
|
1725
|
+
if ("selectionSlotId" in followUp) {
|
|
1726
|
+
const selected = edge("storedAnswer", followUp.selectionSlotId, "per-option checked stored selection");
|
|
1727
|
+
add("followUpActive", followUp.triggerId, [trigger, selected], (read) => {
|
|
1728
|
+
if (read(trigger.key) !== true) return false;
|
|
1729
|
+
const value = read(selected.key);
|
|
1730
|
+
return typeof value !== "boolean" && (value?.type === "reference" ? value.value === followUp.triggerId : value?.type === "reference[]" && value.value.includes(followUp.triggerId));
|
|
1731
|
+
});
|
|
1732
|
+
} else add("followUpActive", followUp.triggerId, [trigger, ...dependencies(followUp.expression)], (read, input, issues) => {
|
|
1733
|
+
if (read(trigger.key) !== true) return false;
|
|
1734
|
+
const result = evaluate(followUp.expression, read, input, issues, element.path);
|
|
1735
|
+
if (result && result.type !== "boolean") issues.push({
|
|
1736
|
+
code: "condition-type",
|
|
1737
|
+
path: element.path,
|
|
1738
|
+
message: "Follow-up activation must return a boolean",
|
|
1739
|
+
severity: "warning"
|
|
1740
|
+
});
|
|
1741
|
+
return result?.type === "boolean" && result.value;
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
if (draft) {
|
|
1746
|
+
const missing = /* @__PURE__ */ new Set();
|
|
1747
|
+
for (const node of this.nodes.values()) for (const dependency of node.dependencies) if (!this.nodes.has(dependency.key)) missing.add(dependency.key);
|
|
1748
|
+
for (const { expression } of document.expressions) for (const dependency of dependencies(expression)) if (!this.nodes.has(dependency.key)) missing.add(dependency.key);
|
|
1749
|
+
for (const key of missing) {
|
|
1750
|
+
const [type] = JSON.parse(key);
|
|
1751
|
+
const value = type === "answer" || type === "storedAnswer" ? void 0 : false;
|
|
1752
|
+
this.nodes.set(key, {
|
|
1753
|
+
key,
|
|
1754
|
+
dependencies: [],
|
|
1755
|
+
evaluate: () => value
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1760
|
+
const visiting = [];
|
|
1761
|
+
const visit = (key, reason) => {
|
|
1762
|
+
if (visited.has(key)) return;
|
|
1763
|
+
const cycle = visiting.findIndex((node) => node.key === key);
|
|
1764
|
+
if (cycle >= 0) {
|
|
1765
|
+
diagnostics.push({
|
|
1766
|
+
code: "dependency-cycle",
|
|
1767
|
+
path: nodePaths.get(key) ?? [],
|
|
1768
|
+
message: "Dependency cycle: " + [...visiting.slice(cycle), {
|
|
1769
|
+
key,
|
|
1770
|
+
reason
|
|
1771
|
+
}].map((node) => `${node.key} (${node.reason})`).join(" → "),
|
|
1772
|
+
severity: "error"
|
|
1773
|
+
});
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
const node = this.nodes.get(key);
|
|
1777
|
+
if (!node) {
|
|
1778
|
+
diagnostics.push({
|
|
1779
|
+
code: "unknown-dependency",
|
|
1780
|
+
path: [],
|
|
1781
|
+
message: `Unknown dependency ${key} (${reason})`,
|
|
1782
|
+
severity: "error"
|
|
1783
|
+
});
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
visiting.push({
|
|
1787
|
+
key,
|
|
1788
|
+
reason
|
|
1789
|
+
});
|
|
1790
|
+
for (const dependency of node.dependencies) visit(dependency.key, dependency.reason);
|
|
1791
|
+
visiting.pop();
|
|
1792
|
+
visited.add(key);
|
|
1793
|
+
};
|
|
1794
|
+
for (const key of this.nodes.keys()) visit(key, "root");
|
|
1795
|
+
if (draft) for (const diagnostic of diagnostics.splice(0)) (diagnostic.kind === "reference" ? admitted : diagnostics).push(diagnostic);
|
|
1796
|
+
if (diagnostics.length) throw new DefinitionError([...diagnostics, ...admitted]);
|
|
1797
|
+
this.admittedDiagnostics = Object.freeze(admitted);
|
|
1798
|
+
const prefills = new Map([...document.elements.values()].flatMap((element) => Object.entries(element.node.prefills ?? {})));
|
|
1799
|
+
const initialization = [], initialized = /* @__PURE__ */ new Set(), initializing = /* @__PURE__ */ new Set();
|
|
1800
|
+
const initialize = (id) => {
|
|
1801
|
+
if (initialized.has(id)) return;
|
|
1802
|
+
if (initializing.has(id)) throw new DefinitionError([{
|
|
1803
|
+
code: "prefill-cycle",
|
|
1804
|
+
severity: "error",
|
|
1805
|
+
path: [],
|
|
1806
|
+
message: `Prefill dependency cycle through '${id}'`
|
|
1807
|
+
}]);
|
|
1808
|
+
initializing.add(id);
|
|
1809
|
+
for (const candidate of prefills.get(id) ?? []) {
|
|
1810
|
+
const expressions = [candidate.when, candidate.source.type === "expression" ? candidate.source.expression : candidate.source.type === "templateValue" ? ownValue(document.data.templateValues, candidate.source.key)?.expression : void 0];
|
|
1811
|
+
for (const expression of expressions) if (expression) {
|
|
1812
|
+
for (const dependency of this.storedDependencies(expression)) if (prefills.has(dependency)) initialize(dependency);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
initializing.delete(id);
|
|
1816
|
+
initialized.add(id);
|
|
1817
|
+
initialization.push(id);
|
|
1818
|
+
};
|
|
1819
|
+
prefills.forEach((_, id) => initialize(id));
|
|
1820
|
+
this.initializationOrder = Object.freeze(initialization);
|
|
1821
|
+
}
|
|
1822
|
+
evaluate(input) {
|
|
1823
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1824
|
+
const diagnostics = [];
|
|
1825
|
+
const read = (key) => {
|
|
1826
|
+
if (cache.has(key)) return cache.get(key);
|
|
1827
|
+
const node = this.nodes.get(key);
|
|
1828
|
+
if (!node) throw new Error(`Unknown response graph node ${key}`);
|
|
1829
|
+
const result = node.evaluate((dependency) => {
|
|
1830
|
+
if (!node.dependencies.some((edge) => edge.key === dependency)) throw new Error(`Undeclared domain dependency ${key} → ${dependency}`);
|
|
1831
|
+
return read(dependency);
|
|
1832
|
+
}, input, diagnostics);
|
|
1833
|
+
cache.set(key, result);
|
|
1834
|
+
return result;
|
|
1835
|
+
};
|
|
1836
|
+
const answer = (id) => {
|
|
1837
|
+
const value = read(stateNodeKey("answer", id));
|
|
1838
|
+
return typeof value === "boolean" ? void 0 : value;
|
|
1839
|
+
};
|
|
1840
|
+
return {
|
|
1841
|
+
answer,
|
|
1842
|
+
stored: (id) => {
|
|
1843
|
+
const value = read(stateNodeKey("storedAnswer", id));
|
|
1844
|
+
return typeof value === "boolean" ? void 0 : value;
|
|
1845
|
+
},
|
|
1846
|
+
visible: (id) => read(stateNodeKey("visibility", id)) === true,
|
|
1847
|
+
disabled: (id) => read(stateNodeKey("disabled", id)) === true,
|
|
1848
|
+
followUpActive: (id) => read(stateNodeKey("followUpActive", id)) === true,
|
|
1849
|
+
activeAnswers: () => Object.fromEntries(this.document.slots.definitions.flatMap((slot) => {
|
|
1850
|
+
const value = answer(slot.slotId);
|
|
1851
|
+
return value === void 0 || slot.computed ? [] : [[slot.slotId, value]];
|
|
1852
|
+
})),
|
|
1853
|
+
get diagnostics() {
|
|
1854
|
+
return diagnostics.map((issue) => ({
|
|
1855
|
+
...issue,
|
|
1856
|
+
path: [...issue.path]
|
|
1857
|
+
}));
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
storedDependencies(expression) {
|
|
1862
|
+
const result = /* @__PURE__ */ new Set();
|
|
1863
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1864
|
+
const visit = (key) => {
|
|
1865
|
+
if (visited.has(key)) return;
|
|
1866
|
+
visited.add(key);
|
|
1867
|
+
const [type, id] = JSON.parse(key);
|
|
1868
|
+
if (type === "storedAnswer") result.add(id);
|
|
1869
|
+
const node = this.nodes.get(key);
|
|
1870
|
+
if (!node) throw new Error(`Unknown dependency ${key}`);
|
|
1871
|
+
node.dependencies.forEach((edge) => visit(edge.key));
|
|
1872
|
+
};
|
|
1873
|
+
collectExpressionDependencies(expression).forEach((ref) => visit(ref.kind === "answer" ? stateNodeKey("answer", ref.slotId) : stateNodeKey("disabled", ref.targetRef.ownerId)));
|
|
1874
|
+
return result;
|
|
1875
|
+
}
|
|
1876
|
+
};
|
|
1877
|
+
/** The same mechanical permissions power controls, batch writes, and validation eligibility. */
|
|
1878
|
+
function canWriteSelection(binding, before, after, snapshot) {
|
|
1879
|
+
if (!snapshot.visible(binding.controlId) || snapshot.disabled(binding.controlId) || binding.visibilityIds?.some((id) => !snapshot.visible(id) || snapshot.disabled(id))) return false;
|
|
1880
|
+
const selection = binding.selection;
|
|
1881
|
+
if (!selection) return true;
|
|
1882
|
+
const optionEnabled = (id) => selection.options.includes(id) && snapshot.visible(id) && !snapshot.disabled(id);
|
|
1883
|
+
if (!selection.multiple) {
|
|
1884
|
+
if (after?.type === "reference") return after.value === (before?.type === "reference" ? before.value : void 0) || optionEnabled(after.value);
|
|
1885
|
+
return after === void 0 && selection.allowClear !== false && (before?.type !== "reference" || optionEnabled(before.value));
|
|
1886
|
+
}
|
|
1887
|
+
if (after !== void 0 && after.type !== "reference[]") return false;
|
|
1888
|
+
const oldValues = before?.type === "reference[]" ? before.value : [];
|
|
1889
|
+
const values = after?.type === "reference[]" ? after.value : [];
|
|
1890
|
+
const activeCount = values.filter((id) => snapshot.visible(id)).length;
|
|
1891
|
+
const previousActiveCount = oldValues.filter((id) => snapshot.visible(id)).length;
|
|
1892
|
+
if (selection.maxSelection != null && activeCount > selection.maxSelection && activeCount >= previousActiveCount) return false;
|
|
1893
|
+
return [...oldValues.filter((id) => !values.includes(id)), ...values.filter((id) => !oldValues.includes(id))].every(optionEnabled);
|
|
1894
|
+
}
|
|
1895
|
+
function hasPermittedChange(binding, snapshot) {
|
|
1896
|
+
if (!snapshot.visible(binding.controlId) || snapshot.disabled(binding.controlId) || binding.visibilityIds?.some((id) => !snapshot.visible(id) || snapshot.disabled(id))) return false;
|
|
1897
|
+
if (!binding.selection) return true;
|
|
1898
|
+
const current = snapshot.stored(binding.slotId);
|
|
1899
|
+
const selection = binding.selection;
|
|
1900
|
+
if (!selection.multiple) {
|
|
1901
|
+
if (current !== void 0 && canWriteSelection(binding, current, void 0, snapshot)) return true;
|
|
1902
|
+
return selection.options.some((id) => (current?.type !== "reference" || current.value !== id) && canWriteSelection(binding, current, {
|
|
1903
|
+
type: "reference",
|
|
1904
|
+
value: id
|
|
1905
|
+
}, snapshot));
|
|
1906
|
+
}
|
|
1907
|
+
const values = current?.type === "reference[]" ? current.value : [];
|
|
1908
|
+
return selection.options.some((id) => canWriteSelection(binding, current, {
|
|
1909
|
+
type: "reference[]",
|
|
1910
|
+
value: values.includes(id) ? values.filter((value) => value !== id) : [...values, id]
|
|
1911
|
+
}, snapshot));
|
|
1912
|
+
}
|
|
1913
|
+
//#endregion
|
|
1914
|
+
//#region src/survey/responses/response-meta.ts
|
|
1915
|
+
const SurveyEventTypes = {
|
|
1916
|
+
openSurvey: "openSurvey",
|
|
1917
|
+
pageChanged: "pageChanged",
|
|
1918
|
+
responseChanged: "responseChanged",
|
|
1919
|
+
languageChanged: "languageChanged"
|
|
1920
|
+
};
|
|
1921
|
+
const surveyEventSchema = z.discriminatedUnion("type", [
|
|
1922
|
+
z.strictObject({
|
|
1923
|
+
type: z.literal("openSurvey"),
|
|
1924
|
+
timestamp: z.number().finite(),
|
|
1925
|
+
value: z.string()
|
|
1926
|
+
}),
|
|
1927
|
+
z.strictObject({
|
|
1928
|
+
type: z.literal("pageChanged"),
|
|
1929
|
+
timestamp: z.number().finite(),
|
|
1930
|
+
value: z.number().int().nonnegative()
|
|
1931
|
+
}),
|
|
1932
|
+
z.strictObject({
|
|
1933
|
+
type: z.literal("languageChanged"),
|
|
1934
|
+
timestamp: z.number().finite(),
|
|
1935
|
+
value: z.string()
|
|
1936
|
+
}),
|
|
1937
|
+
z.strictObject({
|
|
1938
|
+
type: z.literal("responseChanged"),
|
|
1939
|
+
timestamp: z.number().finite(),
|
|
1940
|
+
slotId: z.string().min(1),
|
|
1941
|
+
operation: z.enum(["set", "clear"])
|
|
1942
|
+
})
|
|
1943
|
+
]);
|
|
1944
|
+
const itemResponseMetadataSchema = stringRecord(z.string().min(1), z.strictObject({ position: z.number().int().nonnegative() }));
|
|
1945
|
+
//#endregion
|
|
1946
|
+
//#region src/survey/responses/survey-response.ts
|
|
1947
|
+
const SURVEY_RESPONSE_SCHEMA_VERSION = 2;
|
|
1948
|
+
const surveyResponseSchema = z.strictObject({
|
|
1949
|
+
schemaVersion: z.literal(2),
|
|
1950
|
+
responseId: z.string().min(1),
|
|
1951
|
+
/** The exact definition the answers were given against; slot and option IDs are only meaningful with it. */
|
|
1952
|
+
definitionId: z.string().min(1),
|
|
1953
|
+
participantId: z.string().optional(),
|
|
1954
|
+
submittedAt: z.number().finite().optional(),
|
|
1955
|
+
answers: stringRecord(z.string().min(1), responseValueSchema),
|
|
1956
|
+
itemMetadata: itemResponseMetadataSchema.optional(),
|
|
1957
|
+
events: z.array(surveyEventSchema),
|
|
1958
|
+
/** Host-provided values stored and exported with the response, e.g. a tracking ID. */
|
|
1959
|
+
customValues: stringRecord(z.string().min(1), responseValueSchema).optional(),
|
|
1960
|
+
evaluationContext: z.strictObject({
|
|
1961
|
+
locale: z.string().min(1),
|
|
1962
|
+
currentDate: calendarDaySchema.optional(),
|
|
1963
|
+
participantFlags: stringRecord(z.string(), z.string()).optional()
|
|
1964
|
+
}).optional(),
|
|
1965
|
+
/** Reproduces item and option shuffles. */
|
|
1966
|
+
randomization: z.strictObject({ seed: z.string().min(1) }).optional()
|
|
1967
|
+
});
|
|
1968
|
+
/** Publication and schema association belong to the host, never to an answer address. */
|
|
1969
|
+
var SurveyResponse = class SurveyResponse {
|
|
1970
|
+
responseId;
|
|
1971
|
+
definitionId;
|
|
1972
|
+
participantId;
|
|
1973
|
+
submittedAt;
|
|
1974
|
+
answers = Object.create(null);
|
|
1975
|
+
itemMetadata = Object.create(null);
|
|
1976
|
+
events = [];
|
|
1977
|
+
customValues;
|
|
1978
|
+
evaluationContext;
|
|
1979
|
+
randomization;
|
|
1980
|
+
constructor(responseId, definitionId) {
|
|
1981
|
+
this.responseId = responseId;
|
|
1982
|
+
this.definitionId = definitionId;
|
|
1983
|
+
}
|
|
1984
|
+
serialize() {
|
|
1985
|
+
return structuredClone({
|
|
1986
|
+
schemaVersion: 2,
|
|
1987
|
+
responseId: this.responseId,
|
|
1988
|
+
definitionId: this.definitionId,
|
|
1989
|
+
participantId: this.participantId,
|
|
1990
|
+
submittedAt: this.submittedAt,
|
|
1991
|
+
answers: this.answers,
|
|
1992
|
+
itemMetadata: this.itemMetadata,
|
|
1993
|
+
events: this.events,
|
|
1994
|
+
customValues: this.customValues,
|
|
1995
|
+
evaluationContext: this.evaluationContext,
|
|
1996
|
+
randomization: this.randomization
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
static deserialize(input) {
|
|
2000
|
+
const json = surveyResponseSchema.parse(input);
|
|
2001
|
+
const response = new SurveyResponse(json.responseId, json.definitionId);
|
|
2002
|
+
response.answers = json.answers;
|
|
2003
|
+
response.participantId = json.participantId;
|
|
2004
|
+
response.submittedAt = json.submittedAt;
|
|
2005
|
+
response.itemMetadata = json.itemMetadata ?? {};
|
|
2006
|
+
response.events = json.events;
|
|
2007
|
+
response.customValues = json.customValues;
|
|
2008
|
+
response.evaluationContext = json.evaluationContext;
|
|
2009
|
+
response.randomization = json.randomization;
|
|
2010
|
+
return response;
|
|
2011
|
+
}
|
|
2012
|
+
};
|
|
2013
|
+
//#endregion
|
|
2014
|
+
//#region src/response-exporter/types.ts
|
|
2015
|
+
/** Meta column keys in fixed order (common attributes first). */
|
|
2016
|
+
const META_COLUMN_ORDER = [
|
|
2017
|
+
"submittedAt",
|
|
2018
|
+
"participantId",
|
|
2019
|
+
"responseId",
|
|
2020
|
+
"sourceId"
|
|
2021
|
+
];
|
|
2022
|
+
/** Built-in transform modes for slot export. */
|
|
2023
|
+
const SlotTransformMode = {
|
|
2024
|
+
default: "default",
|
|
2025
|
+
mask: "mask",
|
|
2026
|
+
dateFormat: "dateFormat"
|
|
2027
|
+
};
|
|
2028
|
+
const EXPORTER_PROFILE_SCHEMA_VERSION = 1;
|
|
2029
|
+
/** Source of an export column. */
|
|
2030
|
+
const ExportColumnSource = {
|
|
2031
|
+
meta: "meta",
|
|
2032
|
+
context: "context",
|
|
2033
|
+
response: "response"
|
|
2034
|
+
};
|
|
2035
|
+
//#endregion
|
|
2036
|
+
//#region src/response-exporter/column-id.ts
|
|
2037
|
+
function encodeExportColumnId(identity) {
|
|
2038
|
+
return JSON.stringify(identity);
|
|
2039
|
+
}
|
|
2040
|
+
function decodeExportColumnId(value) {
|
|
2041
|
+
const tuple = JSON.parse(value);
|
|
2042
|
+
if (!Array.isArray(tuple) || tuple.some((part) => typeof part !== "string") || !(tuple.length === 2 && (tuple[0] === "meta" || tuple[0] === "context") || tuple[0] === "response" && (tuple.length === 3 && tuple[2] === "value" || tuple.length === 4 && tuple[2] === "option"))) throw new Error(`Invalid export column ID: ${value}`);
|
|
2043
|
+
if (JSON.stringify(tuple) !== value) throw new Error("Export column IDs must use the canonical JSON tuple encoding");
|
|
2044
|
+
return tuple;
|
|
2045
|
+
}
|
|
2046
|
+
//#endregion
|
|
2047
|
+
//#region src/response-exporter/slot-formatter.ts
|
|
2048
|
+
/** Default transform config when no profile override. */
|
|
2049
|
+
const DEFAULT_TRANSFORM = { mode: SlotTransformMode.default };
|
|
2050
|
+
/**
|
|
2051
|
+
* Export a single response slot to a string. Pure function for unit testing.
|
|
2052
|
+
* Uses default formatting by ValueType unless overridden by transformConfig.
|
|
2053
|
+
*/
|
|
2054
|
+
function exportSingleResponseSlot(params) {
|
|
2055
|
+
const { value, valueType, transformConfig } = params;
|
|
2056
|
+
if (value === void 0 || value === null) return "";
|
|
2057
|
+
const mode = transformConfig.mode;
|
|
2058
|
+
if (mode === SlotTransformMode.mask) return maskValue(value, transformConfig.maskText ?? "***");
|
|
2059
|
+
if (mode === SlotTransformMode.dateFormat && (valueType === ValueType.date || valueType === ValueType.dateArray)) return formatDateValue(value, transformConfig.dateFormat?.pattern ?? "yyyy-MM-dd");
|
|
2060
|
+
if (value.type === ValueType.reference || value.type === ValueType.referenceArray) {
|
|
2061
|
+
const keys = transformConfig.categorical === "labels" ? transformConfig.optionLabels : transformConfig.optionCodes ?? params.referenceValueKeys;
|
|
2062
|
+
const exportReference = (id) => {
|
|
2063
|
+
if (transformConfig.categorical === "ids") return id;
|
|
2064
|
+
const key = keys && Object.hasOwn(keys, id) ? keys[id] : void 0;
|
|
2065
|
+
if (typeof key !== "string" || !key.trim()) throw new Error(`Missing categorical export mapping for option "${id}"`);
|
|
2066
|
+
return key.trim();
|
|
2067
|
+
};
|
|
2068
|
+
return value.type === ValueType.reference ? exportReference(value.value) : JSON.stringify(value.value.map(exportReference));
|
|
2069
|
+
}
|
|
2070
|
+
return formatDefault(value);
|
|
2071
|
+
}
|
|
2072
|
+
const BooleanExportValues = {
|
|
2073
|
+
TRUE: "TRUE",
|
|
2074
|
+
FALSE: "FALSE"
|
|
2075
|
+
};
|
|
2076
|
+
function formatDefault(value) {
|
|
2077
|
+
switch (value.type) {
|
|
2078
|
+
case ValueType.string: return String(value.value);
|
|
2079
|
+
case ValueType.reference: return String(value.value);
|
|
2080
|
+
case ValueType.boolean: return value.value ? BooleanExportValues.TRUE : BooleanExportValues.FALSE;
|
|
2081
|
+
case ValueType.number: return value.precision === NumberPrecision.int ? String(Math.floor(value.value)) : String(value.value);
|
|
2082
|
+
case ValueType.date: return value.value;
|
|
2083
|
+
case ValueType.duration: {
|
|
2084
|
+
const d = value;
|
|
2085
|
+
return `${d.precision === NumberPrecision.int ? Math.floor(d.value) : d.value} ${d.unit}`;
|
|
2086
|
+
}
|
|
2087
|
+
case ValueType.stringArray: return value.value.join("; ");
|
|
2088
|
+
case ValueType.referenceArray: return value.value.join("; ");
|
|
2089
|
+
case ValueType.numberArray: {
|
|
2090
|
+
const arr = value.value;
|
|
2091
|
+
const prec = value.precision;
|
|
2092
|
+
const fmt = (n) => prec === NumberPrecision.int ? Math.floor(n) : n;
|
|
2093
|
+
return arr.map(fmt).join("; ");
|
|
2094
|
+
}
|
|
2095
|
+
case ValueType.dateArray: return value.value.join("; ");
|
|
2096
|
+
case ValueType.durationArray: {
|
|
2097
|
+
const d = value;
|
|
2098
|
+
const prec = d.precision === NumberPrecision.int;
|
|
2099
|
+
const fmt = (n) => prec ? Math.floor(n) : n;
|
|
2100
|
+
return d.value.map((v) => `${fmt(v)} ${d.unit}`).join("; ");
|
|
2101
|
+
}
|
|
2102
|
+
default: return String(value.value ?? "");
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
function formatDateValue(value, pattern) {
|
|
2106
|
+
if (value.type === ValueType.date) return formatCalendarDate(value.value, pattern);
|
|
2107
|
+
if (value.type === ValueType.dateArray) return value.value.map((date) => formatCalendarDate(date, pattern)).join("; ");
|
|
2108
|
+
return formatDefault(value);
|
|
2109
|
+
}
|
|
2110
|
+
function maskValue(value, maskText) {
|
|
2111
|
+
return valueToString(value) ? maskText : "";
|
|
2112
|
+
}
|
|
2113
|
+
function valueToString(value) {
|
|
2114
|
+
switch (value.type) {
|
|
2115
|
+
case ValueType.string:
|
|
2116
|
+
case ValueType.reference: return String(value.value);
|
|
2117
|
+
case ValueType.stringArray:
|
|
2118
|
+
case ValueType.referenceArray: return value.value.join(" ");
|
|
2119
|
+
default: return String(value.value ?? "");
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
//#endregion
|
|
2123
|
+
//#region src/response-exporter/projection.ts
|
|
2124
|
+
function optionIds(slot) {
|
|
2125
|
+
return [...new Set((slot.allowedValues ?? []).flatMap((value) => value.type === ValueType.reference ? [value.value] : value.type === ValueType.referenceArray ? value.value : []))];
|
|
2126
|
+
}
|
|
2127
|
+
function optionMapping(slot, config) {
|
|
2128
|
+
const ids = optionIds(slot);
|
|
2129
|
+
const encoding = config.categorical ?? "codes";
|
|
2130
|
+
const mapping = encoding === "ids" ? Object.fromEntries(ids.map((id) => [id, id])) : encoding === "labels" ? config.optionLabels ?? ownValue(slot.referenceValueLabels, config.locale ?? "en") ?? {} : config.optionCodes ?? slot.referenceValueKeys ?? {};
|
|
2131
|
+
const used = /* @__PURE__ */ new Set();
|
|
2132
|
+
for (const id of ids) {
|
|
2133
|
+
if (typeof mapping[id] !== "string" || !mapping[id].trim()) throw new Error(`Variable "${slot.variableName}" has no ${encoding} mapping for option "${id}"`);
|
|
2134
|
+
if (used.has(mapping[id])) throw new Error(`Variable "${slot.variableName}" has ambiguous ${encoding} mapping "${mapping[id]}"`);
|
|
2135
|
+
used.add(mapping[id]);
|
|
2136
|
+
}
|
|
2137
|
+
for (const id of [...Object.keys(config.optionCodes ?? {}), ...Object.keys(config.optionLabels ?? {})]) if (!ids.includes(id)) throw new Error(`Mapping for "${slot.variableName}" contains unknown option "${id}"`);
|
|
2138
|
+
return mapping;
|
|
2139
|
+
}
|
|
2140
|
+
/** Compile every declared role before applying inclusion settings. */
|
|
2141
|
+
function projectSlot(slot, config, allRoles = false) {
|
|
2142
|
+
if (![
|
|
2143
|
+
"default",
|
|
2144
|
+
"mask",
|
|
2145
|
+
"dateFormat"
|
|
2146
|
+
].includes(config.mode)) throw new Error(`Unsupported export transform "${config.mode}"`);
|
|
2147
|
+
if (config.categorical && ![
|
|
2148
|
+
"codes",
|
|
2149
|
+
"labels",
|
|
2150
|
+
"ids"
|
|
2151
|
+
].includes(config.categorical)) throw new Error("Unsupported categorical encoding");
|
|
2152
|
+
if (config.multiple && ![
|
|
2153
|
+
"json",
|
|
2154
|
+
"delimited",
|
|
2155
|
+
"indicators"
|
|
2156
|
+
].includes(config.multiple)) throw new Error("Unsupported multiple-selection encoding");
|
|
2157
|
+
if (config.mode === "dateFormat" && slot.valueType !== "date" && slot.valueType !== "date[]") throw new Error("Date formatting requires a date variable");
|
|
2158
|
+
if (config.mode === "dateFormat") {
|
|
2159
|
+
const pattern = config.dateFormat?.pattern ?? "yyyy-MM-dd";
|
|
2160
|
+
if (!pattern.trim()) throw new Error("Date export pattern cannot be empty");
|
|
2161
|
+
formatCalendarDate("2026-01-15", pattern);
|
|
2162
|
+
}
|
|
2163
|
+
if (config.durationUnit && (!Object.values(DurationUnits).includes(config.durationUnit) || !slot.valueType.startsWith("duration"))) throw new Error("Duration units require a duration variable and a supported unit");
|
|
2164
|
+
if (config.booleanValues && (!config.booleanValues.true || !config.booleanValues.false || config.booleanValues.true === config.booleanValues.false)) throw new Error("Boolean export values must be distinct and nonempty");
|
|
2165
|
+
const categorical = slot.valueType === ValueType.reference || slot.valueType === ValueType.referenceArray;
|
|
2166
|
+
if (config.multiple && slot.valueType !== ValueType.referenceArray) throw new Error(`Multiple-selection encoding requires a reference[] variable: ${slot.variableName}`);
|
|
2167
|
+
if ((config.categorical || config.optionCodes || config.optionLabels) && !categorical) throw new Error(`Categorical encoding requires a categorical variable: ${slot.variableName}`);
|
|
2168
|
+
if (config.booleanValues && slot.valueType !== ValueType.boolean) throw new Error(`Boolean encoding requires a boolean variable: ${slot.variableName}`);
|
|
2169
|
+
if (config.precision !== void 0 && ![
|
|
2170
|
+
"number",
|
|
2171
|
+
"number[]",
|
|
2172
|
+
"duration",
|
|
2173
|
+
"duration[]"
|
|
2174
|
+
].includes(slot.valueType)) throw new Error("Precision requires a numeric or duration variable");
|
|
2175
|
+
if (config.precision !== void 0 && (!Number.isInteger(config.precision) || config.precision < 0 || config.precision > 15)) throw new Error("Precision must be an integer between 0 and 15");
|
|
2176
|
+
if (config.multiple === "delimited" && (!config.delimiter || config.delimiter.includes("\"") || /[\r\n]/.test(config.delimiter))) throw new Error("Delimited selection export requires an explicit delimiter without quotes or line breaks");
|
|
2177
|
+
if (!allRoles && (config.include === false || slot.computed && config.include !== true)) return [];
|
|
2178
|
+
const mapping = categorical && !allRoles ? optionMapping(slot, config) : {};
|
|
2179
|
+
const base = {
|
|
2180
|
+
source: "response",
|
|
2181
|
+
itemId: slot.itemId,
|
|
2182
|
+
slotId: slot.slotId,
|
|
2183
|
+
valueType: slot.valueType
|
|
2184
|
+
};
|
|
2185
|
+
const scalar = {
|
|
2186
|
+
...base,
|
|
2187
|
+
id: encodeExportColumnId([
|
|
2188
|
+
"response",
|
|
2189
|
+
slot.slotId,
|
|
2190
|
+
"value"
|
|
2191
|
+
]),
|
|
2192
|
+
header: slot.variableName
|
|
2193
|
+
};
|
|
2194
|
+
const indicators = () => optionIds(slot).map((optionId) => ({
|
|
2195
|
+
...base,
|
|
2196
|
+
id: encodeExportColumnId([
|
|
2197
|
+
"response",
|
|
2198
|
+
slot.slotId,
|
|
2199
|
+
"option",
|
|
2200
|
+
optionId
|
|
2201
|
+
]),
|
|
2202
|
+
header: `${slot.variableName}_${mapping[optionId] ?? optionId}`,
|
|
2203
|
+
optionId
|
|
2204
|
+
}));
|
|
2205
|
+
return allRoles ? [scalar, ...slot.valueType === ValueType.referenceArray ? indicators() : []] : config.multiple === "indicators" ? indicators() : [scalar];
|
|
2206
|
+
}
|
|
2207
|
+
function formatProjectedValue(slot, column, value, config) {
|
|
2208
|
+
if (value === void 0) return "";
|
|
2209
|
+
if (config.mode === "mask") return config.maskText ?? "***";
|
|
2210
|
+
if (value.type === ValueType.reference || value.type === ValueType.referenceArray) {
|
|
2211
|
+
const mapping = optionMapping(slot, config);
|
|
2212
|
+
const values = value.type === ValueType.reference ? [value.value] : value.value;
|
|
2213
|
+
if (values.some((id) => !Object.hasOwn(mapping, id))) throw new Error(`Unmapped option in "${slot.variableName}"`);
|
|
2214
|
+
if (column.optionId) return values.includes(column.optionId) ? "1" : "0";
|
|
2215
|
+
const encoded = values.map((id) => mapping[id]);
|
|
2216
|
+
if (value.type === ValueType.reference) return encoded[0];
|
|
2217
|
+
if (config.multiple === "delimited") return encoded.map((entry) => `"${entry.replaceAll("\"", "\"\"")}"`).join(config.delimiter);
|
|
2218
|
+
return JSON.stringify(encoded);
|
|
2219
|
+
}
|
|
2220
|
+
if (value.type === ValueType.boolean) {
|
|
2221
|
+
const values = config.booleanValues ?? {
|
|
2222
|
+
true: "TRUE",
|
|
2223
|
+
false: "FALSE"
|
|
2224
|
+
};
|
|
2225
|
+
if (!values.true || !values.false || values.true === values.false) throw new Error("Boolean export values must be distinct and nonempty");
|
|
2226
|
+
return value.value ? values.true : values.false;
|
|
2227
|
+
}
|
|
2228
|
+
if (value.type === ValueType.duration || value.type === ValueType.durationArray) {
|
|
2229
|
+
const unit = config.durationUnit ?? "seconds";
|
|
2230
|
+
const seconds = {
|
|
2231
|
+
seconds: 1,
|
|
2232
|
+
minutes: 60,
|
|
2233
|
+
hours: 3600,
|
|
2234
|
+
days: 86400,
|
|
2235
|
+
weeks: 604800
|
|
2236
|
+
};
|
|
2237
|
+
if (unit !== value.unit && (!seconds[unit] || !seconds[value.unit])) throw new Error("Calendar months and years cannot be converted to fixed durations");
|
|
2238
|
+
const convert = (number) => {
|
|
2239
|
+
const n = number * (unit === value.unit ? 1 : seconds[value.unit] / seconds[unit]);
|
|
2240
|
+
return config.precision === void 0 ? String(n) : n.toFixed(config.precision);
|
|
2241
|
+
};
|
|
2242
|
+
return Array.isArray(value.value) ? JSON.stringify(value.value.map(convert)) : convert(value.value);
|
|
2243
|
+
}
|
|
2244
|
+
if (value.type === ValueType.number && config.precision !== void 0) return value.value.toFixed(config.precision);
|
|
2245
|
+
if (value.type === ValueType.numberArray && config.precision !== void 0) return `[${value.value.map((number) => number.toFixed(config.precision)).join(",")}]`;
|
|
2246
|
+
if (Array.isArray(value.value) && config.mode !== "dateFormat") return JSON.stringify(value.value);
|
|
2247
|
+
return exportSingleResponseSlot({
|
|
2248
|
+
value,
|
|
2249
|
+
valueType: slot.valueType,
|
|
2250
|
+
transformConfig: config
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
/** Internal typed diagnostics let authoring distinguish conflicts from unfinished mappings. */
|
|
2254
|
+
function getDefaultProjectionDiagnostics(survey) {
|
|
2255
|
+
const issues = [];
|
|
2256
|
+
const headers = /* @__PURE__ */ new Set([
|
|
2257
|
+
"submittedAt",
|
|
2258
|
+
"participantId",
|
|
2259
|
+
"responseId",
|
|
2260
|
+
"sourceId"
|
|
2261
|
+
]);
|
|
2262
|
+
const definitions = survey.slots.definitions;
|
|
2263
|
+
const slots = definitions.some((slot) => slot.exportSettings?.categorical === "labels") ? localizeExportSlots(survey, definitions) : definitions;
|
|
2264
|
+
for (const slot of slots) try {
|
|
2265
|
+
for (const column of projectSlot(slot, {
|
|
2266
|
+
mode: "default",
|
|
2267
|
+
...slot.exportSettings
|
|
2268
|
+
})) {
|
|
2269
|
+
if (headers.has(column.header)) issues.push({
|
|
2270
|
+
code: "duplicate-header",
|
|
2271
|
+
header: column.header,
|
|
2272
|
+
message: `Duplicate export column header '${column.header}'`
|
|
2273
|
+
});
|
|
2274
|
+
headers.add(column.header);
|
|
2275
|
+
}
|
|
2276
|
+
} catch (error) {
|
|
2277
|
+
issues.push({
|
|
2278
|
+
code: "invalid-projection",
|
|
2279
|
+
slotId: slot.slotId,
|
|
2280
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
return issues;
|
|
2284
|
+
}
|
|
2285
|
+
/** Collect draft diagnostics without stopping at the first unfinished variable. */
|
|
2286
|
+
function getDefaultProjectionIssues(survey) {
|
|
2287
|
+
return getDefaultProjectionDiagnostics(survey).map((issue) => issue.message);
|
|
2288
|
+
}
|
|
2289
|
+
/** Fully resolved output semantics, shared by compatibility checks and codebooks. */
|
|
2290
|
+
function resolvedSlotEncoding(slot, config) {
|
|
2291
|
+
const encoding = {
|
|
2292
|
+
mode: config.mode,
|
|
2293
|
+
include: config.include ?? !slot.computed
|
|
2294
|
+
};
|
|
2295
|
+
if (config.mode === "mask") return {
|
|
2296
|
+
...encoding,
|
|
2297
|
+
maskText: config.maskText ?? "***"
|
|
2298
|
+
};
|
|
2299
|
+
if (config.mode === "dateFormat") encoding.dateFormat = { pattern: config.dateFormat?.pattern ?? "yyyy-MM-dd" };
|
|
2300
|
+
if (slot.valueType === "reference" || slot.valueType === "reference[]") {
|
|
2301
|
+
encoding.categorical = config.categorical ?? "codes";
|
|
2302
|
+
if (encoding.categorical === "labels") {
|
|
2303
|
+
encoding.locale = config.locale ?? "en";
|
|
2304
|
+
encoding.optionLabels = optionMapping(slot, config);
|
|
2305
|
+
} else if (encoding.categorical === "codes") encoding.optionCodes = optionMapping(slot, config);
|
|
2306
|
+
if (slot.valueType === "reference[]") {
|
|
2307
|
+
encoding.multiple = config.multiple ?? "json";
|
|
2308
|
+
if (encoding.multiple === "delimited") encoding.delimiter = config.delimiter;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
if (slot.valueType === "boolean") encoding.booleanValues = config.booleanValues ?? {
|
|
2312
|
+
true: "TRUE",
|
|
2313
|
+
false: "FALSE"
|
|
2314
|
+
};
|
|
2315
|
+
if (slot.valueType.startsWith("duration")) encoding.durationUnit = config.durationUnit ?? "seconds";
|
|
2316
|
+
if (config.precision !== void 0) encoding.precision = config.precision;
|
|
2317
|
+
return structuredClone(encoding);
|
|
2318
|
+
}
|
|
2319
|
+
/** Localization is projected from this exact revision, never cached in structural slot identities. */
|
|
2320
|
+
function localizedExportSlots(survey) {
|
|
2321
|
+
return localizeExportSlots(survey, survey.slots.definitions);
|
|
2322
|
+
}
|
|
2323
|
+
function localizeExportSlots(survey, definitions) {
|
|
2324
|
+
const fallbackLocale = survey.locales[0];
|
|
2325
|
+
return definitions.map((slot) => ({
|
|
2326
|
+
...slot,
|
|
2327
|
+
referenceValueLabels: Object.fromEntries([.../* @__PURE__ */ new Set([...survey.locales, ...Object.keys(slot.referenceValueLabels ?? {})])].map((locale) => [locale, Object.fromEntries(optionIds(slot).flatMap((id) => {
|
|
2328
|
+
const label = survey.owners.get(id)?.roles.some((role) => role.role === "label") ? getContentPlainText(readLocalizationContent(survey, {
|
|
2329
|
+
scope: "content",
|
|
2330
|
+
ownerId: id,
|
|
2331
|
+
role: "label"
|
|
2332
|
+
}, locale) ?? (fallbackLocale ? readLocalizationContent(survey, {
|
|
2333
|
+
scope: "content",
|
|
2334
|
+
ownerId: id,
|
|
2335
|
+
role: "label"
|
|
2336
|
+
}, fallbackLocale) : void 0)).trim() : ownValue(ownValue(slot.referenceValueLabels, locale), id);
|
|
2337
|
+
return label ? [[id, label]] : [];
|
|
2338
|
+
}))]))
|
|
2339
|
+
}));
|
|
2340
|
+
}
|
|
2341
|
+
//#endregion
|
|
2342
|
+
export { shuffleArray as $, isAdmissibleInDraft as A, expressionIssues as B, surveyEventSchema as C, DefinitionError as D, hasPermittedChange as E, allocateResponseVariableName as F, responseValueElements as G, functionTypeIssues as H, isValidResponseVariableName as I, FUNCTION_SIGNATURES as J, responseValuesEqual as K, suggestResponseVariableName as L, getCompositionLabel as M, ResponseSlotRegistry as N, Survey as O, RESPONSE_VARIABLE_NAME_MAX_LENGTH as P, resolveUniqueItemKey as Q, ExpressionEvaluationError as R, itemResponseMetadataSchema as S, canWriteSelection as T, inferExpressionReturnType as U, expressionTypeIssues as V, invalidResponseDomainValues as W, generateCodingKey as X, createSeededRandom as Y, generateId as Z, SlotTransformMode as _, optionIds as a, surveyResponseSchema as b, resolvedSlotEncoding as c, exportSingleResponseSlot as d, shuffleIndices as et, decodeExportColumnId as f, META_COLUMN_ORDER as g, ExportColumnSource as h, localizedExportSlots as i, surveyLocales as j, checkDocumentBudget as k, BooleanExportValues as l, EXPORTER_PROFILE_SCHEMA_VERSION as m, getDefaultProjectionDiagnostics as n, optionMapping as o, encodeExportColumnId as p, validateResponseSlotValue as q, getDefaultProjectionIssues as r, projectSlot as s, formatProjectedValue as t, structuredCloneMethod as tt, DEFAULT_TRANSFORM as u, SURVEY_RESPONSE_SCHEMA_VERSION as v, SurveyStateGraph as w, SurveyEventTypes as x, SurveyResponse as y, evaluateExpression as z };
|
|
2343
|
+
|
|
2344
|
+
//# sourceMappingURL=projection-DrLPFroE.mjs.map
|