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