@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/dist/index.js CHANGED
@@ -833,6 +833,81 @@ var structuredFieldTypes = [
833
833
  }
834
834
  ];
835
835
 
836
+ // src/photo.ts
837
+ var PHOTO_TYPE = "photo";
838
+ var UPLOAD_FIELD_TYPES = [PHOTO_TYPE];
839
+ var PHOTO_FORMATS = {
840
+ jpeg: ["image/jpeg"],
841
+ png: ["image/png"],
842
+ heic: ["image/heic", "image/heif"],
843
+ webp: ["image/webp"]
844
+ };
845
+ var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
846
+ var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
847
+ var DEFAULT_PHOTO_FILES = 3;
848
+ var MAX_PHOTO_FILES = 5;
849
+ var DEFAULT_PHOTO_SIZE_MB = 10;
850
+ var MAX_PHOTO_SIZE_MB = 25;
851
+ var MAX_PHOTO_ID_LENGTH = 200;
852
+ var MAX_PHOTO_NAME_LENGTH = 255;
853
+ var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
854
+ var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
855
+ function photoLimits(field2) {
856
+ const maxFiles = wholeIn(field2.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field2.props.maxFiles : DEFAULT_PHOTO_FILES;
857
+ const maxSizeMb = wholeIn(field2.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field2.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
858
+ const raw = field2.props?.formats;
859
+ const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f2) => raw.includes(f2)) : [];
860
+ const formats = picked.length ? picked : [...ALL_FORMATS];
861
+ return {
862
+ maxFiles,
863
+ maxSizeMb,
864
+ maxBytes: maxSizeMb * 1024 * 1024,
865
+ formats,
866
+ mimeTypes: formats.flatMap((f2) => PHOTO_FORMATS[f2])
867
+ };
868
+ }
869
+ function isPhotoRef(v) {
870
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
871
+ const r = v;
872
+ 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;
873
+ }
874
+ function photoRefs(value) {
875
+ if (!Array.isArray(value)) return [];
876
+ const refs = [];
877
+ for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
878
+ return refs;
879
+ }
880
+ var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
881
+ function photoFormatList(formats) {
882
+ const names = formats.map((f2) => FORMAT_NAMES[f2]);
883
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
884
+ }
885
+ var photoTypeMessage = (field2) => `Use a ${photoFormatList(photoLimits(field2).formats)} photo.`;
886
+ var photoSizeMessage = (field2) => `Each photo must be ${photoLimits(field2).maxSizeMb} MB or smaller.`;
887
+ var photoCountMessage = (field2) => `Add at most ${plural2(photoLimits(field2).maxFiles, "photo")}.`;
888
+ var photoFieldType = {
889
+ type: PHOTO_TYPE,
890
+ label: "Photo",
891
+ category: "media",
892
+ value: { json: true },
893
+ operators: ["answered", "empty"],
894
+ a11y: { semantics: "group" },
895
+ isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
896
+ validate: (value, field2) => {
897
+ if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
898
+ const limits = photoLimits(field2);
899
+ if (value.length > limits.maxFiles) return photoCountMessage(field2);
900
+ const refs = value;
901
+ if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
902
+ if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field2);
903
+ if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field2);
904
+ return null;
905
+ },
906
+ normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
907
+ summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
908
+ samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
909
+ };
910
+
836
911
  // src/fields.ts
837
912
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
838
913
  var globalScope = globalThis;
@@ -875,12 +950,127 @@ function summarizeValue(value, field2) {
875
950
  if (Array.isArray(value)) return value.join(", ");
876
951
  return String(value);
877
952
  }
