@marktiderman/genesis-forms 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -417,7 +417,9 @@ var round = (n, decimals) => {
417
417
  return Object.is(r, -0) ? 0 : r;
418
418
  };
419
419
  function calculatedValue(result, field2) {
420
- const { format, decimals } = calculatedProps(field2);
420
+ return valueAs(result, calculatedProps(field2));
421
+ }
422
+ function valueAs(result, { format, decimals }) {
421
423
  if (result === null || Array.isArray(result)) return void 0;
422
424
  if (format === "number" || format === "percent") {
423
425
  const n = formulaNumber(result);
@@ -444,19 +446,6 @@ var extract = (value, column) => column !== void 0 ? value.flatMap((row) => {
444
446
  const cell = row[column];
445
447
  return typeof cell === "string" || typeof cell === "number" ? [cell] : [];
446
448
  }) : value.flatMap((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? [v] : []);
447
- var extracted = /* @__PURE__ */ new WeakMap();
448
- function cachedList(value, column) {
449
- let byColumn = extracted.get(value);
450
- if (!byColumn) extracted.set(value, byColumn = /* @__PURE__ */ new Map());
451
- const key = column ?? "";
452
- let list = byColumn.get(key);
453
- if (!list) byColumn.set(key, list = ownFormulaList(extract(value, column)));
454
- return list;
455
- }
456
- function readAnswer(value, column) {
457
- if (Array.isArray(value)) return cachedList(value, column);
458
- return column !== void 0 ? null : formulaInput(value);
459
- }
460
449
  function formulaInput(value, column) {
461
450
  if (value === void 0 || value === null) return null;
462
451
  if (Array.isArray(value) && value.length > MAX_FORMULA_ITEMS) return null;
@@ -475,9 +464,19 @@ function refNodes(node, out = []) {
475
464
  else if (node.kind === "call") for (const a of node.args) refNodes(a, out);
476
465
  return out;
477
466
  }
467
+ function formulaSize(node) {
468
+ if (node.kind === "unary") return 1 + formulaSize(node.arg);
469
+ if (node.kind === "binary") return 1 + formulaSize(node.left) + formulaSize(node.right);
470
+ if (node.kind === "if") return 1 + formulaSize(node.test) + formulaSize(node.then) + formulaSize(node.else);
471
+ if (node.kind === "call") return node.args.reduce((n, a) => n + formulaSize(a), 1);
472
+ return 1;
473
+ }
474
+ function spend(budget, amount) {
475
+ return;
476
+ }
478
477
  var UNREAD = /* @__PURE__ */ Symbol("unread");
479
478
  var OVER = /* @__PURE__ */ Symbol("over");
480
- function calculator(definition) {
479
+ function calculator(definition, work) {
481
480
  const compiled = [];
482
481
  const idByKey = /* @__PURE__ */ new Map();
483
482
  for (const block of definition.blocks) {
@@ -486,7 +485,8 @@ function calculator(definition) {
486
485
  }
487
486
  for (const block of definition.blocks) {
488
487
  if (block.kind !== "field" || !isCalculated(block)) continue;
489
- const parsed = parseFormula(calculatedProps(block).formula);
488
+ const props2 = calculatedProps(block);
489
+ const parsed = parseFormula(props2.formula);
490
490
  const reads = [];
491
491
  const slots = /* @__PURE__ */ new Map();
492
492
  const byKey = /* @__PURE__ */ new Map();
@@ -500,33 +500,64 @@ function calculator(definition) {
500
500
  }
501
501
  slots.set(ref, slot);
502
502
  }
503
- compiled.push({ field: block, ast: parsed.ok ? parsed.ast : null, reads, slots, last: null });
503
+ compiled.push({ field: block, props: props2, ast: parsed.ok ? parsed.ast : null, size: parsed.ok ? formulaSize(parsed.ast) : 0, reads, slots, last: null });
504
504
  }
505
+ const extracted = /* @__PURE__ */ new WeakMap();
506
+ const cachedList = (value, column) => {
507
+ let byColumn = extracted.get(value);
508
+ if (!byColumn) extracted.set(value, byColumn = /* @__PURE__ */ new Map());
509
+ const key = column ?? "";
510
+ let list = byColumn.get(key);
511
+ if (!list) {
512
+ byColumn.set(key, list = ownFormulaList(extract(value, column)));
513
+ }
514
+ return list;
515
+ };
516
+ const readAnswer = (value, column) => {
517
+ if (Array.isArray(value)) return cachedList(value, column);
518
+ return column !== void 0 ? null : formulaInput(value);
519
+ };
520
+ const forget = (value) => {
521
+ if (!value || typeof value !== "object") return;
522
+ if (Array.isArray(value)) extracted.delete(value);
523
+ for (const entry of compiled) if (entry.last?.inputs.includes(value)) entry.last = null;
524
+ };
505
525
  const withCalculated = (values, path) => {
506
526
  if (!compiled.length) return values;
507
527
  const visible = new Set(path);
508
528
  let next = values;
529
+ let copied = false;
509
530
  let budget = MAX_SETTLE_ITEMS;
510
531
  const within = /* @__PURE__ */ new Map();
511
- const input = ({ id, key }) => {
532
+ const input = ({ id }) => {
512
533
  if (id === void 0 || !visible.has(id) || !Object.prototype.hasOwnProperty.call(next, id)) return UNREAD;
513
534
  const value = next[id];
514
535
  if (!Array.isArray(value)) return value;
515
- let ok = within.get(key);
536
+ let ok = within.get(id);
516
537
  if (ok === void 0) {
517
538
  ok = value.length <= MAX_FORMULA_ITEMS && value.length <= budget;
518
539
  if (ok) budget -= value.length;
519
- within.set(key, ok);
540
+ within.set(id, ok);
520
541
  }
521
542
  return ok ? value : OVER;
522
543
  };
523
544
  for (const entry of compiled) {
524
545
  const { field: field2, ast } = entry;
525
- const inputs = entry.reads.map(input);
546
+ const { reads } = entry;
547
+ const inputs = new Array(reads.length);
548
+ let over = false;
549
+ let same = entry.last !== null;
550
+ for (let i = 0; i < reads.length; i++) {
551
+ const v = input(reads[i]);
552
+ inputs[i] = v;
553
+ if (v === OVER) over = true;
554
+ if (same && entry.last.inputs[i] !== v) same = false;
555
+ }
526
556
  let value;
527
- if (entry.last && entry.last.inputs.length === inputs.length && entry.last.inputs.every((v, i) => v === inputs[i])) {
557
+ if (same) {
528
558
  value = entry.last.value;
529
559
  } else {
560
+ spend(work, entry.size);
530
561
  const resolved = new Array(inputs.length);
531
562
  const read = (ref) => {
532
563
  const slot = entry.slots.get(ref);
@@ -534,19 +565,25 @@ function calculator(definition) {
534
565
  let v = resolved[slot];
535
566
  if (v === void 0) {
536
567
  const raw = inputs[slot];
537
- v = resolved[slot] = raw === UNREAD ? null : readAnswer(raw, entry.reads[slot].column);
568
+ v = resolved[slot] = raw === UNREAD ? null : readAnswer(raw, reads[slot].column);
538
569
  }
539
570
  return v;
540
571
  };
541
- value = ast && !inputs.includes(OVER) ? calculatedValue(evaluateFormula(ast, read), field2) : void 0;
572
+ value = ast && !over ? valueAs(evaluateFormula(ast, read), entry.props) : void 0;
542
573
  entry.last = { inputs, value };
543
574
  }
544
575
  const current = Object.prototype.hasOwnProperty.call(next, field2.id) ? next[field2.id] : void 0;
545
- if (current !== value) next = { ...next, [field2.id]: value };
576
+ if (current !== value) {
577
+ if (!copied) {
578
+ next = { ...next };
579
+ copied = true;
580
+ }
581
+ next[field2.id] = value;
582
+ }
546
583
  }
547
584
  return next;
548
585
  };
549
- return { active: compiled.length > 0, withCalculated };
586
+ return { active: compiled.length > 0, ids: new Set(compiled.map((c) => c.field.id)), withCalculated, forget };
550
587
  }
551
588
  var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
552
589
  var calculatedFieldType = {
@@ -798,6 +835,81 @@ var structuredFieldTypes = [
798
835
  }
799
836
  ];
800
837
 
838
+ // src/photo.ts
839
+ var PHOTO_TYPE = "photo";
840
+ var UPLOAD_FIELD_TYPES = [PHOTO_TYPE];
841
+ var PHOTO_FORMATS = {
842
+ jpeg: ["image/jpeg"],
843
+ png: ["image/png"],
844
+ heic: ["image/heic", "image/heif"],
845
+ webp: ["image/webp"]
846
+ };
847
+ var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
848
+ var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
849
+ var DEFAULT_PHOTO_FILES = 3;
850
+ var MAX_PHOTO_FILES = 5;
851
+ var DEFAULT_PHOTO_SIZE_MB = 10;
852
+ var MAX_PHOTO_SIZE_MB = 25;
853
+ var MAX_PHOTO_ID_LENGTH = 200;
854
+ var MAX_PHOTO_NAME_LENGTH = 255;
855
+ var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
856
+ var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
857
+ function photoLimits(field2) {
858
+ const maxFiles = wholeIn(field2.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field2.props.maxFiles : DEFAULT_PHOTO_FILES;
859
+ const maxSizeMb = wholeIn(field2.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field2.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
860
+ const raw = field2.props?.formats;
861
+ const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f2) => raw.includes(f2)) : [];
862
+ const formats = picked.length ? picked : [...ALL_FORMATS];
863
+ return {
864
+ maxFiles,
865
+ maxSizeMb,
866
+ maxBytes: maxSizeMb * 1024 * 1024,
867
+ formats,
868
+ mimeTypes: formats.flatMap((f2) => PHOTO_FORMATS[f2])
869
+ };
870
+ }
871
+ function isPhotoRef(v) {
872
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
873
+ const r = v;
874
+ 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;
875
+ }
876
+ function photoRefs(value) {
877
+ if (!Array.isArray(value)) return [];
878
+ const refs = [];
879
+ for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
880
+ return refs;
881
+ }
882
+ var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
883
+ function photoFormatList(formats) {
884
+ const names = formats.map((f2) => FORMAT_NAMES[f2]);
885
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
886
+ }
887
+ var photoTypeMessage = (field2) => `Use a ${photoFormatList(photoLimits(field2).formats)} photo.`;
888
+ var photoSizeMessage = (field2) => `Each photo must be ${photoLimits(field2).maxSizeMb} MB or smaller.`;
889
+ var photoCountMessage = (field2) => `Add at most ${plural2(photoLimits(field2).maxFiles, "photo")}.`;
890
+ var photoFieldType = {
891
+ type: PHOTO_TYPE,
892
+ label: "Photo",
893
+ category: "media",
894
+ value: { json: true },
895
+ operators: ["answered", "empty"],
896
+ a11y: { semantics: "group" },
897
+ isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
898
+ validate: (value, field2) => {
899
+ if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
900
+ const limits = photoLimits(field2);
901
+ if (value.length > limits.maxFiles) return photoCountMessage(field2);
902
+ const refs = value;
903
+ if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
904
+ if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field2);
905
+ if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field2);
906
+ return null;
907
+ },
908
+ normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
909
+ summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
910
+ samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
911
+ };
912
+
801
913
  // src/fields.ts
802
914
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
803
915
  var globalScope = globalThis;
@@ -840,6 +952,20 @@ function summarizeValue(value, field2) {
840
952
  if (Array.isArray(value)) return value.join(", ");
841
953
  return String(value);
842
954
  }
955
+ var OTHER_MAX_LENGTH = 200;
956
+ function otherOption(field2) {
957
+ const other = field2.props?.other;
958
+ if (other === true) return { label: "Other" };
959
+ if (other && typeof other === "object" && !Array.isArray(other)) {
960
+ const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
961
+ return { label };
962
+ }
963
+ return null;
964
+ }
965
+ function isOtherValue(field2, value) {
966
+ return otherOption(field2) !== null && typeof value === "string" && !choiceOptions(field2).some((o) => o.value === value);
967
+ }
968
+ var typedOther = (field2, value) => isOtherValue(field2, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
843
969
  function choiceOptions(field2) {
844
970
  const raw = field2.props?.options;
845
971
  if (!Array.isArray(raw)) return [];
@@ -847,6 +973,42 @@ function choiceOptions(field2) {
847
973
  (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
848
974
  );
849
975
  }
976
+ function optionsSource(field2) {
977
+ const raw = field2.props?.optionsFrom;
978
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
979
+ return raw.source === "host" && typeof raw.key === "string" && raw.key ? { source: "host", key: raw.key } : void 0;
980
+ }
981
+ function hostList(hostOptions, key) {
982
+ if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
983
+ const list = hostOptions[key];
984
+ if (!Array.isArray(list)) return [];
985
+ const seen = /* @__PURE__ */ new Set();
986
+ const out = [];
987
+ for (const o of list) {
988
+ if (!o || typeof o !== "object" || Array.isArray(o)) continue;
989
+ const { value, label } = o;
990
+ if (typeof value !== "string" || !value || seen.has(value)) continue;
991
+ seen.add(value);
992
+ out.push(typeof label === "string" ? { value, label } : { value });
993
+ }
994
+ return out;
995
+ }
996
+ function withHostOptions(definition, hostOptions) {
997
+ let changed = false;
998
+ const blocks = definition.blocks.map((block) => {
999
+ if (block.kind !== "field") return block;
1000
+ const from = optionsSource(block);
1001
+ if (!from) return block;
1002
+ const own2 = choiceOptions(block);
1003
+ const taken = new Set(own2.map((o) => o.value));
1004
+ const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
1005
+ if (!added.length) return block;
1006
+ changed = true;
1007
+ const options = [...own2, ...added].map((o) => o.label === void 0 ? { value: o.value } : { value: o.value, label: o.label });
1008
+ return { ...block, props: { ...block.props, options } };
1009
+ });
1010
+ return changed ? { ...definition, blocks } : definition;
1011
+ }
850
1012
  function optionLabel(field2, value) {
851
1013
  const option = choiceOptions(field2).find((o) => o.value === value);
852
1014
  return option?.label ?? value;
@@ -965,7 +1127,7 @@ var builtins = [
965
1127
  value: { json: true },
966
1128
  operators: ["eq", "neq", "in", "answered", "empty"],
967
1129
  a11y: { semantics: "group" },
968
- validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
1130
+ validate: (value, field2) => typeof value === "string" && (choiceOptions(field2).some((o) => o.value === value) || typedOther(field2, value)) ? null : "Choose one of the options.",
969
1131
  summarize: (value, field2) => optionLabel(field2, String(value))
970
1132
  },
971
1133
  {
@@ -977,7 +1139,8 @@ var builtins = [
977
1139
  a11y: { semantics: "group" },
978
1140
  validate: (value, field2) => {
979
1141
  const allowed = new Set(choiceOptions(field2).map((o) => o.value));
980
- 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.";
1142
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1143
+ 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.";
981
1144
  },
982
1145
  summarize: (value, field2) => Array.isArray(value) ? value.map((v) => optionLabel(field2, String(v))).join(", ") : String(value)
983
1146
  },
@@ -998,7 +1161,7 @@ var builtins = [
998
1161
  value: { json: true },
999
1162
  operators: ["eq", "neq", "in", "answered", "empty"],
1000
1163
  a11y: { semantics: "control" },
1001
- validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
1164
+ validate: (value, field2) => typeof value === "string" && (choiceOptions(field2).some((o) => o.value === value) || typedOther(field2, value)) ? null : "Choose one of the options.",
1002
1165
  summarize: (value, field2) => optionLabel(field2, String(value))
1003
1166
  },
1004
1167
  {
@@ -1108,7 +1271,7 @@ function dateLimits(field2) {
1108
1271
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
1109
1272
  return out;
1110
1273
  }
1111
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
1274
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
1112
1275
 
1113
1276
  // src/logic.ts
1114
1277
  function own(record, key) {
@@ -1181,8 +1344,8 @@ function rulesAllow(def, blockId, scope) {
1181
1344
  }
1182
1345
  function walkPath(def, scope) {
1183
1346
  const list = steps(def);
1184
- const index = new Map(list.map((b, i2) => [b.id, i2]));
1185
- const position = new Map(def.blocks.map((b, i2) => [b.id, i2]));
1347
+ let index;
1348
+ let position;
1186
1349
  const jumps = (def.logic ?? []).filter(
1187
1350
  (r) => r.effect === "jump"
1188
1351
  );
@@ -1206,6 +1369,7 @@ function walkPath(def, scope) {
1206
1369
  ending = jump.to.ending;
1207
1370
  break;
1208
1371
  }
1372
+ index ??= new Map(list.map((b, at) => [b.id, at]));
1209
1373
  const target = index.get(jump.to);
1210
1374
  if (target !== void 0 && target > i) {
1211
1375
  i = nextVisible(target);
@@ -1215,6 +1379,7 @@ function walkPath(def, scope) {
1215
1379
  i = nextVisible(i + 1);
1216
1380
  }
1217
1381
  const scopeAt = (blockId, inclusive = false) => {
1382
+ position ??= new Map(def.blocks.map((b, i2) => [b.id, i2]));
1218
1383
  const at = position.get(blockId) ?? -1;
1219
1384
  const values = /* @__PURE__ */ Object.create(null);
1220
1385
  for (const id of path) {
@@ -1406,6 +1571,22 @@ function parseForm(input) {
1406
1571
  }
1407
1572
  }
1408
1573
  if (type === "consent") optionalText(props2.text, `${path}.text`);
1574
+ if (type === PHOTO_TYPE) {
1575
+ if (props2.maxFiles !== void 0 && !(isInt(props2.maxFiles) && props2.maxFiles >= 1 && props2.maxFiles <= MAX_PHOTO_FILES)) {
1576
+ issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
1577
+ }
1578
+ if (props2.maxSizeMb !== void 0 && !(isInt(props2.maxSizeMb) && props2.maxSizeMb >= 1 && props2.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
1579
+ issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
1580
+ }
1581
+ if (props2.formats !== void 0) {
1582
+ const formats = props2.formats;
1583
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
1584
+ issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
1585
+ } else if (new Set(formats).size !== formats.length) {
1586
+ issue(`${path}.formats`, "Name each format once.");
1587
+ }
1588
+ }
1589
+ }
1409
1590
  };
1410
1591
  if (!isObject2(input)) {
1411
1592
  issue("", "A form definition must be an object.");
@@ -1426,6 +1607,7 @@ function parseForm(input) {
1426
1607
  else {
1427
1608
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
1428
1609
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
1610
+ optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
1429
1611
  }
1430
1612
  }
1431
1613
  if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
@@ -1466,6 +1648,14 @@ function parseForm(input) {
1466
1648
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
1467
1649
  }
1468
1650
  if (block.props !== void 0) {
1651
+ const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
1652
+ if (from !== void 0) {
1653
+ if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
1654
+ issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
1655
+ } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1656
+ warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
1657
+ }
1658
+ }
1469
1659
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1470
1660
  else if (block.props.options !== void 0) {
1471
1661
  const options = block.props.options;
@@ -1476,10 +1666,20 @@ function parseForm(input) {
1476
1666
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1477
1667
  else optionalText(o.label, `${at}.label`);
1478
1668
  });
1479
- choices.set(
1480
- block.id,
1481
- new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1482
- );
1669
+ if (from === void 0) {
1670
+ choices.set(
1671
+ block.id,
1672
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1673
+ );
1674
+ }
1675
+ }
1676
+ }
1677
+ const other = isObject2(block.props) ? block.props.other : void 0;
1678
+ if (other !== void 0) {
1679
+ if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
1680
+ issue(`${path}.props.other`, "Must be true, false or { label }.");
1681
+ } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1682
+ warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
1483
1683
  }
1484
1684
  }
1485
1685
  const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
@@ -1554,6 +1754,8 @@ function parseForm(input) {
1554
1754
  } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
1555
1755
  else if (targetType === "media-notes" || targetType === "text-highlights") {
1556
1756
  warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
1757
+ } else if (targetType === PHOTO_TYPE) {
1758
+ warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
1557
1759
  }
1558
1760
  }
1559
1761
  });
@@ -1773,9 +1975,15 @@ function field(type, key, options, props2) {
1773
1975
  return block;
1774
1976
  }
1775
1977
  var props = (all) => Object.fromEntries(Object.entries(all).filter((e) => e[1] !== void 0));
1776
- var choiceProps = (options) => ({
1777
- options: options.map((o) => o.label ? { value: o.value, label: o.label } : { value: o.value })
1778
- });
1978
+ var choiceProps = (o) => {
1979
+ const out = {};
1980
+ if (o.options || !o.optionsFrom) {
1981
+ out.options = (o.options ?? []).map((x) => x.label ? { value: x.value, label: x.label } : { value: x.value });
1982
+ }
1983
+ if (o.optionsFrom) out.optionsFrom = { source: o.optionsFrom.source, key: o.optionsFrom.key };
1984
+ if (o.other) out.other = o.other === true ? true : props({ label: o.other.label });
1985
+ return out;
1986
+ };
1779
1987
  var hiddenSpec = (type, o) => ({
1780
1988
  type,
1781
1989
  source: o.source ?? "url",
@@ -1799,11 +2007,11 @@ var f = {
1799
2007
  ),
1800
2008
  /** A 0-to-10 Net Promoter Score. The end labels default to "Not likely" and "Very likely" in the renderer. */
1801
2009
  nps: (key, o) => field("nps", key, o, props({ lowLabel: o.lowLabel || void 0, highLabel: o.highLabel || void 0 })),
1802
- singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o.options)),
1803
- multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o.options)),
2010
+ singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o)),
2011
+ multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o)),
1804
2012
  yesNo: (key, o) => field("yes-no", key, o),
1805
2013
  /** One option from a compact menu: for a long list (a country, a class), where single choice would be a wall of buttons. */
1806
- dropdown: (key, o) => field("dropdown", key, o, choiceProps(o.options)),
2014
+ dropdown: (key, o) => field("dropdown", key, o, choiceProps(o)),
1807
2015
  /** A number, optionally between `min` and `max` and on a `step` (from `min`, else 0): `step: 1` for whole numbers. */
1808
2016
  number: (key, o) => field("number", key, o, props({ min: o.min, max: o.max, step: o.step })),
1809
2017
  /** A calendar day, stored as `YYYY-MM-DD`, optionally between `min` and `max` (also `YYYY-MM-DD`). */
@@ -1855,6 +2063,17 @@ var f = {
1855
2063
  o,
1856
2064
  props({ text: o.text, maxHighlights: o.maxHighlights })
1857
2065
  ),
2066
+ /**
2067
+ * Up to a few photos (a picture of broken equipment). The answer holds the
2068
+ * references the host's upload returned, never the bytes; `formats` from
2069
+ * jpeg, png, heic and webp (default all four).
2070
+ */
2071
+ photo: (key, o) => field(
2072
+ "photo",
2073
+ key,
2074
+ o,
2075
+ props({ maxFiles: o.maxFiles, maxSizeMb: o.maxSizeMb, formats: o.formats ? [...o.formats] : void 0 })
2076
+ ),
1858
2077
  /** Any registered field type, including plugins. */
1859
2078
  custom: (type, key, o) => field(type, key, o, o.props),
1860
2079
  heading: (id, text) => ({ kind: "content", id, type: "heading", text }),
@@ -2026,10 +2245,20 @@ function validateField(field2, value, options = {}) {
2026
2245
  }
2027
2246
 
2028
2247
  // src/engine.ts
2248
+ function readsAny(node, ids) {
2249
+ if (Array.isArray(node)) return node.some((n) => readsAny(n, ids));
2250
+ if (!node || typeof node !== "object") return false;
2251
+ for (const [k, v] of Object.entries(node)) {
2252
+ if (k === "field" && typeof v === "string" && ids.has(v)) return true;
2253
+ if (typeof v === "object" && readsAny(v, ids)) return true;
2254
+ }
2255
+ return false;
2256
+ }
2029
2257
  function createForm(definition, options = {}) {
2030
2258
  return build(definition, options).engine;
2031
2259
  }
2032
- function build(definition, options, replaying = false) {
2260
+ function build(given, options, replaying = false, budget) {
2261
+ const definition = withHostOptions(given, options.hostOptions);
2033
2262
  const fields = fieldMap(definition);
2034
2263
  const byKey = new Map([...fields.values()].map((f2) => [f2.key, f2]));
2035
2264
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -2047,15 +2276,24 @@ function build(definition, options, replaying = false) {
2047
2276
  if (!declared.has(name)) initialHidden[name] = value;
2048
2277
  }
2049
2278
  Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
2050
- const calc = calculator(definition);
2279
+ const calc = calculator(definition, budget);
2280
+ const pathOf = (values, hidden) => {
2281
+ spend(budget, definition.blocks.length);
2282
+ return computePath(definition, scopeOf(values, hidden));
2283
+ };
2284
+ const logicReadsCalculated = calc.active && readsAny(definition.logic, calc.ids);
2051
2285
  const settle = (values, hidden) => {
2052
- let { path } = computePath(definition, scopeOf(values, hidden));
2286
+ let { path } = pathOf(values, hidden);
2053
2287
  if (!calc.active) return { values, path };
2288
+ if (!logicReadsCalculated) return { values: calc.withCalculated(values, path), path };
2054
2289
  for (let round2 = 0; round2 < 4; round2++) {
2055
2290
  const next = calc.withCalculated(values, path);
2056
2291
  if (next === values) break;
2057
2292
  values = next;
2058
- path = computePath(definition, scopeOf(values, hidden)).path;
2293
+ const again = pathOf(values, hidden).path;
2294
+ const same = again.length === path.length && again.every((id, i) => id === path[i]);
2295
+ path = again;
2296
+ if (same) break;
2059
2297
  }
2060
2298
  return { values, path };
2061
2299
  };
@@ -2066,6 +2304,7 @@ function build(definition, options, replaying = false) {
2066
2304
  path: first.path,
2067
2305
  current: first.path[0] ?? null,
2068
2306
  history: [],
2307
+ furthest: first.path[0] ?? null,
2069
2308
  errors: {},
2070
2309
  status: "in_progress",
2071
2310
  ending: null,
@@ -2097,14 +2336,30 @@ function build(definition, options, replaying = false) {
2097
2336
  return errors;
2098
2337
  };
2099
2338
  let unsettled = false;
2100
- const settleNow = () => {
2101
- if (!unsettled) return;
2102
- unsettled = false;
2103
- const { values, path } = settle(state.values, state.hidden);
2104
- state = { ...state, values, path };
2339
+ let formulasStale = false;
2340
+ const settleNow = (needValues = true) => {
2341
+ if (unsettled && calc.active && !logicReadsCalculated && !needValues) {
2342
+ unsettled = false;
2343
+ formulasStale = true;
2344
+ state = { ...state, path: pathOf(state.values, state.hidden).path };
2345
+ return;
2346
+ }
2347
+ if (unsettled) {
2348
+ unsettled = false;
2349
+ formulasStale = false;
2350
+ const { values, path } = settle(state.values, state.hidden);
2351
+ state = { ...state, values, path };
2352
+ } else if (formulasStale && needValues) {
2353
+ formulasStale = false;
2354
+ state = { ...state, values: calc.withCalculated(state.values, state.path) };
2355
+ }
2105
2356
  };
2106
2357
  const answer = (id, value, event) => {
2107
- if (state.status === "completed" || own(state.values, id) === value || isCalculated(fields.get(id))) return;
2358
+ if (state.status === "completed" || isCalculated(fields.get(id))) return;
2359
+ if (own(state.values, id) === value) {
2360
+ if (value === null || typeof value !== "object") return;
2361
+ calc.forget(value);
2362
+ }
2108
2363
  const errors = { ...state.errors };
2109
2364
  delete errors[id];
2110
2365
  if (batch) {
@@ -2150,7 +2405,8 @@ function build(definition, options, replaying = false) {
2150
2405
  emit({ ...state, events });
2151
2406
  return true;
2152
2407
  }
2153
- emit({ ...state, current: target, history: [...state.history, state.current], events });
2408
+ const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
2409
+ emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
2154
2410
  return true;
2155
2411
  },
2156
2412
  back() {
@@ -2170,7 +2426,7 @@ function build(definition, options, replaying = false) {
2170
2426
  emit({ ...state, errors, current: firstError, events });
2171
2427
  return false;
2172
2428
  }
2173
- const { ending } = computePath(definition, scopeOf(state.values, state.hidden));
2429
+ const { ending } = pathOf(state.values, state.hidden);
2174
2430
  emit({ ...state, errors: {}, status: "completed", ending, events });
2175
2431
  return true;
2176
2432
  },
@@ -2195,6 +2451,15 @@ function build(definition, options, replaying = false) {
2195
2451
  settleNow();
2196
2452
  batch = wasBatch;
2197
2453
  }
2454
+ const resumeAt = options.resumeAt;
2455
+ if (resumeAt && !replaying && state.path.includes(resumeAt)) {
2456
+ for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
2457
+ if (!engine.next()) {
2458
+ state = { ...state, errors: {} };
2459
+ break;
2460
+ }
2461
+ }
2462
+ }
2198
2463
  const apply = (event) => {
2199
2464
  if (event.type === "answer") {
2200
2465
  if (fields.has(event.field)) {
@@ -2202,10 +2467,11 @@ function build(definition, options, replaying = false) {
2202
2467
  answer(event.field, event.value, logged);
2203
2468
  }
2204
2469
  } else if (event.type === "next") {
2205
- settleNow();
2470
+ settleNow(false);
2471
+ if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
2206
2472
  engine.next();
2207
2473
  } else if (event.type === "back") {
2208
- settleNow();
2474
+ settleNow(false);
2209
2475
  engine.back();
2210
2476
  } else if (event.type === "submit") {
2211
2477
  settleNow();
@@ -2220,12 +2486,46 @@ function build(definition, options, replaying = false) {
2220
2486
  return { engine, apply, finish };
2221
2487
  }
2222
2488
  function replay(definition, events, options = {}) {
2223
- const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true);
2224
- for (const event of events) apply(event);
2225
- finish();
2489
+ return replayWithin(definition, events, options);
2490
+ }
2491
+ function replayWithin(definition, events, options, maxWork) {
2492
+ const budget = void 0 ;
2493
+ const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
2494
+ try {
2495
+ for (const event of events) apply(event);
2496
+ finish();
2497
+ } finally {
2498
+ }
2226
2499
  return engine;
2227
2500
  }
2228
2501
 
2502
+ // src/progress.ts
2503
+ function furthestReached(state, definition) {
2504
+ const at = (id) => id ? state.path.indexOf(id) : -1;
2505
+ let furthest = Math.max(at(state.current), at(state.furthest));
2506
+ for (const id of state.history) furthest = Math.max(furthest, at(id));
2507
+ const fields = fieldMap(definition);
2508
+ for (let i = state.path.length - 1; i > furthest; i--) {
2509
+ const field2 = fields.get(state.path[i]);
2510
+ if (field2 && !isEmptyValue(own(state.values, field2.id), field2)) {
2511
+ furthest = i;
2512
+ break;
2513
+ }
2514
+ }
2515
+ return furthest >= 0 ? state.path[furthest] : null;
2516
+ }
2517
+ function formProgress(engine) {
2518
+ const state = engine.getState();
2519
+ const furthest = furthestReached(state, engine.definition);
2520
+ const index = furthest ? state.path.indexOf(furthest) : -1;
2521
+ const fields = fieldMap(engine.definition);
2522
+ let fieldId = null;
2523
+ for (let i = index; i >= 0 && fieldId === null; i--) {
2524
+ if (fields.has(state.path[i])) fieldId = state.path[i];
2525
+ }
2526
+ return { step: index + 1, total: state.path.length, fieldId };
2527
+ }
2528
+
2229
2529
  // src/explain.ts
2230
2530
  function explain(source, blockId, options = {}) {
2231
2531
  const input = "getState" in source ? { definition: source.definition, values: source.getState().values, hidden: source.getState().hidden } : source;
@@ -2364,6 +2664,8 @@ function describeCondition(condition, def, fields = fieldMap(def)) {
2364
2664
 
2365
2665
  exports.CALCULATED_TYPE = CALCULATED_TYPE;
2366
2666
  exports.COMPUTED_FIELD_TYPES = COMPUTED_FIELD_TYPES;
2667
+ exports.DEFAULT_PHOTO_FILES = DEFAULT_PHOTO_FILES;
2668
+ exports.DEFAULT_PHOTO_SIZE_MB = DEFAULT_PHOTO_SIZE_MB;
2367
2669
  exports.FORMULA_FUNCTIONS = FORMULA_FUNCTIONS;
2368
2670
  exports.HIGHLIGHT_NOTE_MESSAGE = HIGHLIGHT_NOTE_MESSAGE;
2369
2671
  exports.MAX_CALCULATED_DECIMALS = MAX_CALCULATED_DECIMALS;
@@ -2373,12 +2675,20 @@ exports.MAX_FORM_FORMULA_LENGTH = MAX_FORM_FORMULA_LENGTH;
2373
2675
  exports.MAX_HIDDEN_STRING_LENGTH = MAX_HIDDEN_STRING_LENGTH;
2374
2676
  exports.MAX_HIGHLIGHT_NOTE_LENGTH = MAX_HIGHLIGHT_NOTE_LENGTH;
2375
2677
  exports.MAX_HIGHLIGHT_QUOTE_LENGTH = MAX_HIGHLIGHT_QUOTE_LENGTH;
2678
+ exports.MAX_PHOTO_FILES = MAX_PHOTO_FILES;
2679
+ exports.MAX_PHOTO_ID_LENGTH = MAX_PHOTO_ID_LENGTH;
2680
+ exports.MAX_PHOTO_NAME_LENGTH = MAX_PHOTO_NAME_LENGTH;
2681
+ exports.MAX_PHOTO_SIZE_MB = MAX_PHOTO_SIZE_MB;
2376
2682
  exports.MAX_SETTLE_ITEMS = MAX_SETTLE_ITEMS;
2377
2683
  exports.MAX_TABLE_CELL_LENGTH = MAX_TABLE_CELL_LENGTH;
2378
2684
  exports.MAX_TABLE_COLUMNS = MAX_TABLE_COLUMNS;
2379
2685
  exports.MAX_TIMESTAMP_SECONDS = MAX_TIMESTAMP_SECONDS;
2686
+ exports.OTHER_MAX_LENGTH = OTHER_MAX_LENGTH;
2687
+ exports.PHOTO_FORMATS = PHOTO_FORMATS;
2688
+ exports.PHOTO_TYPE = PHOTO_TYPE;
2380
2689
  exports.REQUIRED_MESSAGE = REQUIRED_MESSAGE;
2381
2690
  exports.STRUCTURED_FIELD_TYPES = STRUCTURED_FIELD_TYPES;
2691
+ exports.UPLOAD_FIELD_TYPES = UPLOAD_FIELD_TYPES;
2382
2692
  exports.calculatedProps = calculatedProps;
2383
2693
  exports.calculatedValue = calculatedValue;
2384
2694
  exports.canonicalJson = canonicalJson;
@@ -2395,24 +2705,37 @@ exports.evaluateFormula = evaluateFormula;
2395
2705
  exports.explain = explain;
2396
2706
  exports.f = f;
2397
2707
  exports.fingerprint = fingerprint;
2708
+ exports.formProgress = formProgress;
2398
2709
  exports.formatCalculated = formatCalculated;
2399
2710
  exports.formatTimestamp = formatTimestamp;
2400
2711
  exports.formulaInput = formulaInput;
2401
2712
  exports.formulaNumber = formulaNumber;
2402
2713
  exports.formulaRefs = formulaRefs;
2714
+ exports.furthestReached = furthestReached;
2403
2715
  exports.getFieldType = getFieldType;
2404
2716
  exports.isEmptyValue = isEmptyValue;
2405
2717
  exports.isFormEvent = isFormEvent;
2406
2718
  exports.isIsoDate = isIsoDate;
2407
2719
  exports.isJson = isJson;
2720
+ exports.isOtherValue = isOtherValue;
2721
+ exports.isPhotoFormat = isPhotoFormat;
2722
+ exports.isPhotoRef = isPhotoRef;
2408
2723
  exports.isSensitive = isSensitive;
2409
2724
  exports.listFieldTypes = listFieldTypes;
2410
2725
  exports.listLimits = listLimits;
2411
2726
  exports.normalizeValue = normalizeValue;
2412
2727
  exports.numberLimits = numberLimits;
2728
+ exports.optionsSource = optionsSource;
2729
+ exports.otherOption = otherOption;
2413
2730
  exports.parseForm = parseForm;
2414
2731
  exports.parseFormula = parseFormula;
2415
2732
  exports.parseTimestamp = parseTimestamp;
2733
+ exports.photoCountMessage = photoCountMessage;
2734
+ exports.photoFormatList = photoFormatList;
2735
+ exports.photoLimits = photoLimits;
2736
+ exports.photoRefs = photoRefs;
2737
+ exports.photoSizeMessage = photoSizeMessage;
2738
+ exports.photoTypeMessage = photoTypeMessage;
2416
2739
  exports.ratingDisplay = ratingDisplay;
2417
2740
  exports.recall = recall;
2418
2741
  exports.registerFieldType = registerFieldType;
@@ -2426,5 +2749,6 @@ exports.tableColumns = tableColumns;
2426
2749
  exports.tableLimits = tableLimits;
2427
2750
  exports.validateField = validateField;
2428
2751
  exports.when = when;
2752
+ exports.withHostOptions = withHostOptions;
2429
2753
  //# sourceMappingURL=index.cjs.map
2430
2754
  //# sourceMappingURL=index.cjs.map