@form-engine-ts/core 2.2.0 → 2.5.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 +18 -0
- package/dist/index.cjs +500 -25
- package/dist/index.d.cts +124 -1
- package/dist/index.d.ts +124 -1
- package/dist/index.js +490 -25
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,92 @@
|
|
|
1
|
+
// src/policy.ts
|
|
2
|
+
function collectRecordKeys(value, path, pathsByLocale) {
|
|
3
|
+
for (const locale of Object.keys(value ?? {})) {
|
|
4
|
+
const paths = pathsByLocale.get(locale) ?? [];
|
|
5
|
+
paths.push(`${path}.${locale}`);
|
|
6
|
+
pathsByLocale.set(locale, paths);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function collectSchemaLocales(schema) {
|
|
10
|
+
const pathsByLocale = /* @__PURE__ */ new Map();
|
|
11
|
+
collectRecordKeys(schema.translations, "translations", pathsByLocale);
|
|
12
|
+
collectRecordKeys(schema.translationMetadata, "translationMetadata", pathsByLocale);
|
|
13
|
+
schema.fields.forEach((field, fieldIndex) => {
|
|
14
|
+
collectRecordKeys(field.translations, `fields[${fieldIndex}].translations`, pathsByLocale);
|
|
15
|
+
collectRecordKeys(field.translationMetadata, `fields[${fieldIndex}].translationMetadata`, pathsByLocale);
|
|
16
|
+
if (!("options" in field)) return;
|
|
17
|
+
field.options.forEach((option, optionIndex) => {
|
|
18
|
+
collectRecordKeys(
|
|
19
|
+
option.translations,
|
|
20
|
+
`fields[${fieldIndex}].options[${optionIndex}].translations`,
|
|
21
|
+
pathsByLocale
|
|
22
|
+
);
|
|
23
|
+
collectRecordKeys(
|
|
24
|
+
option.translationMetadata,
|
|
25
|
+
`fields[${fieldIndex}].options[${optionIndex}].translationMetadata`,
|
|
26
|
+
pathsByLocale
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
schema.pages?.forEach((page, pageIndex) => {
|
|
31
|
+
collectRecordKeys(page.translations, `pages[${pageIndex}].translations`, pathsByLocale);
|
|
32
|
+
collectRecordKeys(page.translationMetadata, `pages[${pageIndex}].translationMetadata`, pathsByLocale);
|
|
33
|
+
});
|
|
34
|
+
const translationLocales = new Set(pathsByLocale.keys());
|
|
35
|
+
const allUniqueLocales = /* @__PURE__ */ new Set([
|
|
36
|
+
...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
|
|
37
|
+
...schema.supportedLocales ?? [],
|
|
38
|
+
...translationLocales
|
|
39
|
+
]);
|
|
40
|
+
return {
|
|
41
|
+
...schema.defaultLocale === void 0 ? {} : { defaultLocale: schema.defaultLocale },
|
|
42
|
+
supportedLocales: schema.supportedLocales ?? [],
|
|
43
|
+
translationLocales,
|
|
44
|
+
allUniqueLocales,
|
|
45
|
+
translationLocalePaths: pathsByLocale
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
1
49
|
// src/sanitization.ts
|
|
50
|
+
function registeredEntries(value, registeredLocales) {
|
|
51
|
+
if (value === void 0) return void 0;
|
|
52
|
+
const entries = Object.entries(value).filter(([locale]) => registeredLocales.has(locale));
|
|
53
|
+
return entries.length === 0 ? void 0 : Object.fromEntries(entries);
|
|
54
|
+
}
|
|
55
|
+
function sanitizeNodeLocales(node, registeredLocales) {
|
|
56
|
+
const { translationMetadata: _translationMetadata, ...base } = node;
|
|
57
|
+
const translationMetadata = registeredEntries(node.translationMetadata, registeredLocales);
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
...translationMetadata === void 0 ? {} : { translationMetadata }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function sanitizeOptionLocales(option, registeredLocales) {
|
|
64
|
+
const { translations: _translations, ...base } = sanitizeNodeLocales(option, registeredLocales);
|
|
65
|
+
const translations = registeredEntries(option.translations, registeredLocales);
|
|
66
|
+
return { ...base, ...translations === void 0 ? {} : { translations } };
|
|
67
|
+
}
|
|
68
|
+
function sanitizeFieldLocales(field, registeredLocales) {
|
|
69
|
+
const localizedNode = sanitizeNodeLocales(field, registeredLocales);
|
|
70
|
+
const { translations: _translations, ...base } = localizedNode;
|
|
71
|
+
const translations = registeredEntries(field.translations, registeredLocales);
|
|
72
|
+
const localized = {
|
|
73
|
+
...base,
|
|
74
|
+
...translations === void 0 ? {} : { translations }
|
|
75
|
+
};
|
|
76
|
+
if (!("options" in localizedNode)) return localized;
|
|
77
|
+
const { translations: _choiceTranslations, ...choiceBase } = localizedNode;
|
|
78
|
+
return {
|
|
79
|
+
...choiceBase,
|
|
80
|
+
...translations === void 0 ? {} : { translations },
|
|
81
|
+
options: localizedNode.options.map((option) => sanitizeOptionLocales(option, registeredLocales))
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function sanitizePageLocales(page, registeredLocales) {
|
|
85
|
+
const localizedNode = sanitizeNodeLocales(page, registeredLocales);
|
|
86
|
+
const { translations: _translations, ...base } = localizedNode;
|
|
87
|
+
const translations = registeredEntries(page.translations, registeredLocales);
|
|
88
|
+
return { ...base, ...translations === void 0 ? {} : { translations } };
|
|
89
|
+
}
|
|
2
90
|
function cyclicQuestionIds(fields) {
|
|
3
91
|
const firstById = /* @__PURE__ */ new Map();
|
|
4
92
|
for (const field of fields) {
|
|
@@ -85,8 +173,13 @@ function validateSchemaStructure(schema) {
|
|
|
85
173
|
}
|
|
86
174
|
function sanitizeSchema(schema) {
|
|
87
175
|
const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
|
|
176
|
+
const registeredLocales = /* @__PURE__ */ new Set([
|
|
177
|
+
...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
|
|
178
|
+
...schema.supportedLocales ?? []
|
|
179
|
+
]);
|
|
88
180
|
const cyclic = cyclicQuestionIds(schema.fields);
|
|
89
|
-
const sanitizedFields = schema.fields.map((
|
|
181
|
+
const sanitizedFields = schema.fields.map((sourceField) => {
|
|
182
|
+
const field = sanitizeFieldLocales(sourceField, registeredLocales);
|
|
90
183
|
const sourceId = field.displayCondition?.questionId;
|
|
91
184
|
if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
|
|
92
185
|
return field;
|
|
@@ -94,15 +187,19 @@ function sanitizeSchema(schema) {
|
|
|
94
187
|
const { displayCondition: _displayCondition, ...sanitized } = field;
|
|
95
188
|
return sanitized;
|
|
96
189
|
});
|
|
190
|
+
const localizedSchema = sanitizeNodeLocales(schema, registeredLocales);
|
|
191
|
+
const { translations: _translations, ...schemaWithoutLocaleContent } = localizedSchema;
|
|
192
|
+
const translations = registeredEntries(schema.translations, registeredLocales);
|
|
97
193
|
const base = {
|
|
98
|
-
...
|
|
194
|
+
...schemaWithoutLocaleContent,
|
|
195
|
+
...translations === void 0 ? {} : { translations },
|
|
99
196
|
fields: sanitizedFields
|
|
100
197
|
};
|
|
101
198
|
if (schema.pages === void 0) return base;
|
|
102
199
|
const assigned = /* @__PURE__ */ new Set();
|
|
103
|
-
const pages = schema.pages.map((
|
|
104
|
-
...
|
|
105
|
-
questionIds:
|
|
200
|
+
const pages = schema.pages.map((sourcePage) => ({
|
|
201
|
+
...sanitizePageLocales(sourcePage, registeredLocales),
|
|
202
|
+
questionIds: sourcePage.questionIds.filter((id) => {
|
|
106
203
|
if (!existingQuestionIds.has(id) || assigned.has(id)) return false;
|
|
107
204
|
assigned.add(id);
|
|
108
205
|
return true;
|
|
@@ -506,31 +603,38 @@ function validatePolicy(schema, policy, issues) {
|
|
|
506
603
|
}
|
|
507
604
|
}
|
|
508
605
|
}
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
606
|
+
const collectedLocales = collectSchemaLocales(schema);
|
|
607
|
+
const registeredLocales = /* @__PURE__ */ new Set([
|
|
608
|
+
...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
|
|
609
|
+
...schema.supportedLocales ?? []
|
|
610
|
+
]);
|
|
611
|
+
for (const locale of collectedLocales.translationLocales) {
|
|
612
|
+
if (registeredLocales.has(locale)) continue;
|
|
613
|
+
for (const path of collectedLocales.translationLocalePaths.get(locale) ?? []) {
|
|
517
614
|
issue(
|
|
518
615
|
issues,
|
|
519
|
-
|
|
520
|
-
"
|
|
521
|
-
`
|
|
616
|
+
path,
|
|
617
|
+
"unregistered_translation_locale",
|
|
618
|
+
`Translation locale ${locale} is not registered by defaultLocale or supportedLocales.`
|
|
522
619
|
);
|
|
523
620
|
}
|
|
621
|
+
}
|
|
622
|
+
if (policy.allowedLocales !== void 0) {
|
|
623
|
+
const pathsByLocale = /* @__PURE__ */ new Map();
|
|
624
|
+
if (schema.defaultLocale !== void 0) pathsByLocale.set(schema.defaultLocale, ["defaultLocale"]);
|
|
524
625
|
schema.supportedLocales?.forEach((locale, index) => {
|
|
525
|
-
|
|
526
|
-
issue(
|
|
527
|
-
issues,
|
|
528
|
-
`supportedLocales[${index}]`,
|
|
529
|
-
"disallowed_locale",
|
|
530
|
-
`Locale ${locale} is not allowed by the form policy.`
|
|
531
|
-
);
|
|
532
|
-
}
|
|
626
|
+
pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], `supportedLocales[${index}]`]);
|
|
533
627
|
});
|
|
628
|
+
for (const [locale, paths] of collectedLocales.translationLocalePaths) {
|
|
629
|
+
pathsByLocale.set(locale, [...pathsByLocale.get(locale) ?? [], ...paths]);
|
|
630
|
+
}
|
|
631
|
+
for (const [locale, paths] of pathsByLocale) {
|
|
632
|
+
if (!policy.allowedLocales.includes(locale)) {
|
|
633
|
+
for (const path of paths) {
|
|
634
|
+
issue(issues, path, "disallowed_locale", `Locale ${locale} is not allowed by the form policy.`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
534
638
|
for (const locale of policy.requiredLocales ?? []) {
|
|
535
639
|
if (!policy.allowedLocales.includes(locale)) {
|
|
536
640
|
issue(
|
|
@@ -542,7 +646,7 @@ function validatePolicy(schema, policy, issues) {
|
|
|
542
646
|
}
|
|
543
647
|
}
|
|
544
648
|
}
|
|
545
|
-
if (policy.maxLocales !== void 0 &&
|
|
649
|
+
if (policy.maxLocales !== void 0 && collectedLocales.allUniqueLocales.size > policy.maxLocales) {
|
|
546
650
|
issue(issues, "supportedLocales", "max_locales_exceeded", `At most ${policy.maxLocales} locales are allowed.`);
|
|
547
651
|
}
|
|
548
652
|
for (const locale of policy.requiredLocales ?? []) addRequiredTranslationIssues(schema, locale, issues);
|
|
@@ -938,6 +1042,152 @@ function aggregateResponses(schema, submissions) {
|
|
|
938
1042
|
questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
|
|
939
1043
|
};
|
|
940
1044
|
}
|
|
1045
|
+
function responseValues(submission) {
|
|
1046
|
+
return "values" in submission ? submission.values : submission.answers;
|
|
1047
|
+
}
|
|
1048
|
+
function responseIdentifier(submission) {
|
|
1049
|
+
return "id" in submission ? submission.id : submission.responseId;
|
|
1050
|
+
}
|
|
1051
|
+
function responseMismatch(schema, submission) {
|
|
1052
|
+
if (submission.formId !== schema.id) {
|
|
1053
|
+
return `Submission ${responseIdentifier(submission)} does not match form ${schema.id}.`;
|
|
1054
|
+
}
|
|
1055
|
+
if ("formVersion" in submission && submission.formVersion !== schema.version) {
|
|
1056
|
+
return `Submission ${responseIdentifier(submission)} does not match ${schema.id}@${schema.version}.`;
|
|
1057
|
+
}
|
|
1058
|
+
return void 0;
|
|
1059
|
+
}
|
|
1060
|
+
var IncrementalResponseAccumulator = class _IncrementalResponseAccumulator {
|
|
1061
|
+
#schema;
|
|
1062
|
+
#mode;
|
|
1063
|
+
#fields;
|
|
1064
|
+
#submissionCount = 0;
|
|
1065
|
+
constructor(schema, options) {
|
|
1066
|
+
assertValidFormSchema(schema);
|
|
1067
|
+
this.#schema = JSON.parse(JSON.stringify(schema));
|
|
1068
|
+
this.#mode = options.mode ?? "strict";
|
|
1069
|
+
this.#fields = new Map(
|
|
1070
|
+
schema.fields.map((field) => [
|
|
1071
|
+
field.id,
|
|
1072
|
+
{
|
|
1073
|
+
answeredCount: 0,
|
|
1074
|
+
total: 0,
|
|
1075
|
+
minimum: null,
|
|
1076
|
+
maximum: null,
|
|
1077
|
+
trueCount: 0,
|
|
1078
|
+
falseCount: 0,
|
|
1079
|
+
optionCounts: new Map("options" in field ? field.options.map((option) => [option.id, 0]) : [])
|
|
1080
|
+
}
|
|
1081
|
+
])
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
add(submission) {
|
|
1085
|
+
const mismatch = responseMismatch(this.#schema, submission);
|
|
1086
|
+
if (mismatch !== void 0 && this.#mode === "strict") return { success: false, error: mismatch };
|
|
1087
|
+
const values = responseValues(submission);
|
|
1088
|
+
const visibility = calculateFieldVisibility(this.#schema, values);
|
|
1089
|
+
for (const field of this.#schema.fields) {
|
|
1090
|
+
const accumulator = this.#fields.get(field.id);
|
|
1091
|
+
if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
|
|
1092
|
+
const candidate = values[field.id];
|
|
1093
|
+
if (visibility[field.id] !== true || !valueIsValid(field, candidate)) continue;
|
|
1094
|
+
accumulator.answeredCount += 1;
|
|
1095
|
+
if ((field.type === "number" || field.type === "rating") && typeof candidate === "number") {
|
|
1096
|
+
accumulator.total += candidate;
|
|
1097
|
+
accumulator.minimum = accumulator.minimum === null ? candidate : Math.min(accumulator.minimum, candidate);
|
|
1098
|
+
accumulator.maximum = accumulator.maximum === null ? candidate : Math.max(accumulator.maximum, candidate);
|
|
1099
|
+
} else if (field.type === "checkbox") {
|
|
1100
|
+
if (candidate === true) accumulator.trueCount += 1;
|
|
1101
|
+
if (candidate === false) accumulator.falseCount += 1;
|
|
1102
|
+
} else if ("options" in field) {
|
|
1103
|
+
const selections = Array.isArray(candidate) ? candidate : typeof candidate === "string" ? [candidate] : [];
|
|
1104
|
+
for (const selection of selections) {
|
|
1105
|
+
accumulator.optionCounts.set(selection, (accumulator.optionCounts.get(selection) ?? 0) + 1);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
this.#submissionCount += 1;
|
|
1110
|
+
return { success: true };
|
|
1111
|
+
}
|
|
1112
|
+
addMany(submissions) {
|
|
1113
|
+
for (const submission of submissions) {
|
|
1114
|
+
const result = this.add(submission);
|
|
1115
|
+
if (!result.success) throw new TypeError(result.error ?? "Submission could not be accumulated.");
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
merge(other) {
|
|
1119
|
+
if (!(other instanceof _IncrementalResponseAccumulator)) {
|
|
1120
|
+
throw new TypeError("Only form-engine response accumulators can be merged.");
|
|
1121
|
+
}
|
|
1122
|
+
if (other.#schema.id !== this.#schema.id || other.#schema.version !== this.#schema.version || JSON.stringify(other.#schema.fields) !== JSON.stringify(this.#schema.fields)) {
|
|
1123
|
+
throw new TypeError("Response accumulators must use the same schema.");
|
|
1124
|
+
}
|
|
1125
|
+
this.#submissionCount += other.#submissionCount;
|
|
1126
|
+
for (const [fieldId, source] of other.#fields) {
|
|
1127
|
+
const target = this.#fields.get(fieldId);
|
|
1128
|
+
if (target === void 0) throw new Error(`Accumulator for ${fieldId} is unavailable.`);
|
|
1129
|
+
target.answeredCount += source.answeredCount;
|
|
1130
|
+
target.total += source.total;
|
|
1131
|
+
target.minimum = target.minimum === null ? source.minimum : source.minimum === null ? target.minimum : Math.min(target.minimum, source.minimum);
|
|
1132
|
+
target.maximum = target.maximum === null ? source.maximum : source.maximum === null ? target.maximum : Math.max(target.maximum, source.maximum);
|
|
1133
|
+
target.trueCount += source.trueCount;
|
|
1134
|
+
target.falseCount += source.falseCount;
|
|
1135
|
+
for (const [optionId, count] of source.optionCounts) {
|
|
1136
|
+
target.optionCounts.set(optionId, (target.optionCounts.get(optionId) ?? 0) + count);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return this;
|
|
1140
|
+
}
|
|
1141
|
+
finalize() {
|
|
1142
|
+
return {
|
|
1143
|
+
formId: this.#schema.id,
|
|
1144
|
+
formVersion: this.#schema.version,
|
|
1145
|
+
submissionCount: this.#submissionCount,
|
|
1146
|
+
questions: this.#schema.fields.map((field) => {
|
|
1147
|
+
const accumulator = this.#fields.get(field.id);
|
|
1148
|
+
if (accumulator === void 0) throw new Error(`Accumulator for ${field.id} is unavailable.`);
|
|
1149
|
+
const base = {
|
|
1150
|
+
fieldId: field.id,
|
|
1151
|
+
answeredCount: accumulator.answeredCount,
|
|
1152
|
+
unansweredCount: this.#submissionCount - accumulator.answeredCount
|
|
1153
|
+
};
|
|
1154
|
+
if (field.type === "text" || field.type === "textarea") return { ...base, kind: field.type };
|
|
1155
|
+
if (field.type === "number" || field.type === "rating") {
|
|
1156
|
+
return {
|
|
1157
|
+
...base,
|
|
1158
|
+
kind: field.type,
|
|
1159
|
+
minimum: accumulator.minimum,
|
|
1160
|
+
maximum: accumulator.maximum,
|
|
1161
|
+
average: accumulator.answeredCount === 0 ? null : accumulator.total / accumulator.answeredCount,
|
|
1162
|
+
total: accumulator.total
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
if (field.type === "checkbox") {
|
|
1166
|
+
return {
|
|
1167
|
+
...base,
|
|
1168
|
+
kind: "checkbox",
|
|
1169
|
+
trueCount: accumulator.trueCount,
|
|
1170
|
+
falseCount: accumulator.falseCount,
|
|
1171
|
+
truePercentageOfSubmissions: percentage(accumulator.trueCount, this.#submissionCount),
|
|
1172
|
+
falsePercentageOfSubmissions: percentage(accumulator.falseCount, this.#submissionCount)
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
if (!("options" in field)) throw new TypeError(`Field ${field.id} cannot be aggregated.`);
|
|
1176
|
+
return {
|
|
1177
|
+
...base,
|
|
1178
|
+
kind: field.type,
|
|
1179
|
+
options: field.options.map((option) => {
|
|
1180
|
+
const count = accumulator.optionCounts.get(option.id) ?? 0;
|
|
1181
|
+
return { id: option.id, count, percentageOfSubmissions: percentage(count, this.#submissionCount) };
|
|
1182
|
+
})
|
|
1183
|
+
};
|
|
1184
|
+
})
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
function createResponseAccumulator(schema, options = {}) {
|
|
1189
|
+
return new IncrementalResponseAccumulator(schema, options);
|
|
1190
|
+
}
|
|
941
1191
|
function escapeCsvCell(value, neutralizeFormulas = true) {
|
|
942
1192
|
if (value === null || value === void 0) return "";
|
|
943
1193
|
let stringValue = String(value);
|
|
@@ -953,6 +1203,51 @@ function serializeValue(value) {
|
|
|
953
1203
|
if (value === void 0) return "";
|
|
954
1204
|
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
|
|
955
1205
|
}
|
|
1206
|
+
function asFormResponse(submission) {
|
|
1207
|
+
if (!("values" in submission)) return submission;
|
|
1208
|
+
return {
|
|
1209
|
+
responseId: submission.id,
|
|
1210
|
+
formId: submission.formId,
|
|
1211
|
+
sourceLocale: submission.locale,
|
|
1212
|
+
answers: submission.values,
|
|
1213
|
+
submittedAt: submission.submittedAt,
|
|
1214
|
+
...submission.metadata === void 0 ? {} : { metadata: submission.metadata },
|
|
1215
|
+
...submission.translationMetadata === void 0 ? {} : { translationMetadata: submission.translationMetadata }
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
function serializeUnknown(value) {
|
|
1219
|
+
if (value === null || value === void 0) return "";
|
|
1220
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
1221
|
+
return JSON.stringify(value);
|
|
1222
|
+
}
|
|
1223
|
+
async function* exportResponsesToCsvStream(schema, submissions, options = {}) {
|
|
1224
|
+
assertValidFormSchema(schema);
|
|
1225
|
+
const includeDefaultColumns = options.includeDefaultColumns ?? true;
|
|
1226
|
+
const customColumns = options.columns ?? [];
|
|
1227
|
+
const headers = [
|
|
1228
|
+
...includeDefaultColumns ? ["submissionId", "submittedAt", "locale", ...schema.fields.map((field) => field.id)] : [],
|
|
1229
|
+
...customColumns.map((column) => column.header)
|
|
1230
|
+
];
|
|
1231
|
+
const neutralizeFormulas = options.neutralizeFormulas ?? true;
|
|
1232
|
+
const header = headers.map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",");
|
|
1233
|
+
yield `${options.withBom ?? true ? "\uFEFF" : ""}${header}`;
|
|
1234
|
+
for await (const submission of submissions) {
|
|
1235
|
+
const mismatch = responseMismatch(schema, submission);
|
|
1236
|
+
if (mismatch !== void 0) throw new TypeError(mismatch);
|
|
1237
|
+
const response = asFormResponse(submission);
|
|
1238
|
+
const answers = response.answers;
|
|
1239
|
+
const visible = selectVisibleAnswers(schema, answers);
|
|
1240
|
+
const defaultCells = includeDefaultColumns ? [
|
|
1241
|
+
response.responseId,
|
|
1242
|
+
response.submittedAt,
|
|
1243
|
+
response.sourceLocale ?? "",
|
|
1244
|
+
...schema.fields.map((field) => serializeUnknown(visible[field.id]))
|
|
1245
|
+
] : [];
|
|
1246
|
+
const customCells = customColumns.map((column) => column.getValue(response));
|
|
1247
|
+
yield `\r
|
|
1248
|
+
${[...defaultCells, ...customCells].map((value) => escapeCsvCell(value, neutralizeFormulas)).join(",")}`;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
956
1251
|
function exportResponsesToCsv(schema, responses, options = {}) {
|
|
957
1252
|
assertValidFormSchema(schema);
|
|
958
1253
|
for (const response of responses) {
|
|
@@ -1070,6 +1365,61 @@ function transformFieldType(field, nextType) {
|
|
|
1070
1365
|
return { ...common, type: nextType, options };
|
|
1071
1366
|
}
|
|
1072
1367
|
|
|
1368
|
+
// src/pagination.ts
|
|
1369
|
+
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
1370
|
+
function encodeBase64(bytes) {
|
|
1371
|
+
let result = "";
|
|
1372
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
1373
|
+
const first = bytes[index] ?? 0;
|
|
1374
|
+
const second = bytes[index + 1] ?? 0;
|
|
1375
|
+
const third = bytes[index + 2] ?? 0;
|
|
1376
|
+
const combined = first << 16 | second << 8 | third;
|
|
1377
|
+
result += BASE64_ALPHABET[combined >> 18 & 63] ?? "";
|
|
1378
|
+
result += BASE64_ALPHABET[combined >> 12 & 63] ?? "";
|
|
1379
|
+
result += index + 1 < bytes.length ? BASE64_ALPHABET[combined >> 6 & 63] ?? "" : "=";
|
|
1380
|
+
result += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] ?? "" : "=";
|
|
1381
|
+
}
|
|
1382
|
+
return result;
|
|
1383
|
+
}
|
|
1384
|
+
function decodeBase64(value) {
|
|
1385
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
1386
|
+
throw new TypeError("cursor must be a valid Base64 token.");
|
|
1387
|
+
}
|
|
1388
|
+
const bytes = [];
|
|
1389
|
+
for (let index = 0; index < value.length; index += 4) {
|
|
1390
|
+
const characters = value.slice(index, index + 4);
|
|
1391
|
+
const sextets = [...characters].map((character) => character === "=" ? 0 : BASE64_ALPHABET.indexOf(character));
|
|
1392
|
+
const combined = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
|
|
1393
|
+
bytes.push(combined >> 16 & 255);
|
|
1394
|
+
if (characters[2] !== "=") bytes.push(combined >> 8 & 255);
|
|
1395
|
+
if (characters[3] !== "=") bytes.push(combined & 255);
|
|
1396
|
+
}
|
|
1397
|
+
return new Uint8Array(bytes);
|
|
1398
|
+
}
|
|
1399
|
+
function encodeSubmissionCursor(value) {
|
|
1400
|
+
if (value.submittedAt.length === 0 || value.responseId.length === 0) {
|
|
1401
|
+
throw new TypeError("Cursor values must not be empty.");
|
|
1402
|
+
}
|
|
1403
|
+
return encodeBase64(new TextEncoder().encode(JSON.stringify(value)));
|
|
1404
|
+
}
|
|
1405
|
+
function decodeSubmissionCursor(cursor) {
|
|
1406
|
+
try {
|
|
1407
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64(cursor)));
|
|
1408
|
+
if (typeof parsed !== "object" || parsed === null || !("submittedAt" in parsed) || typeof parsed.submittedAt !== "string" || parsed.submittedAt.length === 0 || !("responseId" in parsed) || typeof parsed.responseId !== "string" || parsed.responseId.length === 0) {
|
|
1409
|
+
throw new TypeError("cursor payload is invalid.");
|
|
1410
|
+
}
|
|
1411
|
+
return { submittedAt: parsed.submittedAt, responseId: parsed.responseId };
|
|
1412
|
+
} catch (cause) {
|
|
1413
|
+
if (cause instanceof TypeError && cause.message === "cursor payload is invalid.") throw cause;
|
|
1414
|
+
throw new TypeError("cursor must be a valid form-engine cursor.", { cause });
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
function normalizeSubmissionPageSize(pageSize, fallback = 100) {
|
|
1418
|
+
const value = pageSize ?? fallback;
|
|
1419
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("pageSize must be a positive safe integer.");
|
|
1420
|
+
return value;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1073
1423
|
// src/validation.ts
|
|
1074
1424
|
var DEFAULT_MESSAGES = {
|
|
1075
1425
|
required: "validation.required",
|
|
@@ -1445,6 +1795,18 @@ function resolveLocalizedSchema(schema, targetLocale) {
|
|
|
1445
1795
|
async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
|
|
1446
1796
|
assertValidFormSchema(schema);
|
|
1447
1797
|
const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
|
|
1798
|
+
const allowedLocales = options.policy?.allowedLocales;
|
|
1799
|
+
const collectedLocales = collectSchemaLocales(schema);
|
|
1800
|
+
const disallowedLocale = [...collectedLocales.allUniqueLocales, ...locales].find(
|
|
1801
|
+
(locale) => allowedLocales !== void 0 && !allowedLocales.includes(locale)
|
|
1802
|
+
);
|
|
1803
|
+
if (disallowedLocale !== void 0) {
|
|
1804
|
+
throw new RangeError(`Translation locale ${disallowedLocale} is not allowed by the form policy.`);
|
|
1805
|
+
}
|
|
1806
|
+
const projectedLocales = /* @__PURE__ */ new Set([...collectedLocales.allUniqueLocales, ...locales]);
|
|
1807
|
+
if (options.policy?.maxLocales !== void 0 && projectedLocales.size > options.policy.maxLocales) {
|
|
1808
|
+
throw new RangeError(`At most ${options.policy.maxLocales} locales are allowed by the form policy.`);
|
|
1809
|
+
}
|
|
1448
1810
|
const updatedSlots = [];
|
|
1449
1811
|
const skippedSlots = [];
|
|
1450
1812
|
let result = schema;
|
|
@@ -1493,21 +1855,124 @@ async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocal
|
|
|
1493
1855
|
);
|
|
1494
1856
|
return resolveLocalizedSchema(populated.schema, targetLocale);
|
|
1495
1857
|
}
|
|
1858
|
+
|
|
1859
|
+
// src/versioning.ts
|
|
1860
|
+
function validateState(state) {
|
|
1861
|
+
if (state.formId.trim().length === 0) throw new TypeError("formId must not be empty.");
|
|
1862
|
+
if (!Number.isSafeInteger(state.nextVersion) || state.nextVersion < 1) {
|
|
1863
|
+
throw new TypeError("nextVersion must be a positive safe integer.");
|
|
1864
|
+
}
|
|
1865
|
+
if (!Number.isSafeInteger(state.revision) || state.revision < 0) {
|
|
1866
|
+
throw new TypeError("revision must be a non-negative safe integer.");
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
function cloneVersionToDraft(state, sourceSchema, options = {}) {
|
|
1870
|
+
validateState(state);
|
|
1871
|
+
if (sourceSchema.id !== state.formId) throw new TypeError("sourceSchema.id must match state.formId.");
|
|
1872
|
+
if (state.draftVersion !== void 0) {
|
|
1873
|
+
return { success: false, error: { type: "draft_already_exists", currentDraftVersion: state.draftVersion } };
|
|
1874
|
+
}
|
|
1875
|
+
const maxVersions = options.maxVersions ?? Number.MAX_SAFE_INTEGER;
|
|
1876
|
+
if (!Number.isSafeInteger(maxVersions) || maxVersions < 1) {
|
|
1877
|
+
throw new TypeError("maxVersions must be a positive safe integer.");
|
|
1878
|
+
}
|
|
1879
|
+
if (state.nextVersion > maxVersions) {
|
|
1880
|
+
return { success: false, error: { type: "max_version_exceeded", max: maxVersions } };
|
|
1881
|
+
}
|
|
1882
|
+
const version = state.nextVersion;
|
|
1883
|
+
return {
|
|
1884
|
+
success: true,
|
|
1885
|
+
value: {
|
|
1886
|
+
nextState: {
|
|
1887
|
+
...state,
|
|
1888
|
+
draftVersion: version,
|
|
1889
|
+
nextVersion: version + 1,
|
|
1890
|
+
revision: state.revision + 1
|
|
1891
|
+
},
|
|
1892
|
+
draftSchema: { ...sourceSchema, version }
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
function publishDraft(state, draftSchema, options = {}) {
|
|
1897
|
+
validateState(state);
|
|
1898
|
+
if (options.expectedRevision !== void 0 && options.expectedRevision !== state.revision) {
|
|
1899
|
+
return {
|
|
1900
|
+
success: false,
|
|
1901
|
+
error: {
|
|
1902
|
+
type: "revision_conflict",
|
|
1903
|
+
expectedRevision: options.expectedRevision,
|
|
1904
|
+
actualRevision: state.revision
|
|
1905
|
+
}
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
if (state.draftVersion === void 0 || draftSchema.id !== state.formId || draftSchema.version !== state.draftVersion) {
|
|
1909
|
+
return { success: false, error: { type: "draft_not_found" } };
|
|
1910
|
+
}
|
|
1911
|
+
if (options.validate?.(draftSchema) === false) throw new TypeError("Draft schema validation failed.");
|
|
1912
|
+
const timestamp = options.timestamp ?? "1970-01-01T00:00:00.000Z";
|
|
1913
|
+
if (!Number.isFinite(Date.parse(timestamp))) throw new TypeError("timestamp must be a valid date string.");
|
|
1914
|
+
const archivedVersion = state.publishedVersion;
|
|
1915
|
+
const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
|
|
1916
|
+
return {
|
|
1917
|
+
success: true,
|
|
1918
|
+
value: {
|
|
1919
|
+
nextState: {
|
|
1920
|
+
...stateWithoutDraft,
|
|
1921
|
+
publishedVersion: draftSchema.version,
|
|
1922
|
+
revision: state.revision + 1
|
|
1923
|
+
},
|
|
1924
|
+
publishedRecord: {
|
|
1925
|
+
formId: state.formId,
|
|
1926
|
+
version: draftSchema.version,
|
|
1927
|
+
status: "published",
|
|
1928
|
+
schema: draftSchema,
|
|
1929
|
+
createdAt: timestamp,
|
|
1930
|
+
publishedAt: timestamp
|
|
1931
|
+
},
|
|
1932
|
+
...archivedVersion === void 0 ? {} : { archivedVersion }
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
function deleteDraft(state) {
|
|
1937
|
+
validateState(state);
|
|
1938
|
+
if (state.draftVersion === void 0) return { success: false, error: { type: "draft_not_found" } };
|
|
1939
|
+
const { draftVersion: _draftVersion, ...stateWithoutDraft } = state;
|
|
1940
|
+
return {
|
|
1941
|
+
success: true,
|
|
1942
|
+
value: { nextState: { ...stateWithoutDraft, revision: state.revision + 1 } }
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
function assertVersionMutable(status) {
|
|
1946
|
+
if (status !== "draft") {
|
|
1947
|
+
const error = { type: "version_immutable", status };
|
|
1948
|
+
throw new TypeError(`A ${status} form version is immutable.`, { cause: error });
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1496
1951
|
export {
|
|
1497
1952
|
aggregateResponses,
|
|
1498
1953
|
assertValidFormSchema,
|
|
1954
|
+
assertVersionMutable,
|
|
1499
1955
|
calculateChoiceDistribution,
|
|
1500
1956
|
calculateCrossTabulation,
|
|
1501
1957
|
calculateFieldVisibility,
|
|
1502
1958
|
calculateNumericSummary,
|
|
1503
1959
|
calculatePageVisibility,
|
|
1960
|
+
cloneVersionToDraft,
|
|
1961
|
+
collectSchemaLocales,
|
|
1962
|
+
createResponseAccumulator,
|
|
1504
1963
|
createSubmission,
|
|
1964
|
+
decodeSubmissionCursor,
|
|
1965
|
+
deleteDraft,
|
|
1505
1966
|
dispatchWebhook,
|
|
1967
|
+
encodeSubmissionCursor,
|
|
1506
1968
|
escapeCsvCell,
|
|
1507
1969
|
exportResponsesToCsv,
|
|
1970
|
+
exportResponsesToCsvStream,
|
|
1508
1971
|
isDisplayConditionSatisfied,
|
|
1509
1972
|
isQuestionVisible,
|
|
1973
|
+
normalizeSubmissionPageSize,
|
|
1510
1974
|
populateSchemaTranslations,
|
|
1975
|
+
publishDraft,
|
|
1511
1976
|
resolveFormTranslation,
|
|
1512
1977
|
resolveLocalizedSchema,
|
|
1513
1978
|
sanitizeSchema,
|