953
+ var OPTION_LIMITS = {
954
+ /** Characters in a description. */
955
+ description: 500,
956
+ /** Characters in an image URL. */
957
+ image: 2048,
958
+ /** Attributes on one option. */
959
+ attributes: 20,
960
+ /** Characters in an attribute's name. */
961
+ attributeName: 64,
962
+ /** Characters in one attribute value. */
963
+ attributeValue: 200,
964
+ /** Values in one attribute's list. */
965
+ attributeItems: 50
966
+ };
967
+ function isHttpsUrl(url) {
968
+ 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).
969
+ ![...url].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
970
+ }
971
+ function attributeName(name) {
972
+ return name.trim() !== "" && name.length <= OPTION_LIMITS.attributeName && !(name in Object.prototype);
973
+ }
974
+ function readAttributes(raw) {
975
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
976
+ const out = {};
977
+ let count = 0;
978
+ for (const name of Object.keys(raw)) {
979
+ if (count >= OPTION_LIMITS.attributes) break;
980
+ if (!attributeName(name)) continue;
981
+ const v = raw[name];
982
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
983
+ if (fits(v)) out[name] = v;
984
+ else if (Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits)) out[name] = [...v];
985
+ else continue;
986
+ count++;
987
+ }
988
+ return count ? out : void 0;
989
+ }
990
+ function readOption(raw) {
991
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
992
+ const o = raw;
993
+ if (typeof o.value !== "string") return null;
994
+ const out = { value: o.value };
995
+ if (typeof o.label === "string") out.label = o.label;
996
+ if (typeof o.description === "string" && o.description.trim() && o.description.length <= OPTION_LIMITS.description) {
997
+ out.description = o.description;
998
+ }
999
+ if (isHttpsUrl(o.image)) out.image = o.image;
1000
+ const attributes = readAttributes(o.attributes);
1001
+ if (attributes) out.attributes = attributes;
1002
+ return out;
1003
+ }
1004
+ function optionJson(option) {
1005
+ const out = { value: option.value };
1006
+ if (option.label !== void 0) out.label = option.label;
1007
+ if (option.description !== void 0) out.description = option.description;
1008
+ if (option.image !== void 0) out.image = option.image;
1009
+ if (option.attributes !== void 0) out.attributes = { ...option.attributes };
1010
+ return out;
1011
+ }
1012
+ var OTHER_MAX_LENGTH = 200;
1013
+ function otherOption(field2) {
1014
+ const other = field2.props?.other;
1015
+ if (other === true) return { label: "Other" };
1016
+ if (other && typeof other === "object" && !Array.isArray(other)) {
1017
+ const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
1018
+ return { label };
1019
+ }
1020
+ return null;
1021
+ }
1022
+ function isOtherValue(field2, value) {
1023
+ return otherOption(field2) !== null && typeof value === "string" && !choiceOptions(field2).some((o) => o.value === value);
1024
+ }
1025
+ var typedOther = (field2, value) => isOtherValue(field2, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
878
1026
  function choiceOptions(field2) {
879
1027
  const raw = field2.props?.options;
880
1028
  if (!Array.isArray(raw)) return [];
881
- return raw.flatMap(
882
- (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
883
- );
1029
+ return raw.flatMap((o) => {
1030
+ const option = readOption(o);
1031
+ return option ? [option] : [];
1032
+ });
1033
+ }
1034
+ function optionsSource(field2) {
1035
+ const raw = field2.props?.optionsFrom;
1036
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
1037
+ if (raw.source !== "host" || typeof raw.key !== "string" || !raw.key) return void 0;
1038
+ const out = { source: "host", key: raw.key };
1039
+ const filter = raw.filter;
1040
+ if (filter && typeof filter === "object" && !Array.isArray(filter) && typeof filter.attribute === "string" && filter.attribute && typeof filter.field === "string" && filter.field) {
1041
+ out.filter = { attribute: filter.attribute, field: filter.field };
1042
+ }
1043
+ return out;
1044
+ }
1045
+ function hostList(hostOptions, key) {
1046
+ if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
1047
+ const list = hostOptions[key];
1048
+ if (!Array.isArray(list)) return [];
1049
+ const seen = /* @__PURE__ */ new Set();
1050
+ const out = [];
1051
+ for (const o of list) {
1052
+ const option = readOption(o);
1053
+ if (!option || !option.value || seen.has(option.value)) continue;
1054
+ seen.add(option.value);
1055
+ out.push(option);
1056
+ }
1057
+ return out;
1058
+ }
1059
+ function withHostOptions(definition, hostOptions) {
1060
+ let changed = false;
1061
+ const blocks = definition.blocks.map((block) => {
1062
+ if (block.kind !== "field") return block;
1063
+ const from = optionsSource(block);
1064
+ if (!from) return block;
1065
+ const own2 = choiceOptions(block);
1066
+ const taken = new Set(own2.map((o) => o.value));
1067
+ const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
1068
+ if (!added.length) return block;
1069
+ changed = true;
1070
+ const options = [...own2, ...added].map(optionJson);
1071
+ return { ...block, props: { ...block.props, options } };
1072
+ });
1073
+ return changed ? { ...definition, blocks } : definition;
884
1074
  }
885
1075
  function optionLabel(field2, value) {
886
1076
  const option = choiceOptions(field2).find((o) => o.value === value);
@@ -1000,7 +1190,7 @@ var builtins = [
1000
1190
  value: { json: true },
1001
1191
  operators: ["eq", "neq", "in", "answered", "empty"],
1002
1192
  a11y: { semantics: "group" },
1003
- validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
1193
+ validate: (value, field2) => typeof value === "string" && (choiceOptions(field2).some((o) => o.value === value) || typedOther(field2, value)) ? null : "Choose one of the options.",
1004
1194
  summarize: (value, field2) => optionLabel(field2, String(value))
1005
1195
  },
1006
1196
  {
@@ -1012,7 +1202,8 @@ var builtins = [
1012
1202
  a11y: { semantics: "group" },
1013
1203
  validate: (value, field2) => {
1014
1204
  const allowed = new Set(choiceOptions(field2).map((o) => o.value));
1015
- return Array.isArray(value) && value.length <= allowed.size && new Set(value).size === value.length && value.every((v) => typeof v === "string" && allowed.has(v)) ? null : "Choose from the options.";
1205
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1206
+ return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field2, others[0])) ? null : "Choose from the options.";
1016
1207
  },
1017
1208
  summarize: (value, field2) => Array.isArray(value) ? value.map((v) => optionLabel(field2, String(v))).join(", ") : String(value)
1018
1209
  },
@@ -1033,7 +1224,7 @@ var builtins = [
1033
1224
  value: { json: true },
1034
1225
  operators: ["eq", "neq", "in", "answered", "empty"],
1035
1226
  a11y: { semantics: "control" },
1036
- validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
1227
+ validate: (value, field2) => typeof value === "string" && (choiceOptions(field2).some((o) => o.value === value) || typedOther(field2, value)) ? null : "Choose one of the options.",
1037
1228
  summarize: (value, field2) => optionLabel(field2, String(value))
1038
1229
  },
1039
1230
  {
@@ -1143,7 +1334,48 @@ function dateLimits(field2) {
1143
1334
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
1144
1335
  return out;
1145
1336
  }
1146
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
1337
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
1338
+
1339
+ // src/hidden.ts
1340
+ var MAX_HIDDEN_STRING_LENGTH = 1e3;
1341
+ var DECIMAL = /^-?\d+(\.\d+)?$/;
1342
+ function coerceHiddenValue(value, type) {
1343
+ if (typeof value === "string" && value.length > MAX_HIDDEN_STRING_LENGTH) return void 0;
1344
+ if (type === "string") {
1345
+ if (typeof value === "string") return value;
1346
+ if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return String(value);
1347
+ return void 0;
1348
+ }
1349
+ if (type === "number") {
1350
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
1351
+ if (typeof value !== "string" || !DECIMAL.test(value)) return void 0;
1352
+ const n = Number(value);
1353
+ return Number.isFinite(n) ? n : void 0;
1354
+ }
1355
+ if (typeof value === "boolean") return value;
1356
+ if (value === "true") return true;
1357
+ if (value === "false") return false;
1358
+ return void 0;
1359
+ }
1360
+ function coerceHidden(definition, raw, options = {}) {
1361
+ const declared = new Map((definition.hidden ?? []).map((h) => [h.name, h]));
1362
+ const values = {};
1363
+ const ignored = [];
1364
+ if (!isPlainObject(raw)) return { values, ignored };
1365
+ for (const name of Object.keys(raw)) {
1366
+ const value = raw[name];
1367
+ if (value === void 0) continue;
1368
+ const spec = declared.get(name);
1369
+ if (!spec || options.sources && !options.sources.includes(spec.source)) {
1370
+ ignored.push(name);
1371
+ continue;
1372
+ }
1373
+ const coerced = coerceHiddenValue(value, spec.type);
1374
+ if (coerced === void 0) ignored.push(name);
1375
+ else Object.defineProperty(values, name, { value: coerced, enumerable: true, writable: true, configurable: true });
1376
+ }
1377
+ return { values, ignored };
1378
+ }
1147
1379
 
1148
1380
  // src/logic.ts
1149
1381
  function own(record, key) {
@@ -1276,6 +1508,225 @@ function fieldMap(def) {
1276
1508
  return map;
1277
1509
  }
1278
1510
 
1511
+ // src/options.ts
1512
+ var CHOICE_FIELD_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
1513
+ var isChoice = (block) => !!block && block.kind === "field" && CHOICE_FIELD_TYPES.has(block.type);
1514
+ function put(record, key, value) {
1515
+ Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
1516
+ }
1517
+ function optionFilter(field2) {
1518
+ return optionsSource(field2)?.filter;
1519
+ }
1520
+ function answerValues(answer) {
1521
+ const one = (v) => typeof v === "string" ? [v] : typeof v === "number" || typeof v === "boolean" ? [String(v)] : [];
1522
+ if (answer === void 0 || answer === null) return [];
1523
+ return Array.isArray(answer) ? answer.flatMap(one) : one(answer);
1524
+ }
1525
+ function optionMatches(option, attribute, answer) {
1526
+ const have = option.attributes && Object.prototype.hasOwnProperty.call(option.attributes, attribute) ? option.attributes[attribute] : void 0;
1527
+ if (have === void 0) return false;
1528
+ const wanted = answerValues(answer);
1529
+ return (Array.isArray(have) ? have : [have]).some((v) => wanted.includes(v));
1530
+ }
1531
+ function filterSource(definition, key) {
1532
+ return definition.blocks.find((b) => b.kind === "field" && b.key === key);
1533
+ }
1534
+ function visibleOptions(definition, field2, values) {
1535
+ const options = choiceOptions(field2);
1536
+ const filter = optionFilter(field2);
1537
+ if (!filter) return options;
1538
+ const source = filterSource(definition, filter.field);
1539
+ if (!source || source.id === field2.id) return options;
1540
+ const answer = own(values, source.id);
1541
+ if (isEmptyValue(answer, source)) return options;
1542
+ return options.filter((o) => optionMatches(o, filter.attribute, answer));
1543
+ }
1544
+ function answersOnPath(state) {
1545
+ const out = /* @__PURE__ */ Object.create(null);
1546
+ for (const id of state.path) out[id] = own(state.values, id);
1547
+ return out;
1548
+ }
1549
+ var OUTSIDE_FILTER = "Choose one of the options.";
1550
+ function filteredChoiceError(definition, field2, value, values) {
1551
+ if (!optionFilter(field2) || isEmptyValue(value, field2)) return null;
1552
+ const all = new Set(choiceOptions(field2).map((o) => o.value));
1553
+ const shown2 = new Set(visibleOptions(definition, field2, values).map((o) => o.value));
1554
+ const picked = answerValues(value);
1555
+ return picked.some((v) => all.has(v) && !shown2.has(v)) ? OUTSIDE_FILTER : null;
1556
+ }
1557
+ function hasOptionFilters(definition) {
1558
+ return definition.blocks.some((b) => isChoice(b) && optionFilter(b) !== void 0);
1559
+ }
1560
+ function clearFilteredAnswers(definition, values, changedId) {
1561
+ const changed = definition.blocks.find((b) => b.kind === "field" && b.id === changedId);
1562
+ if (!changed) return values;
1563
+ const keys = /* @__PURE__ */ new Set([changed.key]);
1564
+ let out = values;
1565
+ for (const block of definition.blocks) {
1566
+ if (!isChoice(block)) continue;
1567
+ const filter = optionFilter(block);
1568
+ if (!filter || !keys.has(filter.field)) continue;
1569
+ const value = own(out, block.id);
1570
+ if (value === void 0 || isEmptyValue(value, block)) continue;
1571
+ const all = new Set(choiceOptions(block).map((o) => o.value));
1572
+ const shown2 = new Set(visibleOptions(definition, block, out).map((o) => o.value));
1573
+ const keep = (v) => typeof v !== "string" || !all.has(v) || shown2.has(v);
1574
+ let next = value;
1575
+ if (Array.isArray(value)) {
1576
+ const kept = value.filter(keep);
1577
+ if (kept.length !== value.length) next = kept.length ? kept : void 0;
1578
+ } else if (!keep(value)) next = void 0;
1579
+ if (next === value) continue;
1580
+ if (out === values) out = { ...values };
1581
+ put(out, block.id, next);
1582
+ keys.add(block.key);
1583
+ }
1584
+ return out;
1585
+ }
1586
+ var SEARCHABLE_OPTION_COUNT = 10;
1587
+ function isSearchable(field2, optionCount = choiceOptions(field2).length) {
1588
+ const setting = field2.props?.searchable;
1589
+ if (setting === true || setting === false) return setting;
1590
+ return optionCount > SEARCHABLE_OPTION_COUNT;
1591
+ }
1592
+ function fold(text) {
1593
+ return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
1594
+ }
1595
+ function searchOptions(options, query) {
1596
+ const words = fold(query).split(/\s+/).filter(Boolean);
1597
+ if (!words.length) return [...options];
1598
+ return options.filter((o) => {
1599
+ const text = fold([o.label ?? "", o.value, o.description ?? ""].join(" "));
1600
+ return words.every((w) => text.includes(w));
1601
+ });
1602
+ }
1603
+ var MAX_PREFILL_PARAM_LENGTH = 64;
1604
+ function isPrefillParam(param) {
1605
+ return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
1606
+ }
1607
+ function prefillSetting(field2) {
1608
+ if (!CHOICE_FIELD_TYPES.has(field2.type)) return void 0;
1609
+ const raw = field2.props?.prefill;
1610
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
1611
+ return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
1612
+ }
1613
+ function paramValues(params, name) {
1614
+ let raw;
1615
+ if (typeof params.getAll === "function") {
1616
+ raw = params.getAll(name);
1617
+ } else if (Object.prototype.hasOwnProperty.call(params, name)) {
1618
+ raw = params[name];
1619
+ }
1620
+ const list = Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw];
1621
+ return list.filter((v) => typeof v === "string" && v !== "" && v.length <= MAX_HIDDEN_STRING_LENGTH);
1622
+ }
1623
+ function prefillFromLink(definition, params, values = {}) {
1624
+ const out = {};
1625
+ if (!params || typeof params !== "object") return out;
1626
+ const seen = {};
1627
+ for (const id of Object.keys(values)) put(seen, id, values[id]);
1628
+ for (const block of definition.blocks) {
1629
+ if (!isChoice(block)) continue;
1630
+ const setting = prefillSetting(block);
1631
+ if (!setting || own(seen, block.id) !== void 0) continue;
1632
+ const raw = paramValues(params, setting.param);
1633
+ if (!raw.length) continue;
1634
+ const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
1635
+ let value;
1636
+ if (block.type === "multi-choice") {
1637
+ const picked = [];
1638
+ for (const r of raw) {
1639
+ for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
1640
+ if (shown2.has(v) && !picked.includes(v)) picked.push(v);
1641
+ }
1642
+ }
1643
+ if (picked.length) value = picked;
1644
+ } else value = raw.find((v) => shown2.has(v));
1645
+ if (value === void 0) continue;
1646
+ put(out, block.id, value);
1647
+ put(seen, block.id, value);
1648
+ }
1649
+ return out;
1650
+ }
1651
+ function hiddenByPrefill(definition, state) {
1652
+ const hideable = /* @__PURE__ */ new Set();
1653
+ for (const b of definition.blocks) {
1654
+ if (isChoice(b) && prefillSetting(b)?.hideWhenPrefilled) hideable.add(b.id);
1655
+ }
1656
+ if (!hideable.size) return [];
1657
+ const prefilled = /* @__PURE__ */ new Set();
1658
+ for (const e of state.events) {
1659
+ if (e.type !== "answer" || !hideable.has(e.field)) continue;
1660
+ if (e.source === "prefill") prefilled.add(e.field);
1661
+ else prefilled.delete(e.field);
1662
+ }
1663
+ if (!prefilled.size) return [];
1664
+ const fields = new Map(definition.blocks.map((b) => [b.id, b]));
1665
+ return state.path.filter((id) => {
1666
+ if (!prefilled.has(id) || own(state.errors, id) !== void 0) return false;
1667
+ const field2 = fields.get(id);
1668
+ return !isEmptyValue(own(state.values, id), field2);
1669
+ });
1670
+ }
1671
+ function respondentPath(definition, state) {
1672
+ const hidden = hiddenByPrefill(definition, state);
1673
+ if (!hidden.length) return [...state.path];
1674
+ const skip = new Set(hidden);
1675
+ return state.path.filter((id) => !skip.has(id));
1676
+ }
1677
+ function snapshotOf(byValue, value) {
1678
+ const option = byValue.get(value);
1679
+ if (option) return { value, label: option.label ?? value };
1680
+ return { value, label: value, other: true };
1681
+ }
1682
+ function answerSnapshots(definition, answers) {
1683
+ const out = {};
1684
+ for (const block of definition.blocks) {
1685
+ if (!isChoice(block) || !optionsSource(block)) continue;
1686
+ const value = own(answers, block.id);
1687
+ if (isEmptyValue(value, block)) continue;
1688
+ const byValue = new Map(choiceOptions(block).map((o) => [o.value, o]));
1689
+ let snapshot;
1690
+ if (Array.isArray(value)) {
1691
+ const items = value.filter((v) => typeof v === "string").map((v) => snapshotOf(byValue, v));
1692
+ if (items.length) snapshot = items;
1693
+ } else if (typeof value === "string") snapshot = snapshotOf(byValue, value);
1694
+ if (snapshot) put(out, block.id, snapshot);
1695
+ }
1696
+ return out;
1697
+ }
1698
+ function answersFromSnapshots(definition, snapshots) {
1699
+ const answers = {};
1700
+ const missing = {};
1701
+ for (const block of definition.blocks) {
1702
+ if (!isChoice(block)) continue;
1703
+ const raw = own(snapshots, block.id);
1704
+ const list = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).flatMap((s) => {
1705
+ const o = readOption(s);
1706
+ if (!o) return [];
1707
+ const label = o.label ?? o.value;
1708
+ return [s.other === true ? { value: o.value, label, other: true } : { value: o.value, label }];
1709
+ });
1710
+ if (!list.length) continue;
1711
+ const options = choiceOptions(block);
1712
+ const byValue = new Set(options.map((o) => o.value));
1713
+ const allowsOther = block.props?.other !== void 0 && block.props?.other !== false;
1714
+ const found = [];
1715
+ const lost = [];
1716
+ for (const s of list) {
1717
+ let value;
1718
+ if (byValue.has(s.value)) value = s.value;
1719
+ else if (s.other) value = allowsOther ? s.value : void 0;
1720
+ else value = options.find((o) => fold(o.label ?? o.value) === fold(s.label))?.value;
1721
+ if (value === void 0) lost.push(s);
1722
+ else if (!found.includes(value)) found.push(value);
1723
+ }
1724
+ if (found.length) put(answers, block.id, block.type === "multi-choice" ? found : found[0]);
1725
+ if (lost.length) put(missing, block.id, lost);
1726
+ }
1727
+ return { answers, missing };
1728
+ }
1729
+
1279
1730
  // src/recall.ts
1280
1731
  var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
1281
1732
  function recall(text, scope) {
@@ -1355,6 +1806,31 @@ function parseForm(input) {
1355
1806
  const optionalBoolean = (v, path) => {
1356
1807
  if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
1357
1808
  };
1809
+ const optionDetails = (o, at) => {
1810
+ if (o.description !== void 0 && (typeof o.description !== "string" || o.description.length > OPTION_LIMITS.description)) {
1811
+ issue(`${at}.description`, `Must be text of at most ${OPTION_LIMITS.description} characters.`);
1812
+ }
1813
+ if (o.image !== void 0 && !isHttpsUrl(o.image)) {
1814
+ issue(`${at}.image`, `Must be an https:// link of at most ${OPTION_LIMITS.image} characters.`);
1815
+ }
1816
+ if (o.attributes === void 0) return;
1817
+ const attributes = o.attributes;
1818
+ if (!isObject2(attributes) || Object.keys(attributes).length > OPTION_LIMITS.attributes) {
1819
+ issue(`${at}.attributes`, `Must be an object of at most ${OPTION_LIMITS.attributes} attributes.`);
1820
+ return;
1821
+ }
1822
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
1823
+ for (const [name2, v] of Object.entries(attributes)) {
1824
+ if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
1825
+ issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
1826
+ } else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
1827
+ issue(
1828
+ `${at}.attributes.${name2}`,
1829
+ `Must be text, or a list of at most ${OPTION_LIMITS.attributeItems} texts, each at most ${OPTION_LIMITS.attributeValue} characters.`
1830
+ );
1831
+ }
1832
+ }
1833
+ };
1358
1834
  const name = (v, path, what) => {
1359
1835
  if (typeof v !== "string" || !v) {
1360
1836
  issue(path, `${what} must be a non-empty string.`);
@@ -1443,6 +1919,22 @@ function parseForm(input) {
1443
1919
  }
1444
1920
  }
1445
1921
  if (type === "consent") optionalText(props2.text, `${path}.text`);
1922
+ if (type === PHOTO_TYPE) {
1923
+ if (props2.maxFiles !== void 0 && !(isInt(props2.maxFiles) && props2.maxFiles >= 1 && props2.maxFiles <= MAX_PHOTO_FILES)) {
1924
+ issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
1925
+ }
1926
+ if (props2.maxSizeMb !== void 0 && !(isInt(props2.maxSizeMb) && props2.maxSizeMb >= 1 && props2.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
1927
+ issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
1928
+ }
1929
+ if (props2.formats !== void 0) {
1930
+ const formats = props2.formats;
1931
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
1932
+ issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
1933
+ } else if (new Set(formats).size !== formats.length) {
1934
+ issue(`${path}.formats`, "Name each format once.");
1935
+ }
1936
+ }
1937
+ }
1446
1938
  };
1447
1939
  if (!isObject2(input)) {
1448
1940
  issue("", "A form definition must be an object.");
@@ -1463,6 +1955,7 @@ function parseForm(input) {
1463
1955
  else {
1464
1956
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
1465
1957
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
1958
+ optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
1466
1959
  }
1467
1960
  }
1468
1961
  if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
@@ -1503,6 +1996,38 @@ function parseForm(input) {
1503
1996
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
1504
1997
  }
1505
1998
  if (block.props !== void 0) {
1999
+ const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
2000
+ if (from !== void 0) {
2001
+ if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
2002
+ issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
2003
+ } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2004
+ warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
2005
+ }
2006
+ const filter = isObject2(from) ? from.filter : void 0;
2007
+ if (filter !== void 0) {
2008
+ const at = `${path}.props.optionsFrom.filter`;
2009
+ if (!isObject2(filter) || typeof filter.attribute !== "string" || !filter.attribute || filter.attribute.length > OPTION_LIMITS.attributeName || typeof filter.field !== "string" || !filter.field) {
2010
+ issue(at, `Must be { attribute, field }: an attribute name (at most ${OPTION_LIMITS.attributeName} characters) and an earlier question's key.`);
2011
+ } else if (!keys.has(filter.field)) {
2012
+ issue(`${at}.field`, `"${filter.field}" must be the key of an earlier question.`);
2013
+ }
2014
+ }
2015
+ }
2016
+ const prefill = isObject2(block.props) ? block.props.prefill : void 0;
2017
+ if (prefill !== void 0) {
2018
+ if (!isObject2(prefill) || !isPrefillParam(prefill.param)) {
2019
+ issue(
2020
+ `${path}.props.prefill`,
2021
+ `Must be { param, hideWhenPrefilled? }: a link parameter of letters, digits, "_", "-" or "." (at most ${MAX_PREFILL_PARAM_LENGTH} characters).`
2022
+ );
2023
+ } else {
2024
+ optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
2025
+ if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2026
+ warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
2027
+ }
2028
+ }
2029
+ }
2030
+ if (isObject2(block.props)) optionalBoolean(block.props.searchable, `${path}.props.searchable`);
1506
2031
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1507
2032
  else if (block.props.options !== void 0) {
1508
2033
  const options = block.props.options;
@@ -1511,12 +2036,25 @@ function parseForm(input) {
1511
2036
  options.forEach((o, j) => {
1512
2037
  const at = `${path}.props.options[${j}]`;
1513
2038
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1514
- else optionalText(o.label, `${at}.label`);
2039
+ else {
2040
+ optionalText(o.label, `${at}.label`);
2041
+ optionDetails(o, at);
2042
+ }
1515
2043
  });
1516
- choices.set(
1517
- block.id,
1518
- new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1519
- );
2044
+ if (from === void 0) {
2045
+ choices.set(
2046
+ block.id,
2047
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
2048
+ );
2049
+ }
2050
+ }
2051
+ }
2052
+ const other = isObject2(block.props) ? block.props.other : void 0;
2053
+ if (other !== void 0) {
2054
+ if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
2055
+ issue(`${path}.props.other`, "Must be true, false or { label }.");
2056
+ } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2057
+ warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
1520
2058
  }
