@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.js CHANGED
@@ -415,7 +415,9 @@ var round = (n, decimals) => {
415
415
  return Object.is(r, -0) ? 0 : r;
416
416
  };
417
417
  function calculatedValue(result, field2) {
418
- const { format, decimals } = calculatedProps(field2);
418
+ return valueAs(result, calculatedProps(field2));
419
+ }
420
+ function valueAs(result, { format, decimals }) {
419
421
  if (result === null || Array.isArray(result)) return void 0;
420
422
  if (format === "number" || format === "percent") {
421
423
  const n = formulaNumber(result);
@@ -442,19 +444,6 @@ var extract = (value, column) => column !== void 0 ? value.flatMap((row) => {
442
444
  const cell = row[column];
443
445
  return typeof cell === "string" || typeof cell === "number" ? [cell] : [];
444
446
  }) : value.flatMap((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? [v] : []);
445
- var extracted = /* @__PURE__ */ new WeakMap();
446
- function cachedList(value, column) {
447
- let byColumn = extracted.get(value);
448
- if (!byColumn) extracted.set(value, byColumn = /* @__PURE__ */ new Map());
449
- const key = column ?? "";
450
- let list = byColumn.get(key);
451
- if (!list) byColumn.set(key, list = ownFormulaList(extract(value, column)));
452
- return list;
453
- }
454
- function readAnswer(value, column) {
455
- if (Array.isArray(value)) return cachedList(value, column);
456
- return column !== void 0 ? null : formulaInput(value);
457
- }
458
447
  function formulaInput(value, column) {
459
448
  if (value === void 0 || value === null) return null;
460
449
  if (Array.isArray(value) && value.length > MAX_FORMULA_ITEMS) return null;
@@ -473,9 +462,19 @@ function refNodes(node, out = []) {
473
462
  else if (node.kind === "call") for (const a of node.args) refNodes(a, out);
474
463
  return out;
475
464
  }
465
+ function formulaSize(node) {
466
+ if (node.kind === "unary") return 1 + formulaSize(node.arg);
467
+ if (node.kind === "binary") return 1 + formulaSize(node.left) + formulaSize(node.right);
468
+ if (node.kind === "if") return 1 + formulaSize(node.test) + formulaSize(node.then) + formulaSize(node.else);
469
+ if (node.kind === "call") return node.args.reduce((n, a) => n + formulaSize(a), 1);
470
+ return 1;
471
+ }
472
+ function spend(budget, amount) {
473
+ return;
474
+ }
476
475
  var UNREAD = /* @__PURE__ */ Symbol("unread");
477
476
  var OVER = /* @__PURE__ */ Symbol("over");
478
- function calculator(definition) {
477
+ function calculator(definition, work) {
479
478
  const compiled = [];
480
479
  const idByKey = /* @__PURE__ */ new Map();
481
480
  for (const block of definition.blocks) {
@@ -484,7 +483,8 @@ function calculator(definition) {
484
483
  }
485
484
  for (const block of definition.blocks) {
486
485
  if (block.kind !== "field" || !isCalculated(block)) continue;
487
- const parsed = parseFormula(calculatedProps(block).formula);
486
+ const props2 = calculatedProps(block);
487
+ const parsed = parseFormula(props2.formula);
488
488
  const reads = [];
489
489
  const slots = /* @__PURE__ */ new Map();
490
490
  const byKey = /* @__PURE__ */ new Map();
@@ -498,33 +498,64 @@ function calculator(definition) {
498
498
  }
499
499
  slots.set(ref, slot);
500
500
  }
501
- compiled.push({ field: block, ast: parsed.ok ? parsed.ast : null, reads, slots, last: null });
501
+ compiled.push({ field: block, props: props2, ast: parsed.ok ? parsed.ast : null, size: parsed.ok ? formulaSize(parsed.ast) : 0, reads, slots, last: null });
502
502
  }
503
+ const extracted = /* @__PURE__ */ new WeakMap();
504
+ const cachedList = (value, column) => {
505
+ let byColumn = extracted.get(value);
506
+ if (!byColumn) extracted.set(value, byColumn = /* @__PURE__ */ new Map());
507
+ const key = column ?? "";
508
+ let list = byColumn.get(key);
509
+ if (!list) {
510
+ byColumn.set(key, list = ownFormulaList(extract(value, column)));
511
+ }
512
+ return list;
513
+ };
514
+ const readAnswer = (value, column) => {
515
+ if (Array.isArray(value)) return cachedList(value, column);
516
+ return column !== void 0 ? null : formulaInput(value);
517
+ };
518
+ const forget = (value) => {
519
+ if (!value || typeof value !== "object") return;
520
+ if (Array.isArray(value)) extracted.delete(value);
521
+ for (const entry of compiled) if (entry.last?.inputs.includes(value)) entry.last = null;
522
+ };
503
523
  const withCalculated = (values, path) => {
504
524
  if (!compiled.length) return values;
505
525
  const visible = new Set(path);
506
526
  let next = values;
527
+ let copied = false;
507
528
  let budget = MAX_SETTLE_ITEMS;
508
529
  const within = /* @__PURE__ */ new Map();
509
- const input = ({ id, key }) => {
530
+ const input = ({ id }) => {
510
531
  if (id === void 0 || !visible.has(id) || !Object.prototype.hasOwnProperty.call(next, id)) return UNREAD;
511
532
  const value = next[id];
512
533
  if (!Array.isArray(value)) return value;
513
- let ok = within.get(key);
534
+ let ok = within.get(id);
514
535
  if (ok === void 0) {
515
536
  ok = value.length <= MAX_FORMULA_ITEMS && value.length <= budget;
516
537
  if (ok) budget -= value.length;
517
- within.set(key, ok);
538
+ within.set(id, ok);
518
539
  }
519
540
  return ok ? value : OVER;
520
541
  };
521
542
  for (const entry of compiled) {
522
543
  const { field: field2, ast } = entry;
523
- const inputs = entry.reads.map(input);
544
+ const { reads } = entry;
545
+ const inputs = new Array(reads.length);
546
+ let over = false;
547
+ let same = entry.last !== null;
548
+ for (let i = 0; i < reads.length; i++) {
549
+ const v = input(reads[i]);
550
+ inputs[i] = v;
551
+ if (v === OVER) over = true;
552
+ if (same && entry.last.inputs[i] !== v) same = false;
553
+ }
524
554
  let value;
525
- if (entry.last && entry.last.inputs.length === inputs.length && entry.last.inputs.every((v, i) => v === inputs[i])) {
555
+ if (same) {
526
556
  value = entry.last.value;
527
557
  } else {
558
+ spend(work, entry.size);
528
559
  const resolved = new Array(inputs.length);
529
560
  const read = (ref) => {
530
561
  const slot = entry.slots.get(ref);
@@ -532,19 +563,25 @@ function calculator(definition) {
532
563
  let v = resolved[slot];
533
564
  if (v === void 0) {
534
565
  const raw = inputs[slot];
535
- v = resolved[slot] = raw === UNREAD ? null : readAnswer(raw, entry.reads[slot].column);
566
+ v = resolved[slot] = raw === UNREAD ? null : readAnswer(raw, reads[slot].column);
536
567
  }
537
568
  return v;
538
569
  };
539
- value = ast && !inputs.includes(OVER) ? calculatedValue(evaluateFormula(ast, read), field2) : void 0;
570
+ value = ast && !over ? valueAs(evaluateFormula(ast, read), entry.props) : void 0;
540
571
  entry.last = { inputs, value };
541
572
  }
542
573
  const current = Object.prototype.hasOwnProperty.call(next, field2.id) ? next[field2.id] : void 0;
543
- if (current !== value) next = { ...next, [field2.id]: value };
574
+ if (current !== value) {
575
+ if (!copied) {
576
+ next = { ...next };
577
+ copied = true;
578
+ }
579
+ next[field2.id] = value;
580
+ }
544
581
  }
545
582
  return next;
546
583
  };
547
- return { active: compiled.length > 0, withCalculated };
584
+ return { active: compiled.length > 0, ids: new Set(compiled.map((c) => c.field.id)), withCalculated, forget };
548
585
  }
549
586
  var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
550
587
  var calculatedFieldType = {
@@ -796,6 +833,81 @@ var structuredFieldTypes = [
796
833
  }
797
834
  ];
798
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
+
799
911
  // src/fields.ts
800
912
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
801
913
  var globalScope = globalThis;
@@ -838,6 +950,20 @@ function summarizeValue(value, field2) {
838
950
  if (Array.isArray(value)) return value.join(", ");
839
951
  return String(value);
840
952
  }
953
+ var OTHER_MAX_LENGTH = 200;
954
+ function otherOption(field2) {
955
+ const other = field2.props?.other;
956
+ if (other === true) return { label: "Other" };
957
+ if (other && typeof other === "object" && !Array.isArray(other)) {
958
+ const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
959
+ return { label };
960
+ }
961
+ return null;
962
+ }
963
+ function isOtherValue(field2, value) {
964
+ return otherOption(field2) !== null && typeof value === "string" && !choiceOptions(field2).some((o) => o.value === value);
965
+ }
966
+ var typedOther = (field2, value) => isOtherValue(field2, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
841
967
  function choiceOptions(field2) {
842
968
  const raw = field2.props?.options;
843
969
  if (!Array.isArray(raw)) return [];
@@ -845,6 +971,42 @@ function choiceOptions(field2) {
845
971
  (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
846
972
  );
847
973
  }
974
+ function optionsSource(field2) {
975
+ const raw = field2.props?.optionsFrom;
976
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
977
+ return raw.source === "host" && typeof raw.key === "string" && raw.key ? { source: "host", key: raw.key } : void 0;
978
+ }
979
+ function hostList(hostOptions, key) {
980
+ if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
981
+ const list = hostOptions[key];
982
+ if (!Array.isArray(list)) return [];
983
+ const seen = /* @__PURE__ */ new Set();
984
+ const out = [];
985
+ for (const o of list) {
986
+ if (!o || typeof o !== "object" || Array.isArray(o)) continue;
987
+ const { value, label } = o;
988
+ if (typeof value !== "string" || !value || seen.has(value)) continue;
989
+ seen.add(value);
990
+ out.push(typeof label === "string" ? { value, label } : { value });
991
+ }
992
+ return out;
993
+ }
994
+ function withHostOptions(definition, hostOptions) {
995
+ let changed = false;
996
+ const blocks = definition.blocks.map((block) => {
997
+ if (block.kind !== "field") return block;
998
+ const from = optionsSource(block);
999
+ if (!from) return block;
1000
+ const own2 = choiceOptions(block);
1001
+ const taken = new Set(own2.map((o) => o.value));
1002
+ const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
1003
+ if (!added.length) return block;
1004
+ changed = true;
1005
+ const options = [...own2, ...added].map((o) => o.label === void 0 ? { value: o.value } : { value: o.value, label: o.label });
1006
+ return { ...block, props: { ...block.props, options } };
1007
+ });
1008
+ return changed ? { ...definition, blocks } : definition;
1009
+ }
848
1010
  function optionLabel(field2, value) {
849
1011
  const option = choiceOptions(field2).find((o) => o.value === value);
850
1012
  return option?.label ?? value;
@@ -963,7 +1125,7 @@ var builtins = [
963
1125
  value: { json: true },
964
1126
  operators: ["eq", "neq", "in", "answered", "empty"],
965
1127
  a11y: { semantics: "group" },
966
- validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
1128
+ validate: (value, field2) => typeof value === "string" && (choiceOptions(field2).some((o) => o.value === value) || typedOther(field2, value)) ? null : "Choose one of the options.",
967
1129
  summarize: (value, field2) => optionLabel(field2, String(value))
968
1130
  },
969
1131
  {
@@ -975,7 +1137,8 @@ var builtins = [
975
1137
  a11y: { semantics: "group" },
976
1138
  validate: (value, field2) => {
977
1139
  const allowed = new Set(choiceOptions(field2).map((o) => o.value));
978
- 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.";
1140
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1141
+ 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.";
979
1142
  },
980
1143
  summarize: (value, field2) => Array.isArray(value) ? value.map((v) => optionLabel(field2, String(v))).join(", ") : String(value)
981
1144
  },
@@ -996,7 +1159,7 @@ var builtins = [
996
1159
  value: { json: true },
997
1160
  operators: ["eq", "neq", "in", "answered", "empty"],
998
1161
  a11y: { semantics: "control" },
999
- validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
1162
+ validate: (value, field2) => typeof value === "string" && (choiceOptions(field2).some((o) => o.value === value) || typedOther(field2, value)) ? null : "Choose one of the options.",
1000
1163
  summarize: (value, field2) => optionLabel(field2, String(value))
1001
1164
  },
1002
1165
  {
@@ -1106,7 +1269,7 @@ function dateLimits(field2) {
1106
1269
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
1107
1270
  return out;
1108
1271
  }
1109
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
1272
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
1110
1273
 
1111
1274
  // src/logic.ts
1112
1275
  function own(record, key) {
@@ -1179,8 +1342,8 @@ function rulesAllow(def, blockId, scope) {
1179
1342
  }
1180
1343
  function walkPath(def, scope) {
1181
1344
  const list = steps(def);
1182
- const index = new Map(list.map((b, i2) => [b.id, i2]));
1183
- const position = new Map(def.blocks.map((b, i2) => [b.id, i2]));
1345
+ let index;
1346
+ let position;
1184
1347
  const jumps = (def.logic ?? []).filter(
1185
1348
  (r) => r.effect === "jump"
1186
1349
  );
@@ -1204,6 +1367,7 @@ function walkPath(def, scope) {
1204
1367
  ending = jump.to.ending;
1205
1368
  break;
1206
1369
  }
1370
+ index ??= new Map(list.map((b, at) => [b.id, at]));
1207
1371
  const target = index.get(jump.to);
1208
1372
  if (target !== void 0 && target > i) {
1209
1373
  i = nextVisible(target);
@@ -1213,6 +1377,7 @@ function walkPath(def, scope) {
1213
1377
  i = nextVisible(i + 1);
1214
1378
  }
1215
1379
  const scopeAt = (blockId, inclusive = false) => {
1380
+ position ??= new Map(def.blocks.map((b, i2) => [b.id, i2]));
1216
1381
  const at = position.get(blockId) ?? -1;
1217
1382
  const values = /* @__PURE__ */ Object.create(null);
1218
1383
  for (const id of path) {
@@ -1404,6 +1569,22 @@ function parseForm(input) {
1404
1569
  }
1405
1570
  }
1406
1571
  if (type === "consent") optionalText(props2.text, `${path}.text`);
1572
+ if (type === PHOTO_TYPE) {
1573
+ if (props2.maxFiles !== void 0 && !(isInt(props2.maxFiles) && props2.maxFiles >= 1 && props2.maxFiles <= MAX_PHOTO_FILES)) {
1574
+ issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
1575
+ }
1576
+ if (props2.maxSizeMb !== void 0 && !(isInt(props2.maxSizeMb) && props2.maxSizeMb >= 1 && props2.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
1577
+ issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
1578
+ }
1579
+ if (props2.formats !== void 0) {
1580
+ const formats = props2.formats;
1581
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
1582
+ issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
1583
+ } else if (new Set(formats).size !== formats.length) {
1584
+ issue(`${path}.formats`, "Name each format once.");
1585
+ }
1586
+ }
1587
+ }
1407
1588
  };
1408
1589
  if (!isObject2(input)) {
1409
1590
  issue("", "A form definition must be an object.");
@@ -1424,6 +1605,7 @@ function parseForm(input) {
1424
1605
  else {
1425
1606
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
1426
1607
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
1608
+ optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
1427
1609
  }
1428
1610
  }
1429
1611
  if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
@@ -1464,6 +1646,14 @@ function parseForm(input) {
1464
1646
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
1465
1647
  }
1466
1648
  if (block.props !== void 0) {
1649
+ const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
1650
+ if (from !== void 0) {
1651
+ if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
1652
+ issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
1653
+ } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1654
+ warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
1655
+ }
1656
+ }
1467
1657
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1468
1658
  else if (block.props.options !== void 0) {
1469
1659
  const options = block.props.options;
@@ -1474,10 +1664,20 @@ function parseForm(input) {
1474
1664
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1475
1665
  else optionalText(o.label, `${at}.label`);
1476
1666
  });
1477
- choices.set(
1478
- block.id,
1479
- new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1480
- );
1667
+ if (from === void 0) {
1668
+ choices.set(
1669
+ block.id,
1670
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1671
+ );
1672
+ }
1673
+ }
1674
+ }
1675
+ const other = isObject2(block.props) ? block.props.other : void 0;
1676
+ if (other !== void 0) {
1677
+ if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
1678
+ issue(`${path}.props.other`, "Must be true, false or { label }.");
1679
+ } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1680
+ warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
1481
1681
  }
1482
1682
  }
1483
1683
  const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
@@ -1552,6 +1752,8 @@ function parseForm(input) {
1552
1752
  } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
1553
1753
  else if (targetType === "media-notes" || targetType === "text-highlights") {
1554
1754
  warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
1755
+ } else if (targetType === PHOTO_TYPE) {
1756
+ warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
1555
1757
  }
1556
1758
  }
1557
1759
  });
@@ -1771,9 +1973,15 @@ function field(type, key, options, props2) {
1771
1973
  return block;
1772
1974
  }
1773
1975
  var props = (all) => Object.fromEntries(Object.entries(all).filter((e) => e[1] !== void 0));
1774
- var choiceProps = (options) => ({
1775
- options: options.map((o) => o.label ? { value: o.value, label: o.label } : { value: o.value })
1776
- });
1976
+ var choiceProps = (o) => {
1977
+ const out = {};
1978
+ if (o.options || !o.optionsFrom) {
1979
+ out.options = (o.options ?? []).map((x) => x.label ? { value: x.value, label: x.label } : { value: x.value });
1980
+ }
1981
+ if (o.optionsFrom) out.optionsFrom = { source: o.optionsFrom.source, key: o.optionsFrom.key };
1982
+ if (o.other) out.other = o.other === true ? true : props({ label: o.other.label });
1983
+ return out;
1984
+ };
1777
1985
  var hiddenSpec = (type, o) => ({
1778
1986
  type,
1779
1987
  source: o.source ?? "url",
@@ -1797,11 +2005,11 @@ var f = {
1797
2005
  ),
1798
2006
  /** A 0-to-10 Net Promoter Score. The end labels default to "Not likely" and "Very likely" in the renderer. */
1799
2007
  nps: (key, o) => field("nps", key, o, props({ lowLabel: o.lowLabel || void 0, highLabel: o.highLabel || void 0 })),
1800
- singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o.options)),
1801
- multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o.options)),
2008
+ singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o)),
2009
+ multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o)),
1802
2010
  yesNo: (key, o) => field("yes-no", key, o),
1803
2011
  /** One option from a compact menu: for a long list (a country, a class), where single choice would be a wall of buttons. */
1804
- dropdown: (key, o) => field("dropdown", key, o, choiceProps(o.options)),
2012
+ dropdown: (key, o) => field("dropdown", key, o, choiceProps(o)),
1805
2013
  /** A number, optionally between `min` and `max` and on a `step` (from `min`, else 0): `step: 1` for whole numbers. */
1806
2014
  number: (key, o) => field("number", key, o, props({ min: o.min, max: o.max, step: o.step })),
1807
2015
  /** A calendar day, stored as `YYYY-MM-DD`, optionally between `min` and `max` (also `YYYY-MM-DD`). */
@@ -1853,6 +2061,17 @@ var f = {
1853
2061
  o,
1854
2062
  props({ text: o.text, maxHighlights: o.maxHighlights })
1855
2063
  ),
2064
+ /**
2065
+ * Up to a few photos (a picture of broken equipment). The answer holds the
2066
+ * references the host's upload returned, never the bytes; `formats` from
2067
+ * jpeg, png, heic and webp (default all four).
2068
+ */
2069
+ photo: (key, o) => field(
2070
+ "photo",
2071
+ key,
2072
+ o,
2073
+ props({ maxFiles: o.maxFiles, maxSizeMb: o.maxSizeMb, formats: o.formats ? [...o.formats] : void 0 })
2074
+ ),
1856
2075
  /** Any registered field type, including plugins. */
1857
2076
  custom: (type, key, o) => field(type, key, o, o.props),
1858
2077
  heading: (id, text) => ({ kind: "content", id, type: "heading", text }),
@@ -2024,10 +2243,20 @@ function validateField(field2, value, options = {}) {
2024
2243
  }
2025
2244
 
2026
2245
  // src/engine.ts
2246
+ function readsAny(node, ids) {
2247
+ if (Array.isArray(node)) return node.some((n) => readsAny(n, ids));
2248
+ if (!node || typeof node !== "object") return false;
2249
+ for (const [k, v] of Object.entries(node)) {
2250
+ if (k === "field" && typeof v === "string" && ids.has(v)) return true;
2251
+ if (typeof v === "object" && readsAny(v, ids)) return true;
2252
+ }
2253
+ return false;
2254
+ }
2027
2255
  function createForm(definition, options = {}) {
2028
2256
  return build(definition, options).engine;
2029
2257
  }
2030
- function build(definition, options, replaying = false) {
2258
+ function build(given, options, replaying = false, budget) {
2259
+ const definition = withHostOptions(given, options.hostOptions);
2031
2260
  const fields = fieldMap(definition);
2032
2261
  const byKey = new Map([...fields.values()].map((f2) => [f2.key, f2]));
2033
2262
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -2045,15 +2274,24 @@ function build(definition, options, replaying = false) {
2045
2274
  if (!declared.has(name)) initialHidden[name] = value;
2046
2275
  }
2047
2276
  Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
2048
- const calc = calculator(definition);
2277
+ const calc = calculator(definition, budget);
2278
+ const pathOf = (values, hidden) => {
2279
+ spend(budget, definition.blocks.length);
2280
+ return computePath(definition, scopeOf(values, hidden));
2281
+ };
2282
+ const logicReadsCalculated = calc.active && readsAny(definition.logic, calc.ids);
2049
2283
  const settle = (values, hidden) => {
2050
- let { path } = computePath(definition, scopeOf(values, hidden));
2284
+ let { path } = pathOf(values, hidden);
2051
2285
  if (!calc.active) return { values, path };
2286
+ if (!logicReadsCalculated) return { values: calc.withCalculated(values, path), path };
2052
2287
  for (let round2 = 0; round2 < 4; round2++) {
2053
2288
  const next = calc.withCalculated(values, path);
2054
2289
  if (next === values) break;
2055
2290
  values = next;
2056
- path = computePath(definition, scopeOf(values, hidden)).path;
2291
+ const again = pathOf(values, hidden).path;
2292
+ const same = again.length === path.length && again.every((id, i) => id === path[i]);
2293
+ path = again;
2294
+ if (same) break;
2057
2295
  }
2058
2296
  return { values, path };
2059
2297
  };
@@ -2064,6 +2302,7 @@ function build(definition, options, replaying = false) {
2064
2302
  path: first.path,
2065
2303
  current: first.path[0] ?? null,
2066
2304
  history: [],
2305
+ furthest: first.path[0] ?? null,
2067
2306
  errors: {},
2068
2307
  status: "in_progress",
2069
2308
  ending: null,
@@ -2095,14 +2334,30 @@ function build(definition, options, replaying = false) {
2095
2334
  return errors;
2096
2335
  };
2097
2336
  let unsettled = false;
2098
- const settleNow = () => {
2099
- if (!unsettled) return;
2100
- unsettled = false;
2101
- const { values, path } = settle(state.values, state.hidden);
2102
- state = { ...state, values, path };
2337
+ let formulasStale = false;
2338
+ const settleNow = (needValues = true) => {
2339
+ if (unsettled && calc.active && !logicReadsCalculated && !needValues) {
2340
+ unsettled = false;
2341
+ formulasStale = true;
2342
+ state = { ...state, path: pathOf(state.values, state.hidden).path };
2343
+ return;
2344
+ }
2345
+ if (unsettled) {
2346
+ unsettled = false;
2347
+ formulasStale = false;
2348
+ const { values, path } = settle(state.values, state.hidden);
2349
+ state = { ...state, values, path };
2350
+ } else if (formulasStale && needValues) {
2351
+ formulasStale = false;
2352
+ state = { ...state, values: calc.withCalculated(state.values, state.path) };
2353
+ }
2103
2354
  };
2104
2355
  const answer = (id, value, event) => {
2105
- if (state.status === "completed" || own(state.values, id) === value || isCalculated(fields.get(id))) return;
2356
+ if (state.status === "completed" || isCalculated(fields.get(id))) return;
2357
+ if (own(state.values, id) === value) {
2358
+ if (value === null || typeof value !== "object") return;
2359
+ calc.forget(value);
2360
+ }
2106
2361
  const errors = { ...state.errors };
2107
2362
  delete errors[id];
2108
2363
  if (batch) {
@@ -2148,7 +2403,8 @@ function build(definition, options, replaying = false) {
2148
2403
  emit({ ...state, events });
2149
2404
  return true;
2150
2405
  }
2151
- emit({ ...state, current: target, history: [...state.history, state.current], events });
2406
+ const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
2407
+ emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
2152
2408
  return true;
2153
2409
  },
2154
2410
  back() {
@@ -2168,7 +2424,7 @@ function build(definition, options, replaying = false) {
2168
2424
  emit({ ...state, errors, current: firstError, events });
2169
2425
  return false;
2170
2426
  }
2171
- const { ending } = computePath(definition, scopeOf(state.values, state.hidden));
2427
+ const { ending } = pathOf(state.values, state.hidden);
2172
2428
  emit({ ...state, errors: {}, status: "completed", ending, events });
2173
2429
  return true;
2174
2430
  },
@@ -2193,6 +2449,15 @@ function build(definition, options, replaying = false) {
2193
2449
  settleNow();
2194
2450
  batch = wasBatch;
2195
2451
  }
2452
+ const resumeAt = options.resumeAt;
2453
+ if (resumeAt && !replaying && state.path.includes(resumeAt)) {
2454
+ for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
2455
+ if (!engine.next()) {
2456
+ state = { ...state, errors: {} };
2457
+ break;
2458
+ }
2459
+ }
2460
+ }
2196
2461
  const apply = (event) => {
2197
2462
  if (event.type === "answer") {
2198
2463
  if (fields.has(event.field)) {
@@ -2200,10 +2465,11 @@ function build(definition, options, replaying = false) {
2200
2465
  answer(event.field, event.value, logged);
2201
2466
  }
2202
2467
  } else if (event.type === "next") {
2203
- settleNow();
2468
+ settleNow(false);
2469
+ if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
2204
2470
  engine.next();
2205
2471
  } else if (event.type === "back") {
2206
- settleNow();
2472
+ settleNow(false);
2207
2473
  engine.back();
2208
2474
  } else if (event.type === "submit") {
2209
2475
  settleNow();
@@ -2218,12 +2484,46 @@ function build(definition, options, replaying = false) {
2218
2484
  return { engine, apply, finish };
2219
2485
  }
2220
2486
  function replay(definition, events, options = {}) {
2221
- const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true);
2222
- for (const event of events) apply(event);
2223
- finish();
2487
+ return replayWithin(definition, events, options);
2488
+ }
2489
+ function replayWithin(definition, events, options, maxWork) {
2490
+ const budget = void 0 ;
2491
+ const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
2492
+ try {
2493
+ for (const event of events) apply(event);
2494
+ finish();
2495
+ } finally {
2496
+ }
2224
2497
  return engine;
2225
2498
  }
2226
2499
 
2500
+ // src/progress.ts
2501
+ function furthestReached(state, definition) {
2502
+ const at = (id) => id ? state.path.indexOf(id) : -1;
2503
+ let furthest = Math.max(at(state.current), at(state.furthest));
2504
+ for (const id of state.history) furthest = Math.max(furthest, at(id));
2505
+ const fields = fieldMap(definition);
2506
+ for (let i = state.path.length - 1; i > furthest; i--) {
2507
+ const field2 = fields.get(state.path[i]);
2508
+ if (field2 && !isEmptyValue(own(state.values, field2.id), field2)) {
2509
+ furthest = i;
2510
+ break;
2511
+ }
2512
+ }
2513
+ return furthest >= 0 ? state.path[furthest] : null;
2514
+ }
2515
+ function formProgress(engine) {
2516
+ const state = engine.getState();
2517
+ const furthest = furthestReached(state, engine.definition);
2518
+ const index = furthest ? state.path.indexOf(furthest) : -1;
2519
+ const fields = fieldMap(engine.definition);
2520
+ let fieldId = null;
2521
+ for (let i = index; i >= 0 && fieldId === null; i--) {
2522
+ if (fields.has(state.path[i])) fieldId = state.path[i];
2523
+ }
2524
+ return { step: index + 1, total: state.path.length, fieldId };
2525
+ }
2526
+
2227
2527
  // src/explain.ts
2228
2528
  function explain(source, blockId, options = {}) {
2229
2529
  const input = "getState" in source ? { definition: source.definition, values: source.getState().values, hidden: source.getState().hidden } : source;
@@ -2360,6 +2660,6 @@ function describeCondition(condition, def, fields = fieldMap(def)) {
2360
2660
  return `${left} ${op} ${right}`;
2361
2661
  }
2362
2662
 
2363
- 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 };
2663
+ export { CALCULATED_TYPE, 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_SETTLE_ITEMS, MAX_TABLE_CELL_LENGTH, MAX_TABLE_COLUMNS, MAX_TIMESTAMP_SECONDS, OTHER_MAX_LENGTH, PHOTO_FORMATS, PHOTO_TYPE, REQUIRED_MESSAGE, STRUCTURED_FIELD_TYPES, UPLOAD_FIELD_TYPES, calculatedProps, calculatedValue, canonicalJson, choiceOptions, coerceHidden, coerceHiddenValue, computePath, createForm, dateLimits, defineForm, describeCondition, evaluate, evaluateFormula, explain, f, fingerprint, formProgress, formatCalculated, formatTimestamp, formulaInput, formulaNumber, formulaRefs, furthestReached, getFieldType, isEmptyValue, isFormEvent, isIsoDate, isJson, isOtherValue, isPhotoFormat, isPhotoRef, isSensitive, listFieldTypes, listLimits, normalizeValue, numberLimits, optionsSource, otherOption, parseForm, parseFormula, parseTimestamp, photoCountMessage, photoFormatList, photoLimits, photoRefs, photoSizeMessage, photoTypeMessage, ratingDisplay, recall, registerFieldType, renameFormulaKey, replay, ruleVisible, scaleLabels, steps, summarizeValue, tableColumns, tableLimits, validateField, when, withHostOptions };
2364
2664
  //# sourceMappingURL=index.js.map
2365
2665
  //# sourceMappingURL=index.js.map