@marktiderman/genesis-forms 0.4.2 → 0.6.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 +195 -0
- package/dist/destinations.cjs +147 -7
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.d.cts +1 -1
- package/dist/destinations.d.ts +1 -1
- package/dist/destinations.js +147 -7
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-BkNuzlna.d.cts → engine-CpfOz8FN.d.ts} +33 -3
- package/dist/{engine-Dz9J5ej2.d.ts → engine-DC4YTPdr.d.cts} +33 -3
- package/dist/index.cjs +734 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +142 -75
- package/dist/index.d.ts +142 -75
- package/dist/index.js +687 -70
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +721 -26
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +201 -4
- package/dist/server.d.ts +201 -4
- package/dist/server.js +702 -27
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +494 -26
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -4
- package/dist/testing.d.ts +4 -4
- package/dist/testing.js +494 -26
- package/dist/testing.js.map +1 -1
- package/dist/{types-BBKBQ8sp.d.cts → types-oAckGR_Z.d.cts} +31 -1
- package/dist/{types-BBKBQ8sp.d.ts → types-oAckGR_Z.d.ts} +31 -1
- package/dist/validate-B5WKHbst.d.cts +322 -0
- package/dist/validate-GpTXPwyl.d.ts +322 -0
- package/package.json +1 -1
- package/dist/validate-BXXqi0fq.d.cts +0 -30
- package/dist/validate-BjNwF1UE.d.ts +0 -30
package/dist/server.js
CHANGED
|
@@ -719,6 +719,80 @@ var structuredFieldTypes = [
|
|
|
719
719
|
}
|
|
720
720
|
];
|
|
721
721
|
|
|
722
|
+
// src/photo.ts
|
|
723
|
+
var PHOTO_TYPE = "photo";
|
|
724
|
+
var PHOTO_FORMATS = {
|
|
725
|
+
jpeg: ["image/jpeg"],
|
|
726
|
+
png: ["image/png"],
|
|
727
|
+
heic: ["image/heic", "image/heif"],
|
|
728
|
+
webp: ["image/webp"]
|
|
729
|
+
};
|
|
730
|
+
var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
|
|
731
|
+
var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
|
|
732
|
+
var DEFAULT_PHOTO_FILES = 3;
|
|
733
|
+
var MAX_PHOTO_FILES = 5;
|
|
734
|
+
var DEFAULT_PHOTO_SIZE_MB = 10;
|
|
735
|
+
var MAX_PHOTO_SIZE_MB = 25;
|
|
736
|
+
var MAX_PHOTO_ID_LENGTH = 200;
|
|
737
|
+
var MAX_PHOTO_NAME_LENGTH = 255;
|
|
738
|
+
var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
|
|
739
|
+
var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
|
|
740
|
+
function photoLimits(field) {
|
|
741
|
+
const maxFiles = wholeIn(field.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field.props.maxFiles : DEFAULT_PHOTO_FILES;
|
|
742
|
+
const maxSizeMb = wholeIn(field.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
|
|
743
|
+
const raw = field.props?.formats;
|
|
744
|
+
const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f) => raw.includes(f)) : [];
|
|
745
|
+
const formats = picked.length ? picked : [...ALL_FORMATS];
|
|
746
|
+
return {
|
|
747
|
+
maxFiles,
|
|
748
|
+
maxSizeMb,
|
|
749
|
+
maxBytes: maxSizeMb * 1024 * 1024,
|
|
750
|
+
formats,
|
|
751
|
+
mimeTypes: formats.flatMap((f) => PHOTO_FORMATS[f])
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
function isPhotoRef(v) {
|
|
755
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
756
|
+
const r = v;
|
|
757
|
+
return typeof r.id === "string" && r.id.length > 0 && r.id.length <= MAX_PHOTO_ID_LENGTH && typeof r.name === "string" && r.name.length <= MAX_PHOTO_NAME_LENGTH && typeof r.type === "string" && typeof r.size === "number" && Number.isInteger(r.size) && r.size > 0;
|
|
758
|
+
}
|
|
759
|
+
function photoRefs(value) {
|
|
760
|
+
if (!Array.isArray(value)) return [];
|
|
761
|
+
const refs = [];
|
|
762
|
+
for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
|
|
763
|
+
return refs;
|
|
764
|
+
}
|
|
765
|
+
var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
766
|
+
function photoFormatList(formats) {
|
|
767
|
+
const names = formats.map((f) => FORMAT_NAMES[f]);
|
|
768
|
+
return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
|
|
769
|
+
}
|
|
770
|
+
var photoTypeMessage = (field) => `Use a ${photoFormatList(photoLimits(field).formats)} photo.`;
|
|
771
|
+
var photoSizeMessage = (field) => `Each photo must be ${photoLimits(field).maxSizeMb} MB or smaller.`;
|
|
772
|
+
var photoCountMessage = (field) => `Add at most ${plural2(photoLimits(field).maxFiles, "photo")}.`;
|
|
773
|
+
var photoFieldType = {
|
|
774
|
+
type: PHOTO_TYPE,
|
|
775
|
+
label: "Photo",
|
|
776
|
+
category: "media",
|
|
777
|
+
value: { json: true },
|
|
778
|
+
operators: ["answered", "empty"],
|
|
779
|
+
a11y: { semantics: "group" },
|
|
780
|
+
isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
|
|
781
|
+
validate: (value, field) => {
|
|
782
|
+
if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
|
|
783
|
+
const limits = photoLimits(field);
|
|
784
|
+
if (value.length > limits.maxFiles) return photoCountMessage(field);
|
|
785
|
+
const refs = value;
|
|
786
|
+
if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
|
|
787
|
+
if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field);
|
|
788
|
+
if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field);
|
|
789
|
+
return null;
|
|
790
|
+
},
|
|
791
|
+
normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
|
|
792
|
+
summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
|
|
793
|
+
samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
|
|
794
|
+
};
|
|
795
|
+
|
|
722
796
|
// src/fields.ts
|
|
723
797
|
var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
|
|
724
798
|
var globalScope = globalThis;
|
|
@@ -755,12 +829,127 @@ function summarizeValue(value, field) {
|
|
|
755
829
|
if (Array.isArray(value)) return value.join(", ");
|
|
756
830
|
return String(value);
|
|
757
831
|
}
|
|
832
|
+
var OPTION_LIMITS = {
|
|
833
|
+
/** Characters in a description. */
|
|
834
|
+
description: 500,
|
|
835
|
+
/** Characters in an image URL. */
|
|
836
|
+
image: 2048,
|
|
837
|
+
/** Attributes on one option. */
|
|
838
|
+
attributes: 20,
|
|
839
|
+
/** Characters in an attribute's name. */
|
|
840
|
+
attributeName: 64,
|
|
841
|
+
/** Characters in one attribute value. */
|
|
842
|
+
attributeValue: 200,
|
|
843
|
+
/** Values in one attribute's list. */
|
|
844
|
+
attributeItems: 50
|
|
845
|
+
};
|
|
846
|
+
function isHttpsUrl(url) {
|
|
847
|
+
return typeof url === "string" && url.length <= OPTION_LIMITS.image && /^https:\/\/[^\s/?#@\\]+(?:[/?#][^\s]*)?$/i.test(url) && // No control characters anywhere (a line break would end a CSV row or an HTML attribute).
|
|
848
|
+
![...url].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
|
|
849
|
+
}
|
|
850
|
+
function attributeName(name) {
|
|
851
|
+
return name.trim() !== "" && name.length <= OPTION_LIMITS.attributeName && !(name in Object.prototype);
|
|
852
|
+
}
|
|
853
|
+
function readAttributes(raw) {
|
|
854
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
855
|
+
const out = {};
|
|
856
|
+
let count = 0;
|
|
857
|
+
for (const name of Object.keys(raw)) {
|
|
858
|
+
if (count >= OPTION_LIMITS.attributes) break;
|
|
859
|
+
if (!attributeName(name)) continue;
|
|
860
|
+
const v = raw[name];
|
|
861
|
+
const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
|
|
862
|
+
if (fits(v)) out[name] = v;
|
|
863
|
+
else if (Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits)) out[name] = [...v];
|
|
864
|
+
else continue;
|
|
865
|
+
count++;
|
|
866
|
+
}
|
|
867
|
+
return count ? out : void 0;
|
|
868
|
+
}
|
|
869
|
+
function readOption(raw) {
|
|
870
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
871
|
+
const o = raw;
|
|
872
|
+
if (typeof o.value !== "string") return null;
|
|
873
|
+
const out = { value: o.value };
|
|
874
|
+
if (typeof o.label === "string") out.label = o.label;
|
|
875
|
+
if (typeof o.description === "string" && o.description.trim() && o.description.length <= OPTION_LIMITS.description) {
|
|
876
|
+
out.description = o.description;
|
|
877
|
+
}
|
|
878
|
+
if (isHttpsUrl(o.image)) out.image = o.image;
|
|
879
|
+
const attributes = readAttributes(o.attributes);
|
|
880
|
+
if (attributes) out.attributes = attributes;
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
function optionJson(option) {
|
|
884
|
+
const out = { value: option.value };
|
|
885
|
+
if (option.label !== void 0) out.label = option.label;
|
|
886
|
+
if (option.description !== void 0) out.description = option.description;
|
|
887
|
+
if (option.image !== void 0) out.image = option.image;
|
|
888
|
+
if (option.attributes !== void 0) out.attributes = { ...option.attributes };
|
|
889
|
+
return out;
|
|
890
|
+
}
|
|
891
|
+
var OTHER_MAX_LENGTH = 200;
|
|
892
|
+
function otherOption(field) {
|
|
893
|
+
const other = field.props?.other;
|
|
894
|
+
if (other === true) return { label: "Other" };
|
|
895
|
+
if (other && typeof other === "object" && !Array.isArray(other)) {
|
|
896
|
+
const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
|
|
897
|
+
return { label };
|
|
898
|
+
}
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
function isOtherValue(field, value) {
|
|
902
|
+
return otherOption(field) !== null && typeof value === "string" && !choiceOptions(field).some((o) => o.value === value);
|
|
903
|
+
}
|
|
904
|
+
var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
|
|
758
905
|
function choiceOptions(field) {
|
|
759
906
|
const raw = field.props?.options;
|
|
760
907
|
if (!Array.isArray(raw)) return [];
|
|
761
|
-
return raw.flatMap(
|
|
762
|
-
|
|
763
|
-
|
|
908
|
+
return raw.flatMap((o) => {
|
|
909
|
+
const option = readOption(o);
|
|
910
|
+
return option ? [option] : [];
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
function optionsSource(field) {
|
|
914
|
+
const raw = field.props?.optionsFrom;
|
|
915
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
916
|
+
if (raw.source !== "host" || typeof raw.key !== "string" || !raw.key) return void 0;
|
|
917
|
+
const out = { source: "host", key: raw.key };
|
|
918
|
+
const filter = raw.filter;
|
|
919
|
+
if (filter && typeof filter === "object" && !Array.isArray(filter) && typeof filter.attribute === "string" && filter.attribute && typeof filter.field === "string" && filter.field) {
|
|
920
|
+
out.filter = { attribute: filter.attribute, field: filter.field };
|
|
921
|
+
}
|
|
922
|
+
return out;
|
|
923
|
+
}
|
|
924
|
+
function hostList(hostOptions, key) {
|
|
925
|
+
if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
|
|
926
|
+
const list = hostOptions[key];
|
|
927
|
+
if (!Array.isArray(list)) return [];
|
|
928
|
+
const seen = /* @__PURE__ */ new Set();
|
|
929
|
+
const out = [];
|
|
930
|
+
for (const o of list) {
|
|
931
|
+
const option = readOption(o);
|
|
932
|
+
if (!option || !option.value || seen.has(option.value)) continue;
|
|
933
|
+
seen.add(option.value);
|
|
934
|
+
out.push(option);
|
|
935
|
+
}
|
|
936
|
+
return out;
|
|
937
|
+
}
|
|
938
|
+
function withHostOptions(definition, hostOptions) {
|
|
939
|
+
let changed = false;
|
|
940
|
+
const blocks = definition.blocks.map((block) => {
|
|
941
|
+
if (block.kind !== "field") return block;
|
|
942
|
+
const from = optionsSource(block);
|
|
943
|
+
if (!from) return block;
|
|
944
|
+
const own2 = choiceOptions(block);
|
|
945
|
+
const taken = new Set(own2.map((o) => o.value));
|
|
946
|
+
const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
|
|
947
|
+
if (!added.length) return block;
|
|
948
|
+
changed = true;
|
|
949
|
+
const options = [...own2, ...added].map(optionJson);
|
|
950
|
+
return { ...block, props: { ...block.props, options } };
|
|
951
|
+
});
|
|
952
|
+
return changed ? { ...definition, blocks } : definition;
|
|
764
953
|
}
|
|
765
954
|
function optionLabel(field, value) {
|
|
766
955
|
const option = choiceOptions(field).find((o) => o.value === value);
|
|
@@ -880,7 +1069,7 @@ var builtins = [
|
|
|
880
1069
|
value: { json: true },
|
|
881
1070
|
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
882
1071
|
a11y: { semantics: "group" },
|
|
883
|
-
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
1072
|
+
validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
|
|
884
1073
|
summarize: (value, field) => optionLabel(field, String(value))
|
|
885
1074
|
},
|
|
886
1075
|
{
|
|
@@ -892,7 +1081,8 @@ var builtins = [
|
|
|
892
1081
|
a11y: { semantics: "group" },
|
|
893
1082
|
validate: (value, field) => {
|
|
894
1083
|
const allowed = new Set(choiceOptions(field).map((o) => o.value));
|
|
895
|
-
|
|
1084
|
+
const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
|
|
1085
|
+
return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
|
|
896
1086
|
},
|
|
897
1087
|
summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
|
|
898
1088
|
},
|
|
@@ -913,7 +1103,7 @@ var builtins = [
|
|
|
913
1103
|
value: { json: true },
|
|
914
1104
|
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
915
1105
|
a11y: { semantics: "control" },
|
|
916
|
-
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
1106
|
+
validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
|
|
917
1107
|
summarize: (value, field) => optionLabel(field, String(value))
|
|
918
1108
|
},
|
|
919
1109
|
{
|
|
@@ -1020,7 +1210,7 @@ function dateLimits(field) {
|
|
|
1020
1210
|
if (typeof max === "string" && isIsoDate(max)) out.max = max;
|
|
1021
1211
|
return out;
|
|
1022
1212
|
}
|
|
1023
|
-
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
|
|
1213
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
|
|
1024
1214
|
|
|
1025
1215
|
// src/events.ts
|
|
1026
1216
|
function isPlainObject(value) {
|
|
@@ -1236,6 +1426,205 @@ function fieldMap(def) {
|
|
|
1236
1426
|
return map;
|
|
1237
1427
|
}
|
|
1238
1428
|
|
|
1429
|
+
// src/options.ts
|
|
1430
|
+
var CHOICE_FIELD_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
|
|
1431
|
+
var isChoice = (block) => !!block && block.kind === "field" && CHOICE_FIELD_TYPES.has(block.type);
|
|
1432
|
+
function put(record, key, value) {
|
|
1433
|
+
Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
|
|
1434
|
+
}
|
|
1435
|
+
function optionFilter(field) {
|
|
1436
|
+
return optionsSource(field)?.filter;
|
|
1437
|
+
}
|
|
1438
|
+
function answerValues(answer) {
|
|
1439
|
+
const one = (v) => typeof v === "string" ? [v] : typeof v === "number" || typeof v === "boolean" ? [String(v)] : [];
|
|
1440
|
+
if (answer === void 0 || answer === null) return [];
|
|
1441
|
+
return Array.isArray(answer) ? answer.flatMap(one) : one(answer);
|
|
1442
|
+
}
|
|
1443
|
+
function optionMatches(option, attribute, answer) {
|
|
1444
|
+
const have = option.attributes && Object.prototype.hasOwnProperty.call(option.attributes, attribute) ? option.attributes[attribute] : void 0;
|
|
1445
|
+
if (have === void 0) return false;
|
|
1446
|
+
const wanted = answerValues(answer);
|
|
1447
|
+
return (Array.isArray(have) ? have : [have]).some((v) => wanted.includes(v));
|
|
1448
|
+
}
|
|
1449
|
+
function filterSource(definition, key) {
|
|
1450
|
+
return definition.blocks.find((b) => b.kind === "field" && b.key === key);
|
|
1451
|
+
}
|
|
1452
|
+
function visibleOptions(definition, field, values) {
|
|
1453
|
+
const options = choiceOptions(field);
|
|
1454
|
+
const filter = optionFilter(field);
|
|
1455
|
+
if (!filter) return options;
|
|
1456
|
+
const source = filterSource(definition, filter.field);
|
|
1457
|
+
if (!source || source.id === field.id) return options;
|
|
1458
|
+
const answer = own(values, source.id);
|
|
1459
|
+
if (isEmptyValue(answer, source)) return options;
|
|
1460
|
+
return options.filter((o) => optionMatches(o, filter.attribute, answer));
|
|
1461
|
+
}
|
|
1462
|
+
function answersOnPath(state) {
|
|
1463
|
+
const out = /* @__PURE__ */ Object.create(null);
|
|
1464
|
+
for (const id of state.path) out[id] = own(state.values, id);
|
|
1465
|
+
return out;
|
|
1466
|
+
}
|
|
1467
|
+
var OUTSIDE_FILTER = "Choose one of the options.";
|
|
1468
|
+
function filteredChoiceError(definition, field, value, values) {
|
|
1469
|
+
if (!optionFilter(field) || isEmptyValue(value, field)) return null;
|
|
1470
|
+
const all = new Set(choiceOptions(field).map((o) => o.value));
|
|
1471
|
+
const shown2 = new Set(visibleOptions(definition, field, values).map((o) => o.value));
|
|
1472
|
+
const picked = answerValues(value);
|
|
1473
|
+
return picked.some((v) => all.has(v) && !shown2.has(v)) ? OUTSIDE_FILTER : null;
|
|
1474
|
+
}
|
|
1475
|
+
function hasOptionFilters(definition) {
|
|
1476
|
+
return definition.blocks.some((b) => isChoice(b) && optionFilter(b) !== void 0);
|
|
1477
|
+
}
|
|
1478
|
+
function clearFilteredAnswers(definition, values, changedId) {
|
|
1479
|
+
const changed = definition.blocks.find((b) => b.kind === "field" && b.id === changedId);
|
|
1480
|
+
if (!changed) return values;
|
|
1481
|
+
const keys = /* @__PURE__ */ new Set([changed.key]);
|
|
1482
|
+
let out = values;
|
|
1483
|
+
for (const block of definition.blocks) {
|
|
1484
|
+
if (!isChoice(block)) continue;
|
|
1485
|
+
const filter = optionFilter(block);
|
|
1486
|
+
if (!filter || !keys.has(filter.field)) continue;
|
|
1487
|
+
const value = own(out, block.id);
|
|
1488
|
+
if (value === void 0 || isEmptyValue(value, block)) continue;
|
|
1489
|
+
const all = new Set(choiceOptions(block).map((o) => o.value));
|
|
1490
|
+
const shown2 = new Set(visibleOptions(definition, block, out).map((o) => o.value));
|
|
1491
|
+
const keep = (v) => typeof v !== "string" || !all.has(v) || shown2.has(v);
|
|
1492
|
+
let next = value;
|
|
1493
|
+
if (Array.isArray(value)) {
|
|
1494
|
+
const kept = value.filter(keep);
|
|
1495
|
+
if (kept.length !== value.length) next = kept.length ? kept : void 0;
|
|
1496
|
+
} else if (!keep(value)) next = void 0;
|
|
1497
|
+
if (next === value) continue;
|
|
1498
|
+
if (out === values) out = { ...values };
|
|
1499
|
+
put(out, block.id, next);
|
|
1500
|
+
keys.add(block.key);
|
|
1501
|
+
}
|
|
1502
|
+
return out;
|
|
1503
|
+
}
|
|
1504
|
+
function fold(text) {
|
|
1505
|
+
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
|
1506
|
+
}
|
|
1507
|
+
var MAX_PREFILL_PARAM_LENGTH = 64;
|
|
1508
|
+
function isPrefillParam(param) {
|
|
1509
|
+
return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
|
|
1510
|
+
}
|
|
1511
|
+
function prefillSetting(field) {
|
|
1512
|
+
if (!CHOICE_FIELD_TYPES.has(field.type)) return void 0;
|
|
1513
|
+
const raw = field.props?.prefill;
|
|
1514
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
|
|
1515
|
+
return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
|
|
1516
|
+
}
|
|
1517
|
+
function paramValues(params, name) {
|
|
1518
|
+
let raw;
|
|
1519
|
+
if (typeof params.getAll === "function") {
|
|
1520
|
+
raw = params.getAll(name);
|
|
1521
|
+
} else if (Object.prototype.hasOwnProperty.call(params, name)) {
|
|
1522
|
+
raw = params[name];
|
|
1523
|
+
}
|
|
1524
|
+
const list = Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw];
|
|
1525
|
+
return list.filter((v) => typeof v === "string" && v !== "" && v.length <= MAX_HIDDEN_STRING_LENGTH);
|
|
1526
|
+
}
|
|
1527
|
+
function prefillFromLink(definition, params, values = {}) {
|
|
1528
|
+
const out = {};
|
|
1529
|
+
if (!params || typeof params !== "object") return out;
|
|
1530
|
+
const seen = {};
|
|
1531
|
+
for (const id of Object.keys(values)) put(seen, id, values[id]);
|
|
1532
|
+
for (const block of definition.blocks) {
|
|
1533
|
+
if (!isChoice(block)) continue;
|
|
1534
|
+
const setting = prefillSetting(block);
|
|
1535
|
+
if (!setting || own(seen, block.id) !== void 0) continue;
|
|
1536
|
+
const raw = paramValues(params, setting.param);
|
|
1537
|
+
if (!raw.length) continue;
|
|
1538
|
+
const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
|
|
1539
|
+
let value;
|
|
1540
|
+
if (block.type === "multi-choice") {
|
|
1541
|
+
const picked = [];
|
|
1542
|
+
for (const r of raw) {
|
|
1543
|
+
for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
|
|
1544
|
+
if (shown2.has(v) && !picked.includes(v)) picked.push(v);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
if (picked.length) value = picked;
|
|
1548
|
+
} else value = raw.find((v) => shown2.has(v));
|
|
1549
|
+
if (value === void 0) continue;
|
|
1550
|
+
put(out, block.id, value);
|
|
1551
|
+
put(seen, block.id, value);
|
|
1552
|
+
}
|
|
1553
|
+
return out;
|
|
1554
|
+
}
|
|
1555
|
+
function hiddenByPrefill(definition, state) {
|
|
1556
|
+
const hideable = /* @__PURE__ */ new Set();
|
|
1557
|
+
for (const b of definition.blocks) {
|
|
1558
|
+
if (isChoice(b) && prefillSetting(b)?.hideWhenPrefilled) hideable.add(b.id);
|
|
1559
|
+
}
|
|
1560
|
+
if (!hideable.size) return [];
|
|
1561
|
+
const prefilled = /* @__PURE__ */ new Set();
|
|
1562
|
+
for (const e of state.events) {
|
|
1563
|
+
if (e.type !== "answer" || !hideable.has(e.field)) continue;
|
|
1564
|
+
if (e.source === "prefill") prefilled.add(e.field);
|
|
1565
|
+
else prefilled.delete(e.field);
|
|
1566
|
+
}
|
|
1567
|
+
if (!prefilled.size) return [];
|
|
1568
|
+
const fields = new Map(definition.blocks.map((b) => [b.id, b]));
|
|
1569
|
+
return state.path.filter((id) => {
|
|
1570
|
+
if (!prefilled.has(id) || own(state.errors, id) !== void 0) return false;
|
|
1571
|
+
const field = fields.get(id);
|
|
1572
|
+
return !isEmptyValue(own(state.values, id), field);
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
function snapshotOf(byValue, value) {
|
|
1576
|
+
const option = byValue.get(value);
|
|
1577
|
+
if (option) return { value, label: option.label ?? value };
|
|
1578
|
+
return { value, label: value, other: true };
|
|
1579
|
+
}
|
|
1580
|
+
function answerSnapshots(definition, answers) {
|
|
1581
|
+
const out = {};
|
|
1582
|
+
for (const block of definition.blocks) {
|
|
1583
|
+
if (!isChoice(block) || !optionsSource(block)) continue;
|
|
1584
|
+
const value = own(answers, block.id);
|
|
1585
|
+
if (isEmptyValue(value, block)) continue;
|
|
1586
|
+
const byValue = new Map(choiceOptions(block).map((o) => [o.value, o]));
|
|
1587
|
+
let snapshot;
|
|
1588
|
+
if (Array.isArray(value)) {
|
|
1589
|
+
const items = value.filter((v) => typeof v === "string").map((v) => snapshotOf(byValue, v));
|
|
1590
|
+
if (items.length) snapshot = items;
|
|
1591
|
+
} else if (typeof value === "string") snapshot = snapshotOf(byValue, value);
|
|
1592
|
+
if (snapshot) put(out, block.id, snapshot);
|
|
1593
|
+
}
|
|
1594
|
+
return out;
|
|
1595
|
+
}
|
|
1596
|
+
function answersFromSnapshots(definition, snapshots) {
|
|
1597
|
+
const answers = {};
|
|
1598
|
+
const missing = {};
|
|
1599
|
+
for (const block of definition.blocks) {
|
|
1600
|
+
if (!isChoice(block)) continue;
|
|
1601
|
+
const raw = own(snapshots, block.id);
|
|
1602
|
+
const list = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).flatMap((s) => {
|
|
1603
|
+
const o = readOption(s);
|
|
1604
|
+
if (!o) return [];
|
|
1605
|
+
const label = o.label ?? o.value;
|
|
1606
|
+
return [s.other === true ? { value: o.value, label, other: true } : { value: o.value, label }];
|
|
1607
|
+
});
|
|
1608
|
+
if (!list.length) continue;
|
|
1609
|
+
const options = choiceOptions(block);
|
|
1610
|
+
const byValue = new Set(options.map((o) => o.value));
|
|
1611
|
+
const allowsOther = block.props?.other !== void 0 && block.props?.other !== false;
|
|
1612
|
+
const found = [];
|
|
1613
|
+
const lost = [];
|
|
1614
|
+
for (const s of list) {
|
|
1615
|
+
let value;
|
|
1616
|
+
if (byValue.has(s.value)) value = s.value;
|
|
1617
|
+
else if (s.other) value = allowsOther ? s.value : void 0;
|
|
1618
|
+
else value = options.find((o) => fold(o.label ?? o.value) === fold(s.label))?.value;
|
|
1619
|
+
if (value === void 0) lost.push(s);
|
|
1620
|
+
else if (!found.includes(value)) found.push(value);
|
|
1621
|
+
}
|
|
1622
|
+
if (found.length) put(answers, block.id, block.type === "multi-choice" ? found : found[0]);
|
|
1623
|
+
if (lost.length) put(missing, block.id, lost);
|
|
1624
|
+
}
|
|
1625
|
+
return { answers, missing };
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1239
1628
|
// src/recall.ts
|
|
1240
1629
|
var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
|
|
1241
1630
|
function recall(text, scope) {
|
|
@@ -1289,7 +1678,8 @@ function readsAny(node, ids) {
|
|
|
1289
1678
|
function createForm(definition, options = {}) {
|
|
1290
1679
|
return build(definition, options).engine;
|
|
1291
1680
|
}
|
|
1292
|
-
function build(
|
|
1681
|
+
function build(given, options, replaying = false, budget) {
|
|
1682
|
+
const definition = withHostOptions(given, options.hostOptions);
|
|
1293
1683
|
const fields = fieldMap(definition);
|
|
1294
1684
|
const byKey = new Map([...fields.values()].map((f) => [f.key, f]));
|
|
1295
1685
|
const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
|
|
@@ -1335,6 +1725,7 @@ function build(definition, options, replaying = false, budget) {
|
|
|
1335
1725
|
path: first.path,
|
|
1336
1726
|
current: first.path[0] ?? null,
|
|
1337
1727
|
history: [],
|
|
1728
|
+
furthest: first.path[0] ?? null,
|
|
1338
1729
|
errors: {},
|
|
1339
1730
|
status: "in_progress",
|
|
1340
1731
|
ending: null,
|
|
@@ -1355,16 +1746,34 @@ function build(definition, options, replaying = false, budget) {
|
|
|
1355
1746
|
return at === void 0 ? event : { ...event, at };
|
|
1356
1747
|
};
|
|
1357
1748
|
const isVisibleIn = (s, id) => s.path.includes(id);
|
|
1749
|
+
const filters = hasOptionFilters(definition);
|
|
1750
|
+
const clearFiltered = (values, id) => filters ? clearFilteredAnswers(definition, values, id) : values;
|
|
1358
1751
|
const errorsFor = (s, ids) => {
|
|
1359
1752
|
const errors = {};
|
|
1753
|
+
let shown2;
|
|
1360
1754
|
for (const id of ids) {
|
|
1361
1755
|
const field = fields.get(id);
|
|
1362
1756
|
if (!field || !isVisibleIn(s, id)) continue;
|
|
1363
|
-
const
|
|
1757
|
+
const value = own(s.values, id);
|
|
1758
|
+
let message = validateField(field, value, { custom: options.validators?.[id] });
|
|
1759
|
+
if (!message && filters) message = filteredChoiceError(definition, field, value, shown2 ??= answersOnPath(s));
|
|
1364
1760
|
if (message) errors[id] = message;
|
|
1365
1761
|
}
|
|
1366
1762
|
return errors;
|
|
1367
1763
|
};
|
|
1764
|
+
const skipped = (s) => new Set(hiddenByPrefill(definition, s));
|
|
1765
|
+
const shownFrom = (s, from, skip) => {
|
|
1766
|
+
for (let i = from; i < s.path.length; i++) if (!skip.has(s.path[i])) return s.path[i];
|
|
1767
|
+
return void 0;
|
|
1768
|
+
};
|
|
1769
|
+
const showCurrent = () => {
|
|
1770
|
+
if (!state.current) return;
|
|
1771
|
+
const skip = skipped(state);
|
|
1772
|
+
if (!skip.has(state.current)) return;
|
|
1773
|
+
const at = state.path.indexOf(state.current);
|
|
1774
|
+
const shown2 = at < 0 ? void 0 : shownFrom(state, at, skip);
|
|
1775
|
+
if (shown2) state = { ...state, current: shown2, furthest: state.furthest === state.current ? shown2 : state.furthest };
|
|
1776
|
+
};
|
|
1368
1777
|
let unsettled = false;
|
|
1369
1778
|
let formulasStale = false;
|
|
1370
1779
|
const settleNow = (needValues = true) => {
|
|
@@ -1393,13 +1802,13 @@ function build(definition, options, replaying = false, budget) {
|
|
|
1393
1802
|
const errors = { ...state.errors };
|
|
1394
1803
|
delete errors[id];
|
|
1395
1804
|
if (batch) {
|
|
1396
|
-
state.values = { ...state.values, [id]: value };
|
|
1805
|
+
state.values = clearFiltered({ ...state.values, [id]: value }, id);
|
|
1397
1806
|
state.errors = errors;
|
|
1398
1807
|
log(event);
|
|
1399
1808
|
unsettled = true;
|
|
1400
1809
|
return;
|
|
1401
1810
|
}
|
|
1402
|
-
const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
|
|
1811
|
+
const { values, path } = settle(clearFiltered({ ...state.values, [id]: value }, id), state.hidden);
|
|
1403
1812
|
emit({ ...state, values, path, errors, events: log(event) });
|
|
1404
1813
|
};
|
|
1405
1814
|
const engine = {
|
|
@@ -1430,19 +1839,21 @@ function build(definition, options, replaying = false, budget) {
|
|
|
1430
1839
|
return false;
|
|
1431
1840
|
}
|
|
1432
1841
|
const at = state.path.indexOf(state.current);
|
|
1433
|
-
const target = state
|
|
1842
|
+
const target = shownFrom(state, at + 1, skipped(state));
|
|
1434
1843
|
if (!target) {
|
|
1435
1844
|
emit({ ...state, events });
|
|
1436
1845
|
return true;
|
|
1437
1846
|
}
|
|
1438
|
-
|
|
1847
|
+
const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
|
|
1848
|
+
emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
|
|
1439
1849
|
return true;
|
|
1440
1850
|
},
|
|
1441
1851
|
back() {
|
|
1442
1852
|
if (state.status === "completed") return;
|
|
1443
1853
|
const history = [...state.history];
|
|
1854
|
+
const skip = skipped(state);
|
|
1444
1855
|
let previous = history.pop();
|
|
1445
|
-
while (previous && !state.path.includes(previous)) previous = history.pop();
|
|
1856
|
+
while (previous && (!state.path.includes(previous) || skip.has(previous))) previous = history.pop();
|
|
1446
1857
|
if (!previous) return;
|
|
1447
1858
|
emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
|
|
1448
1859
|
},
|
|
@@ -1470,15 +1881,27 @@ function build(definition, options, replaying = false, budget) {
|
|
|
1470
1881
|
return out;
|
|
1471
1882
|
}
|
|
1472
1883
|
};
|
|
1473
|
-
|
|
1884
|
+
const fromLink = options.linkParams ? prefillFromLink(definition, options.linkParams, options.initialValues) : {};
|
|
1885
|
+
const initialValues = Object.keys(fromLink).length ? { ...fromLink, ...options.initialValues } : options.initialValues;
|
|
1886
|
+
if (initialValues) {
|
|
1474
1887
|
const wasBatch = batch;
|
|
1475
1888
|
batch = true;
|
|
1476
|
-
for (const [id, value] of Object.entries(
|
|
1889
|
+
for (const [id, value] of Object.entries(initialValues)) {
|
|
1477
1890
|
if (!fields.has(id) || value === void 0) continue;
|
|
1478
1891
|
answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
|
|
1479
1892
|
}
|
|
1480
1893
|
settleNow();
|
|
1481
1894
|
batch = wasBatch;
|
|
1895
|
+
showCurrent();
|
|
1896
|
+
}
|
|
1897
|
+
const resumeAt = options.resumeAt;
|
|
1898
|
+
if (resumeAt && !replaying && state.path.includes(resumeAt)) {
|
|
1899
|
+
for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
|
|
1900
|
+
if (!engine.next()) {
|
|
1901
|
+
state = { ...state, errors: {} };
|
|
1902
|
+
break;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1482
1905
|
}
|
|
1483
1906
|
const apply = (event) => {
|
|
1484
1907
|
if (event.type === "answer") {
|
|
@@ -1488,18 +1911,22 @@ function build(definition, options, replaying = false, budget) {
|
|
|
1488
1911
|
}
|
|
1489
1912
|
} else if (event.type === "next") {
|
|
1490
1913
|
settleNow(false);
|
|
1914
|
+
showCurrent();
|
|
1491
1915
|
if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
|
|
1492
1916
|
engine.next();
|
|
1493
1917
|
} else if (event.type === "back") {
|
|
1494
1918
|
settleNow(false);
|
|
1919
|
+
showCurrent();
|
|
1495
1920
|
engine.back();
|
|
1496
1921
|
} else if (event.type === "submit") {
|
|
1497
1922
|
settleNow();
|
|
1923
|
+
showCurrent();
|
|
1498
1924
|
engine.submit();
|
|
1499
1925
|
}
|
|
1500
1926
|
};
|
|
1501
1927
|
const finish = () => {
|
|
1502
1928
|
settleNow();
|
|
1929
|
+
showCurrent();
|
|
1503
1930
|
batch = false;
|
|
1504
1931
|
state = { ...state, events: [...state.events] };
|
|
1505
1932
|
};
|
|
@@ -1507,7 +1934,12 @@ function build(definition, options, replaying = false, budget) {
|
|
|
1507
1934
|
}
|
|
1508
1935
|
function replayWithin(definition, events, options, maxWork) {
|
|
1509
1936
|
const budget = maxWork === void 0 ? void 0 : { used: 0, max: maxWork };
|
|
1510
|
-
const { engine, apply, finish } = build(
|
|
1937
|
+
const { engine, apply, finish } = build(
|
|
1938
|
+
definition,
|
|
1939
|
+
{ ...options, initialValues: void 0, linkParams: void 0, clock: () => void 0 },
|
|
1940
|
+
true,
|
|
1941
|
+
budget
|
|
1942
|
+
);
|
|
1511
1943
|
try {
|
|
1512
1944
|
for (const event of events) apply(event);
|
|
1513
1945
|
finish();
|
|
@@ -1579,6 +2011,31 @@ function parseForm(input) {
|
|
|
1579
2011
|
const optionalBoolean = (v, path) => {
|
|
1580
2012
|
if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
|
|
1581
2013
|
};
|
|
2014
|
+
const optionDetails = (o, at) => {
|
|
2015
|
+
if (o.description !== void 0 && (typeof o.description !== "string" || o.description.length > OPTION_LIMITS.description)) {
|
|
2016
|
+
issue(`${at}.description`, `Must be text of at most ${OPTION_LIMITS.description} characters.`);
|
|
2017
|
+
}
|
|
2018
|
+
if (o.image !== void 0 && !isHttpsUrl(o.image)) {
|
|
2019
|
+
issue(`${at}.image`, `Must be an https:// link of at most ${OPTION_LIMITS.image} characters.`);
|
|
2020
|
+
}
|
|
2021
|
+
if (o.attributes === void 0) return;
|
|
2022
|
+
const attributes = o.attributes;
|
|
2023
|
+
if (!isObject2(attributes) || Object.keys(attributes).length > OPTION_LIMITS.attributes) {
|
|
2024
|
+
issue(`${at}.attributes`, `Must be an object of at most ${OPTION_LIMITS.attributes} attributes.`);
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
|
|
2028
|
+
for (const [name2, v] of Object.entries(attributes)) {
|
|
2029
|
+
if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
|
|
2030
|
+
issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
|
|
2031
|
+
} else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
|
|
2032
|
+
issue(
|
|
2033
|
+
`${at}.attributes.${name2}`,
|
|
2034
|
+
`Must be text, or a list of at most ${OPTION_LIMITS.attributeItems} texts, each at most ${OPTION_LIMITS.attributeValue} characters.`
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
1582
2039
|
const name = (v, path, what) => {
|
|
1583
2040
|
if (typeof v !== "string" || !v) {
|
|
1584
2041
|
issue(path, `${what} must be a non-empty string.`);
|
|
@@ -1667,6 +2124,22 @@ function parseForm(input) {
|
|
|
1667
2124
|
}
|
|
1668
2125
|
}
|
|
1669
2126
|
if (type === "consent") optionalText(props.text, `${path}.text`);
|
|
2127
|
+
if (type === PHOTO_TYPE) {
|
|
2128
|
+
if (props.maxFiles !== void 0 && !(isInt(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
|
|
2129
|
+
issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
|
|
2130
|
+
}
|
|
2131
|
+
if (props.maxSizeMb !== void 0 && !(isInt(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
|
|
2132
|
+
issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
|
|
2133
|
+
}
|
|
2134
|
+
if (props.formats !== void 0) {
|
|
2135
|
+
const formats = props.formats;
|
|
2136
|
+
if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
|
|
2137
|
+
issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
|
|
2138
|
+
} else if (new Set(formats).size !== formats.length) {
|
|
2139
|
+
issue(`${path}.formats`, "Name each format once.");
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
1670
2143
|
};
|
|
1671
2144
|
if (!isObject2(input)) {
|
|
1672
2145
|
issue("", "A form definition must be an object.");
|
|
@@ -1687,6 +2160,7 @@ function parseForm(input) {
|
|
|
1687
2160
|
else {
|
|
1688
2161
|
optionalBoolean(input.settings.anonymous, "settings.anonymous");
|
|
1689
2162
|
optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
|
|
2163
|
+
optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
|
|
1690
2164
|
}
|
|
1691
2165
|
}
|
|
1692
2166
|
if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
|
|
@@ -1727,6 +2201,38 @@ function parseForm(input) {
|
|
|
1727
2201
|
issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
|
|
1728
2202
|
}
|
|
1729
2203
|
if (block.props !== void 0) {
|
|
2204
|
+
const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
|
|
2205
|
+
if (from !== void 0) {
|
|
2206
|
+
if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
|
|
2207
|
+
issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
|
|
2208
|
+
} else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
|
|
2209
|
+
warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
|
|
2210
|
+
}
|
|
2211
|
+
const filter = isObject2(from) ? from.filter : void 0;
|
|
2212
|
+
if (filter !== void 0) {
|
|
2213
|
+
const at = `${path}.props.optionsFrom.filter`;
|
|
2214
|
+
if (!isObject2(filter) || typeof filter.attribute !== "string" || !filter.attribute || filter.attribute.length > OPTION_LIMITS.attributeName || typeof filter.field !== "string" || !filter.field) {
|
|
2215
|
+
issue(at, `Must be { attribute, field }: an attribute name (at most ${OPTION_LIMITS.attributeName} characters) and an earlier question's key.`);
|
|
2216
|
+
} else if (!keys.has(filter.field)) {
|
|
2217
|
+
issue(`${at}.field`, `"${filter.field}" must be the key of an earlier question.`);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
const prefill = isObject2(block.props) ? block.props.prefill : void 0;
|
|
2222
|
+
if (prefill !== void 0) {
|
|
2223
|
+
if (!isObject2(prefill) || !isPrefillParam(prefill.param)) {
|
|
2224
|
+
issue(
|
|
2225
|
+
`${path}.props.prefill`,
|
|
2226
|
+
`Must be { param, hideWhenPrefilled? }: a link parameter of letters, digits, "_", "-" or "." (at most ${MAX_PREFILL_PARAM_LENGTH} characters).`
|
|
2227
|
+
);
|
|
2228
|
+
} else {
|
|
2229
|
+
optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
|
|
2230
|
+
if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
|
|
2231
|
+
warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
if (isObject2(block.props)) optionalBoolean(block.props.searchable, `${path}.props.searchable`);
|
|
1730
2236
|
if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
|
|
1731
2237
|
else if (block.props.options !== void 0) {
|
|
1732
2238
|
const options = block.props.options;
|
|
@@ -1735,12 +2241,25 @@ function parseForm(input) {
|
|
|
1735
2241
|
options.forEach((o, j) => {
|
|
1736
2242
|
const at = `${path}.props.options[${j}]`;
|
|
1737
2243
|
if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
|
|
1738
|
-
else
|
|
2244
|
+
else {
|
|
2245
|
+
optionalText(o.label, `${at}.label`);
|
|
2246
|
+
optionDetails(o, at);
|
|
2247
|
+
}
|
|
1739
2248
|
});
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
2249
|
+
if (from === void 0) {
|
|
2250
|
+
choices.set(
|
|
2251
|
+
block.id,
|
|
2252
|
+
new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
const other = isObject2(block.props) ? block.props.other : void 0;
|
|
2258
|
+
if (other !== void 0) {
|
|
2259
|
+
if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
|
|
2260
|
+
issue(`${path}.props.other`, "Must be true, false or { label }.");
|
|
2261
|
+
} else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
|
|
2262
|
+
warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
|
|
1744
2263
|
}
|
|
1745
2264
|
}
|
|
1746
2265
|
const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
|
|
@@ -1815,6 +2334,8 @@ function parseForm(input) {
|
|
|
1815
2334
|
} else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
|
|
1816
2335
|
else if (targetType === "media-notes" || targetType === "text-highlights") {
|
|
1817
2336
|
warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
|
|
2337
|
+
} else if (targetType === PHOTO_TYPE) {
|
|
2338
|
+
warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
|
|
1818
2339
|
}
|
|
1819
2340
|
}
|
|
1820
2341
|
});
|
|
@@ -2016,6 +2537,158 @@ function parseForm(input) {
|
|
|
2016
2537
|
return issues.length ? fail() : { ok: true, form: input, warnings };
|
|
2017
2538
|
}
|
|
2018
2539
|
|
|
2540
|
+
// src/edit-link.ts
|
|
2541
|
+
var EDIT_LINK_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
2542
|
+
var MIN_EDIT_SECRET_BYTES = 32;
|
|
2543
|
+
var EDIT_LINK_PARAM = "edit";
|
|
2544
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2545
|
+
var NONCE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
2546
|
+
var EXPIRES = /^[1-9][0-9]{0,11}$/;
|
|
2547
|
+
var SIG = /^[A-Za-z0-9_-]{43}$/;
|
|
2548
|
+
var DOMAIN = "edit-link:v2:";
|
|
2549
|
+
var MAX_TOKEN_LENGTH = 300;
|
|
2550
|
+
var encoder = new TextEncoder();
|
|
2551
|
+
function toBase64Url(bytes) {
|
|
2552
|
+
let binary = "";
|
|
2553
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
2554
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2555
|
+
}
|
|
2556
|
+
function fromBase64Url(text) {
|
|
2557
|
+
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
2558
|
+
const bytes = new Uint8Array(binary.length);
|
|
2559
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
2560
|
+
return bytes;
|
|
2561
|
+
}
|
|
2562
|
+
function isUsableEditSecret(secret) {
|
|
2563
|
+
return typeof secret === "string" && secret.trim().length > 0 && encoder.encode(secret).length >= MIN_EDIT_SECRET_BYTES;
|
|
2564
|
+
}
|
|
2565
|
+
function hmacKey(secret, usage) {
|
|
2566
|
+
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
|
|
2567
|
+
}
|
|
2568
|
+
var payload = (id, nonce, expiresAt) => encoder.encode(`${DOMAIN}${id}.${nonce}.${expiresAt}`);
|
|
2569
|
+
async function signEditToken(link, secret, options = {}) {
|
|
2570
|
+
if (!isUsableEditSecret(secret) || !UUID.test(link.id) || !NONCE.test(link.nonce)) return null;
|
|
2571
|
+
const ttl = options.ttlSeconds ?? EDIT_LINK_TTL_SECONDS;
|
|
2572
|
+
if (!Number.isFinite(ttl) || ttl <= 0) return null;
|
|
2573
|
+
const id = link.id.toLowerCase();
|
|
2574
|
+
const expiresAt = Math.floor((options.now ?? Date.now()) / 1e3) + Math.floor(ttl);
|
|
2575
|
+
const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret, "sign"), payload(id, link.nonce, expiresAt));
|
|
2576
|
+
return `${id}.${link.nonce}.${expiresAt}.${toBase64Url(new Uint8Array(sig))}`;
|
|
2577
|
+
}
|
|
2578
|
+
async function verifyEditToken(token, secret, options = {}) {
|
|
2579
|
+
if (!isUsableEditSecret(secret) || typeof token !== "string" || token.length > MAX_TOKEN_LENGTH) return null;
|
|
2580
|
+
const parts = token.split(".");
|
|
2581
|
+
if (parts.length !== 4) return null;
|
|
2582
|
+
const [id, nonce, expires, sig] = parts;
|
|
2583
|
+
if (!UUID.test(id) || id !== id.toLowerCase() || !NONCE.test(nonce) || !EXPIRES.test(expires)) return null;
|
|
2584
|
+
if (!SIG.test(sig)) return null;
|
|
2585
|
+
const expiresAt = Number(expires);
|
|
2586
|
+
const now = Math.floor((options.now ?? Date.now()) / 1e3);
|
|
2587
|
+
const ttl = options.ttlSeconds ?? EDIT_LINK_TTL_SECONDS;
|
|
2588
|
+
if (expiresAt <= now || expiresAt > now + ttl) return null;
|
|
2589
|
+
let bytes;
|
|
2590
|
+
try {
|
|
2591
|
+
bytes = fromBase64Url(sig);
|
|
2592
|
+
} catch {
|
|
2593
|
+
return null;
|
|
2594
|
+
}
|
|
2595
|
+
const ok = await crypto.subtle.verify("HMAC", await hmacKey(secret, "verify"), bytes, payload(id, nonce, expiresAt));
|
|
2596
|
+
return ok ? { id, nonce, expiresAt } : null;
|
|
2597
|
+
}
|
|
2598
|
+
function withEditToken(formUrl, token, param = EDIT_LINK_PARAM) {
|
|
2599
|
+
return `${formUrl}${formUrl.includes("?") ? "&" : "?"}${param}=${encodeURIComponent(token)}`;
|
|
2600
|
+
}
|
|
2601
|
+
async function editLinkUrl(formUrl, link, secret, options = {}) {
|
|
2602
|
+
const token = await signEditToken(link, secret, options);
|
|
2603
|
+
return token ? withEditToken(formUrl, token, options.param) : null;
|
|
2604
|
+
}
|
|
2605
|
+
function createEditSecretReader(options) {
|
|
2606
|
+
const { variable } = options;
|
|
2607
|
+
let warned = false;
|
|
2608
|
+
const read = (env, log) => {
|
|
2609
|
+
const secret = env[variable];
|
|
2610
|
+
if (isUsableEditSecret(secret)) return secret;
|
|
2611
|
+
if (!warned) {
|
|
2612
|
+
warned = true;
|
|
2613
|
+
(log ?? options.log ?? ((m) => console.warn(m)))(
|
|
2614
|
+
`${variable} is ${secret ? `shorter than ${MIN_EDIT_SECRET_BYTES} bytes` : "not set"}: edit links are off.`
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
return null;
|
|
2618
|
+
};
|
|
2619
|
+
return Object.assign(read, {
|
|
2620
|
+
reset: () => {
|
|
2621
|
+
warned = false;
|
|
2622
|
+
}
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
function isEditLinkRequest(url, options = {}) {
|
|
2626
|
+
const isFormPage = options.isFormPage ?? (() => true);
|
|
2627
|
+
return isFormPage(url) && url.searchParams.has(options.param ?? EDIT_LINK_PARAM);
|
|
2628
|
+
}
|
|
2629
|
+
function withEditLinkHeaders(request, response, options = {}) {
|
|
2630
|
+
let url;
|
|
2631
|
+
try {
|
|
2632
|
+
url = new URL(request.url);
|
|
2633
|
+
} catch {
|
|
2634
|
+
return response;
|
|
2635
|
+
}
|
|
2636
|
+
if (!isEditLinkRequest(url, options)) return response;
|
|
2637
|
+
const headers = new Headers(response.headers);
|
|
2638
|
+
headers.set("referrer-policy", "no-referrer");
|
|
2639
|
+
headers.set("cache-control", "private, no-store");
|
|
2640
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// src/rate-limit.ts
|
|
2644
|
+
var DEFAULT_TRUSTED_IP_HEADERS = ["x-vercel-forwarded-for", "x-forwarded-for", "x-real-ip"];
|
|
2645
|
+
var RATE_LIMIT_DEFAULTS = {
|
|
2646
|
+
submit: { limit: 60, windowSeconds: 600 },
|
|
2647
|
+
progress: { limit: 900, windowSeconds: 600 },
|
|
2648
|
+
upload: { limit: 120, windowSeconds: 600 }
|
|
2649
|
+
};
|
|
2650
|
+
function clientIp(headers, trusted = DEFAULT_TRUSTED_IP_HEADERS) {
|
|
2651
|
+
for (const name of trusted) {
|
|
2652
|
+
const first = headers.get(name)?.split(",")[0]?.trim();
|
|
2653
|
+
if (first) return first;
|
|
2654
|
+
}
|
|
2655
|
+
return "unknown";
|
|
2656
|
+
}
|
|
2657
|
+
function addressPrefix(ip) {
|
|
2658
|
+
const bare = ip.replace(/^\[|\](:\d+)?$/g, "").split("%")[0].toLowerCase();
|
|
2659
|
+
if (!bare.includes(":")) return bare;
|
|
2660
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(bare);
|
|
2661
|
+
if (mapped) return mapped[1];
|
|
2662
|
+
const [head = "", tail = ""] = bare.split("::");
|
|
2663
|
+
const left = head ? head.split(":") : [];
|
|
2664
|
+
const right = bare.includes("::") && tail ? tail.split(":") : [];
|
|
2665
|
+
const groups = bare.includes("::") ? [...left, ...Array(Math.max(0, 8 - left.length - right.length)).fill("0"), ...right] : left;
|
|
2666
|
+
return `${groups.slice(0, 4).map((g) => (g || "0").replace(/^0+(?=.)/, "")).join(":")}::/64`;
|
|
2667
|
+
}
|
|
2668
|
+
async function rateLimitKey(input) {
|
|
2669
|
+
const encoder2 = new TextEncoder();
|
|
2670
|
+
const key = await crypto.subtle.importKey("raw", encoder2.encode(input.secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
2671
|
+
"sign"
|
|
2672
|
+
]);
|
|
2673
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder2.encode(addressPrefix(input.ip))));
|
|
2674
|
+
let hex = "";
|
|
2675
|
+
for (const b of mac) hex += b.toString(16).padStart(2, "0");
|
|
2676
|
+
return `${input.kind}:${input.scope}:${hex}`;
|
|
2677
|
+
}
|
|
2678
|
+
async function allow(store, check, options = {}) {
|
|
2679
|
+
let allowed;
|
|
2680
|
+
try {
|
|
2681
|
+
allowed = await store.hit(check.key, check.limit, check.windowSeconds);
|
|
2682
|
+
} catch {
|
|
2683
|
+
allowed = null;
|
|
2684
|
+
}
|
|
2685
|
+
if (allowed !== null) return allowed;
|
|
2686
|
+
(options.log ?? ((m) => console.warn(m)))(
|
|
2687
|
+
options.message ?? "The rate limit could not be checked; letting the request through."
|
|
2688
|
+
);
|
|
2689
|
+
return true;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2019
2692
|
// src/server.ts
|
|
2020
2693
|
var MAX_EVENTS = 1e4;
|
|
2021
2694
|
var MAX_SUBMISSION_VALUES = 2e5;
|
|
@@ -2079,9 +2752,10 @@ function wellFormed(input) {
|
|
|
2079
2752
|
}
|
|
2080
2753
|
return { answers, events: known, hidden };
|
|
2081
2754
|
}
|
|
2082
|
-
function validateSubmission(
|
|
2083
|
-
const invalid = checkDefinition(
|
|
2755
|
+
function validateSubmission(published, submission, options = {}) {
|
|
2756
|
+
const invalid = checkDefinition(published);
|
|
2084
2757
|
if (invalid) return { ok: false, errors: { "": invalid } };
|
|
2758
|
+
const definition = withHostOptions(published, options.hostOptions);
|
|
2085
2759
|
if (!isPlainObject(submission)) return MALFORMED;
|
|
2086
2760
|
const maxEvents = options.maxEvents ?? MAX_EVENTS;
|
|
2087
2761
|
let sent = submission;
|
|
@@ -2152,11 +2826,12 @@ function validateSubmission(definition, submission, options = {}) {
|
|
|
2152
2826
|
}
|
|
2153
2827
|
} else {
|
|
2154
2828
|
const errors = {};
|
|
2155
|
-
|
|
2829
|
+
const kept = engine.answers();
|
|
2830
|
+
for (const [id, value] of Object.entries(kept)) {
|
|
2156
2831
|
const message = validateField(fields.get(id), value, {
|
|
2157
2832
|
requireAnswers: false,
|
|
2158
2833
|
custom: options.validators?.[id]
|
|
2159
|
-
});
|
|
2834
|
+
}) ?? filteredChoiceError(definition, fields.get(id), value, kept);
|
|
2160
2835
|
if (message) errors[id] = message;
|
|
2161
2836
|
}
|
|
2162
2837
|
if (Object.keys(errors).length) return { ok: false, errors };
|
|
@@ -2198,6 +2873,6 @@ function publicDefinition(definition) {
|
|
|
2198
2873
|
return JSON.parse(JSON.stringify(definition));
|
|
2199
2874
|
}
|
|
2200
2875
|
|
|
2201
|
-
export { MAX_EVENTS, MAX_REPLAY_WORK, MAX_SUBMISSION_VALUES, RAW_EVENTS_PER_EVENT, publicDefinition, validateSubmission };
|
|
2876
|
+
export { DEFAULT_TRUSTED_IP_HEADERS, EDIT_LINK_PARAM, EDIT_LINK_TTL_SECONDS, MAX_EVENTS, MAX_REPLAY_WORK, MAX_SUBMISSION_VALUES, MIN_EDIT_SECRET_BYTES, RATE_LIMIT_DEFAULTS, RAW_EVENTS_PER_EVENT, addressPrefix, allow, answerSnapshots, answersFromSnapshots, clientIp, createEditSecretReader, editLinkUrl, isEditLinkRequest, isUsableEditSecret, publicDefinition, rateLimitKey, signEditToken, validateSubmission, verifyEditToken, withEditLinkHeaders, withEditToken, withHostOptions };
|
|
2202
2877
|
//# sourceMappingURL=server.js.map
|
|
2203
2878
|
//# sourceMappingURL=server.js.map
|