1521
2059
  }
1522
2060
  const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
@@ -1591,6 +2129,8 @@ function parseForm(input) {
1591
2129
  } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
1592
2130
  else if (targetType === "media-notes" || targetType === "text-highlights") {
1593
2131
  warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
2132
+ } else if (targetType === PHOTO_TYPE) {
2133
+ warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
1594
2134
  }
1595
2135
  }
1596
2136
  });
@@ -1810,9 +2350,30 @@ function field(type, key, options, props2) {
1810
2350
  return block;
1811
2351
  }
1812
2352
  var props = (all) => Object.fromEntries(Object.entries(all).filter((e) => e[1] !== void 0));
1813
- var choiceProps = (options) => ({
1814
- options: options.map((o) => o.label ? { value: o.value, label: o.label } : { value: o.value })
1815
- });
2353
+ var choiceProps = (o) => {
2354
+ const out = {};
2355
+ if (o.options || !o.optionsFrom) {
2356
+ out.options = (o.options ?? []).map(
2357
+ (x) => props({
2358
+ value: x.value,
2359
+ label: x.label || void 0,
2360
+ description: x.description,
2361
+ image: x.image,
2362
+ attributes: x.attributes
2363
+ })
2364
+ );
2365
+ }
2366
+ if (o.optionsFrom) {
2367
+ const { source, key, filter } = o.optionsFrom;
2368
+ out.optionsFrom = filter ? { source, key, filter: { attribute: filter.attribute, field: filter.field } } : { source, key };
2369
+ }
2370
+ if (o.prefill) {
2371
+ out.prefill = o.prefill.hideWhenPrefilled ? { param: o.prefill.param, hideWhenPrefilled: true } : { param: o.prefill.param };
2372
+ }
2373
+ if (o.searchable !== void 0) out.searchable = o.searchable;
2374
+ if (o.other) out.other = o.other === true ? true : props({ label: o.other.label });
2375
+ return out;
2376
+ };
1816
2377
  var hiddenSpec = (type, o) => ({
1817
2378
  type,
1818
2379
  source: o.source ?? "url",
@@ -1836,11 +2397,11 @@ var f = {
1836
2397
  ),
1837
2398
  /** A 0-to-10 Net Promoter Score. The end labels default to "Not likely" and "Very likely" in the renderer. */
1838
2399
  nps: (key, o) => field("nps", key, o, props({ lowLabel: o.lowLabel || void 0, highLabel: o.highLabel || void 0 })),
1839
- singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o.options)),
1840
- multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o.options)),
2400
+ singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o)),
2401
+ multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o)),
1841
2402
  yesNo: (key, o) => field("yes-no", key, o),
1842
2403
  /** One option from a compact menu: for a long list (a country, a class), where single choice would be a wall of buttons. */
1843
- dropdown: (key, o) => field("dropdown", key, o, choiceProps(o.options)),
2404
+ dropdown: (key, o) => field("dropdown", key, o, choiceProps(o)),
1844
2405
  /** A number, optionally between `min` and `max` and on a `step` (from `min`, else 0): `step: 1` for whole numbers. */
1845
2406
  number: (key, o) => field("number", key, o, props({ min: o.min, max: o.max, step: o.step })),
1846
2407
  /** A calendar day, stored as `YYYY-MM-DD`, optionally between `min` and `max` (also `YYYY-MM-DD`). */
@@ -1892,6 +2453,17 @@ var f = {
1892
2453
  o,
1893
2454
  props({ text: o.text, maxHighlights: o.maxHighlights })
1894
2455
  ),
2456
+ /**
2457
+ * Up to a few photos (a picture of broken equipment). The answer holds the
2458
+ * references the host's upload returned, never the bytes; `formats` from
2459
+ * jpeg, png, heic and webp (default all four).
2460
+ */
2461
+ photo: (key, o) => field(
2462
+ "photo",
2463
+ key,
2464
+ o,
2465
+ props({ maxFiles: o.maxFiles, maxSizeMb: o.maxSizeMb, formats: o.formats ? [...o.formats] : void 0 })
2466
+ ),
1895
2467
  /** Any registered field type, including plugins. */
1896
2468
  custom: (type, key, o) => field(type, key, o, o.props),
1897
2469
  heading: (id, text) => ({ kind: "content", id, type: "heading", text }),
@@ -1999,47 +2571,6 @@ ${list}`);
1999
2571
  };
2000
2572
  }
2001
2573
 
2002
- // src/hidden.ts
2003
- var MAX_HIDDEN_STRING_LENGTH = 1e3;
2004
- var DECIMAL = /^-?\d+(\.\d+)?$/;
2005
- function coerceHiddenValue(value, type) {
2006
- if (typeof value === "string" && value.length > MAX_HIDDEN_STRING_LENGTH) return void 0;
2007
- if (type === "string") {
2008
- if (typeof value === "string") return value;
2009
- if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return String(value);
2010
- return void 0;
2011
- }
2012
- if (type === "number") {
2013
- if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
2014
- if (typeof value !== "string" || !DECIMAL.test(value)) return void 0;
2015
- const n = Number(value);
2016
- return Number.isFinite(n) ? n : void 0;
2017
- }
2018
- if (typeof value === "boolean") return value;
2019
- if (value === "true") return true;
2020
- if (value === "false") return false;
2021
- return void 0;
2022
- }
2023
- function coerceHidden(definition, raw, options = {}) {
2024
- const declared = new Map((definition.hidden ?? []).map((h) => [h.name, h]));
2025
- const values = {};
2026
- const ignored = [];
2027
- if (!isPlainObject(raw)) return { values, ignored };
2028
- for (const name of Object.keys(raw)) {
2029
- const value = raw[name];
2030
- if (value === void 0) continue;
2031
- const spec = declared.get(name);
2032
- if (!spec || options.sources && !options.sources.includes(spec.source)) {
2033
- ignored.push(name);
2034
- continue;
2035
- }
2036
- const coerced = coerceHiddenValue(value, spec.type);
2037
- if (coerced === void 0) ignored.push(name);
2038
- else Object.defineProperty(values, name, { value: coerced, enumerable: true, writable: true, configurable: true });
2039
- }
2040
- return { values, ignored };
2041
- }
2042
-
2043
2574
  // src/validate.ts
2044
2575
  var REQUIRED_MESSAGE = "This question needs an answer.";
2045
2576
  function runCustom(validator, value) {
@@ -2075,7 +2606,8 @@ function readsAny(node, ids) {
2075
2606
  function createForm(definition, options = {}) {
2076
2607
  return build(definition, options).engine;
2077
2608
  }
2078
- function build(definition, options, replaying = false, budget) {
2609
+ function build(given, options, replaying = false, budget) {
2610
+ const definition = withHostOptions(given, options.hostOptions);
2079
2611
  const fields = fieldMap(definition);
2080
2612
  const byKey = new Map([...fields.values()].map((f2) => [f2.key, f2]));
2081
2613
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -2121,6 +2653,7 @@ function build(definition, options, replaying = false, budget) {
2121
2653
  path: first.path,
2122
2654
  current: first.path[0] ?? null,
2123
2655
  history: [],
2656
+ furthest: first.path[0] ?? null,
2124
2657
  errors: {},
2125
2658
  status: "in_progress",
2126
2659
  ending: null,
@@ -2141,16 +2674,34 @@ function build(definition, options, replaying = false, budget) {
2141
2674
  return at === void 0 ? event : { ...event, at };
2142
2675
  };
2143
2676
  const isVisibleIn = (s, id) => s.path.includes(id);
2677
+ const filters = hasOptionFilters(definition);
2678
+ const clearFiltered = (values, id) => filters ? clearFilteredAnswers(definition, values, id) : values;
2144
2679
  const errorsFor = (s, ids) => {
2145
2680
  const errors = {};
2681
+ let shown2;
2146
2682
  for (const id of ids) {
2147
2683
  const field2 = fields.get(id);
2148
2684
  if (!field2 || !isVisibleIn(s, id)) continue;
2149
- const message = validateField(field2, own(s.values, id), { custom: options.validators?.[id] });
2685
+ const value = own(s.values, id);
2686
+ let message = validateField(field2, value, { custom: options.validators?.[id] });
2687
+ if (!message && filters) message = filteredChoiceError(definition, field2, value, shown2 ??= answersOnPath(s));
2150
2688
  if (message) errors[id] = message;
2151
2689
  }
2152
2690
  return errors;
2153
2691
  };
2692
+ const skipped = (s) => new Set(hiddenByPrefill(definition, s));
2693
+ const shownFrom = (s, from, skip) => {
2694
+ for (let i = from; i < s.path.length; i++) if (!skip.has(s.path[i])) return s.path[i];
2695
+ return void 0;
2696
+ };
2697
+ const showCurrent = () => {
2698
+ if (!state.current) return;
2699
+ const skip = skipped(state);
2700
+ if (!skip.has(state.current)) return;
2701
+ const at = state.path.indexOf(state.current);
2702
+ const shown2 = at < 0 ? void 0 : shownFrom(state, at, skip);
2703
+ if (shown2) state = { ...state, current: shown2, furthest: state.furthest === state.current ? shown2 : state.furthest };
2704
+ };
2154
2705
  let unsettled = false;
2155
2706
  let formulasStale = false;
2156
2707
  const settleNow = (needValues = true) => {
@@ -2179,13 +2730,13 @@ function build(definition, options, replaying = false, budget) {
2179
2730
  const errors = { ...state.errors };
2180
2731
  delete errors[id];
2181
2732
  if (batch) {
2182
- state.values = { ...state.values, [id]: value };
2733
+ state.values = clearFiltered({ ...state.values, [id]: value }, id);
2183
2734
  state.errors = errors;
2184
2735
  log(event);
2185
2736
  unsettled = true;
2186
2737
  return;
2187
2738
  }
2188
- const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
2739
+ const { values, path } = settle(clearFiltered({ ...state.values, [id]: value }, id), state.hidden);
2189
2740
  emit({ ...state, values, path, errors, events: log(event) });
2190
2741
  };
2191
2742
  const engine = {
@@ -2216,19 +2767,21 @@ function build(definition, options, replaying = false, budget) {
2216
2767
  return false;
2217
2768
  }
2218
2769
  const at = state.path.indexOf(state.current);
2219
- const target = state.path[at + 1];
2770
+ const target = shownFrom(state, at + 1, skipped(state));
2220
2771
  if (!target) {
2221
2772
  emit({ ...state, events });
2222
2773
  return true;
2223
2774
  }
2224
- emit({ ...state, current: target, history: [...state.history, state.current], events });
2775
+ const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
2776
+ emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
2225
2777
  return true;
2226
2778
  },
2227
2779
  back() {
2228
2780
  if (state.status === "completed") return;
2229
2781
  const history = [...state.history];
2782
+ const skip = skipped(state);
2230
2783
  let previous = history.pop();
2231
- while (previous && !state.path.includes(previous)) previous = history.pop();
2784
+ while (previous && (!state.path.includes(previous) || skip.has(previous))) previous = history.pop();
2232
2785
  if (!previous) return;
2233
2786
  emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
2234
2787
  },
@@ -2256,15 +2809,27 @@ function build(definition, options, replaying = false, budget) {
2256
2809
  return out;
2257
2810
  }
2258
2811
  };
2259
- if (options.initialValues) {
2812
+ const fromLink = options.linkParams ? prefillFromLink(definition, options.linkParams, options.initialValues) : {};
2813
+ const initialValues = Object.keys(fromLink).length ? { ...fromLink, ...options.initialValues } : options.initialValues;
2814
+ if (initialValues) {
2260
2815
  const wasBatch = batch;
2261
2816
  batch = true;
2262
- for (const [id, value] of Object.entries(options.initialValues)) {
2817
+ for (const [id, value] of Object.entries(initialValues)) {
2263
2818
  if (!fields.has(id) || value === void 0) continue;
2264
2819
  answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
2265
2820
  }
2266
2821
  settleNow();
2267
2822
  batch = wasBatch;
2823
+ showCurrent();
2824
+ }
2825
+ const resumeAt = options.resumeAt;
2826
+ if (resumeAt && !replaying && state.path.includes(resumeAt)) {
2827
+ for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
2828
+ if (!engine.next()) {
2829
+ state = { ...state, errors: {} };
2830
+ break;
2831
+ }
2832
+ }
2268
2833
  }
2269
2834
  const apply = (event) => {
2270
2835
  if (event.type === "answer") {
@@ -2274,18 +2839,22 @@ function build(definition, options, replaying = false, budget) {
2274
2839
  }
2275
2840
  } else if (event.type === "next") {
2276
2841
  settleNow(false);
2842
+ showCurrent();
2277
2843
  if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
2278
2844
  engine.next();
2279
2845
  } else if (event.type === "back") {
2280
2846
  settleNow(false);
2847
+ showCurrent();
2281
2848
  engine.back();
2282
2849
  } else if (event.type === "submit") {
2283
2850
  settleNow();
2851
+ showCurrent();
2284
2852
  engine.submit();
2285
2853
  }
2286
2854
  };
2287
2855
  const finish = () => {
2288
2856
  settleNow();
2857
+ showCurrent();
2289
2858
  batch = false;
2290
2859
  state = { ...state, events: [...state.events] };
2291
2860
  };
@@ -2296,7 +2865,12 @@ function replay(definition, events, options = {}) {
2296
2865
  }
2297
2866
  function replayWithin(definition, events, options, maxWork) {
2298
2867
  const budget = void 0 ;
2299
- const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
2868
+ const { engine, apply, finish } = build(
2869
+ definition,
2870
+ { ...options, initialValues: void 0, linkParams: void 0, clock: () => void 0 },
2871
+ true,
2872
+ budget
2873
+ );
2300
2874
  try {
2301
2875
  for (const event of events) apply(event);
2302
2876
  finish();
@@ -2305,6 +2879,33 @@ function replayWithin(definition, events, options, maxWork) {
2305
2879
  return engine;
2306
2880
  }
2307
2881
 
2882
+ // src/progress.ts
2883
+ function furthestReached(state, definition) {
2884
+ const at = (id) => id ? state.path.indexOf(id) : -1;
2885
+ let furthest = Math.max(at(state.current), at(state.furthest));
2886
+ for (const id of state.history) furthest = Math.max(furthest, at(id));
2887
+ const fields = fieldMap(definition);
2888
+ for (let i = state.path.length - 1; i > furthest; i--) {
2889
+ const field2 = fields.get(state.path[i]);
2890
+ if (field2 && !isEmptyValue(own(state.values, field2.id), field2)) {
2891
+ furthest = i;
2892
+ break;
2893
+ }
2894
+ }
2895
+ return furthest >= 0 ? state.path[furthest] : null;
2896
+ }
2897
+ function formProgress(engine) {
2898
+ const state = engine.getState();
2899
+ const furthest = furthestReached(state, engine.definition);
2900
+ const index = furthest ? state.path.indexOf(furthest) : -1;
2901
+ const fields = fieldMap(engine.definition);
2902
+ let fieldId = null;
2903
+ for (let i = index; i >= 0 && fieldId === null; i--) {
2904
+ if (fields.has(state.path[i])) fieldId = state.path[i];
2905
+ }
2906
+ return { step: index + 1, total: state.path.length, fieldId };
2907
+ }
2908
+
2308
2909
  // src/explain.ts
2309
2910
  function explain(source, blockId, options = {}) {
2310
2911
  const input = "getState" in source ? { definition: source.definition, values: source.getState().values, hidden: source.getState().hidden } : source;
@@ -2441,6 +3042,22 @@ function describeCondition(condition, def, fields = fieldMap(def)) {
2441
3042
  return `${left} ${op} ${right}`;
2442
3043
  }
2443
3044
 
2444
- export { CALCULATED_TYPE, COMPUTED_FIELD_TYPES, FORMULA_FUNCTIONS, HIGHLIGHT_NOTE_MESSAGE, MAX_CALCULATED_DECIMALS, MAX_FORMULA_ITEMS, MAX_FORMULA_LENGTH, MAX_FORM_FORMULA_LENGTH, MAX_HIDDEN_STRING_LENGTH, MAX_HIGHLIGHT_NOTE_LENGTH, MAX_HIGHLIGHT_QUOTE_LENGTH, MAX_SETTLE_ITEMS, MAX_TABLE_CELL_LENGTH, MAX_TABLE_COLUMNS, MAX_TIMESTAMP_SECONDS, REQUIRED_MESSAGE, STRUCTURED_FIELD_TYPES, calculatedProps, calculatedValue, canonicalJson, choiceOptions, coerceHidden, coerceHiddenValue, computePath, createForm, dateLimits, defineForm, describeCondition, evaluate, evaluateFormula, explain, f, fingerprint, formatCalculated, formatTimestamp, formulaInput, formulaNumber, formulaRefs, getFieldType, isEmptyValue, isFormEvent, isIsoDate, isJson, isSensitive, listFieldTypes, listLimits, normalizeValue, numberLimits, parseForm, parseFormula, parseTimestamp, ratingDisplay, recall, registerFieldType, renameFormulaKey, replay, ruleVisible, scaleLabels, steps, summarizeValue, tableColumns, tableLimits, validateField, when };
3045
+ // src/carry.ts
3046
+ function carryAnswers(from, to, answers, extra = {}) {
3047
+ const byKey = /* @__PURE__ */ new Map();
3048
+ for (const block of from.blocks) {
3049
+ if (block.kind === "field" && answers[block.id] !== void 0) byKey.set(block.key, answers[block.id]);
3050
+ }
3051
+ for (const [key, value] of Object.entries(extra)) {
3052
+ if (value !== null && value !== void 0 && !byKey.has(key)) byKey.set(key, value);
3053
+ }
3054
+ const out = {};
3055
+ for (const block of to.blocks) {
3056
+ if (block.kind === "field" && byKey.has(block.key)) out[block.id] = byKey.get(block.key);
3057
+ }
3058
+ return out;
3059
+ }
3060
+
3061
+ export { CALCULATED_TYPE, CHOICE_FIELD_TYPES, COMPUTED_FIELD_TYPES, DEFAULT_PHOTO_FILES, DEFAULT_PHOTO_SIZE_MB, FORMULA_FUNCTIONS, HIGHLIGHT_NOTE_MESSAGE, MAX_CALCULATED_DECIMALS, MAX_FORMULA_ITEMS, MAX_FORMULA_LENGTH, MAX_FORM_FORMULA_LENGTH, MAX_HIDDEN_STRING_LENGTH, MAX_HIGHLIGHT_NOTE_LENGTH, MAX_HIGHLIGHT_QUOTE_LENGTH, MAX_PHOTO_FILES, MAX_PHOTO_ID_LENGTH, MAX_PHOTO_NAME_LENGTH, MAX_PHOTO_SIZE_MB, MAX_PREFILL_PARAM_LENGTH, MAX_SETTLE_ITEMS, MAX_TABLE_CELL_LENGTH, MAX_TABLE_COLUMNS, MAX_TIMESTAMP_SECONDS, OPTION_LIMITS, OTHER_MAX_LENGTH, PHOTO_FORMATS, PHOTO_TYPE, REQUIRED_MESSAGE, SEARCHABLE_OPTION_COUNT, STRUCTURED_FIELD_TYPES, UPLOAD_FIELD_TYPES, answerSnapshots, answersFromSnapshots, answersOnPath, calculatedProps, calculatedValue, canonicalJson, carryAnswers, choiceOptions, clearFilteredAnswers, coerceHidden, coerceHiddenValue, computePath, createForm, dateLimits, defineForm, describeCondition, evaluate, evaluateFormula, explain, f, filteredChoiceError, fingerprint, formProgress, formatCalculated, formatTimestamp, formulaInput, formulaNumber, formulaRefs, furthestReached, getFieldType, hasOptionFilters, hiddenByPrefill, isEmptyValue, isFormEvent, isHttpsUrl, isIsoDate, isJson, isOtherValue, isPhotoFormat, isPhotoRef, isPrefillParam, isSearchable, isSensitive, listFieldTypes, listLimits, normalizeValue, numberLimits, optionFilter, optionJson, optionMatches, optionsSource, otherOption, parseForm, parseFormula, parseTimestamp, photoCountMessage, photoFormatList, photoLimits, photoRefs, photoSizeMessage, photoTypeMessage, prefillFromLink, prefillSetting, ratingDisplay, readOption, recall, registerFieldType, renameFormulaKey, replay, respondentPath, ruleVisible, scaleLabels, searchOptions, steps, summarizeValue, tableColumns, tableLimits, validateField, visibleOptions, when, withHostOptions };
2445
3062
  //# sourceMappingURL=index.js.map
2446
3063
  //# sourceMappingURL=index.js.map