@marktiderman/genesis-forms 0.3.0 → 0.4.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.
@@ -60,6 +60,11 @@ async function timedFetch(deps, url, init, signal) {
60
60
  const timeout = AbortSignal.timeout(deps.timeoutMs ?? REQUEST_TIMEOUT_MS);
61
61
  const any = AbortSignal.any;
62
62
  const combined = signal ? any ? any([timeout, signal]) : signal.aborted ? signal : timeout : timeout;
63
+ try {
64
+ new Request(url, { method: init.method ?? "GET", headers: init.headers ?? {} });
65
+ } catch {
66
+ throw new DeliveryError("Invalid request (a header value or the URL is malformed)", { retryable: false });
67
+ }
63
68
  try {
64
69
  return await (deps.fetch ?? fetch)(url, { ...init, signal: combined });
65
70
  } catch (error) {
@@ -107,6 +112,39 @@ async function sha256Hex(text) {
107
112
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
108
113
  }
109
114
 
115
+ // src/calculated.ts
116
+ var CALCULATED_TYPE = "calculated";
117
+ var MAX_CALCULATED_DECIMALS = 10;
118
+ function calculatedProps(field) {
119
+ const p = field.props ?? {};
120
+ const out = { formula: typeof p.formula === "string" ? p.formula : "" };
121
+ if (p.format === "number" || p.format === "percent" || p.format === "text") out.format = p.format;
122
+ if (typeof p.decimals === "number" && Number.isInteger(p.decimals) && p.decimals >= 0 && p.decimals <= MAX_CALCULATED_DECIMALS) {
123
+ out.decimals = p.decimals;
124
+ }
125
+ return out;
126
+ }
127
+ function formatCalculated(value, field) {
128
+ if (value === void 0 || value === null) return "";
129
+ const { format, decimals } = calculatedProps(field);
130
+ const fixed = (n) => decimals === void 0 ? String(Number(n.toFixed(6))) : n.toFixed(decimals);
131
+ if (typeof value === "number") return format === "percent" ? `${fixed(value * 100)}%` : fixed(value);
132
+ if (typeof value === "boolean") return value ? "Yes" : "No";
133
+ return typeof value === "string" ? value : "";
134
+ }
135
+ var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
136
+ var calculatedFieldType = {
137
+ type: CALCULATED_TYPE,
138
+ label: "Calculated",
139
+ category: "advanced",
140
+ value: { json: true },
141
+ operators: numberOps,
142
+ a11y: { semantics: "control" },
143
+ // The engine works the value out; any number, text or true / false it made is valid.
144
+ validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
145
+ summarize: (value, field) => formatCalculated(value, field)
146
+ };
147
+
110
148
  // src/structured.ts
111
149
  var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
112
150
  function intProp(field, name, fallback, floor = 1) {
@@ -338,7 +376,7 @@ function intRange(field, min, maxDefault) {
338
376
  const max = typeof field.props?.max === "number" ? field.props.max : maxDefault;
339
377
  return { min, max };
340
378
  }
341
- var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
379
+ var numberOps2 = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
342
380
  var textOps = ["eq", "neq", "contains", "answered", "empty"];
343
381
  function maxLength(field, fallback) {
344
382
  return typeof field.props?.maxLength === "number" ? field.props.maxLength : fallback;
@@ -416,23 +454,30 @@ var builtins = [
416
454
  label: "Rating",
417
455
  category: "scale",
418
456
  value: { json: true },
419
- operators: numberOps,
457
+ operators: numberOps2,
420
458
  a11y: { semantics: "group" },
421
459
  validate: (value, field) => {
422
460
  const { min, max } = intRange(field, 1, 5);
423
461
  return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
424
462
  },
425
- summarize: (value, field) => `${value}/${intRange(field, 1, 5).max}`
463
+ summarize: (value, field) => {
464
+ const { max } = intRange(field, 1, 5);
465
+ const end = scaleEndLabel(field, value, 1, max);
466
+ return end ? `${value}/${max} (${end})` : `${value}/${max}`;
467
+ }
426
468
  },
427
469
  {
428
470
  type: "nps",
429
471
  label: "Net Promoter Score",
430
472
  category: "scale",
431
473
  value: { json: true },
432
- operators: numberOps,
474
+ operators: numberOps2,
433
475
  a11y: { semantics: "group" },
434
476
  validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
435
- summarize: (value) => `${value}/10`
477
+ summarize: (value, field) => {
478
+ const end = scaleEndLabel(field, value, 0, 10);
479
+ return end ? `${value}/10 (${end})` : `${value}/10`;
480
+ }
436
481
  },
437
482
  {
438
483
  type: "single-choice",
@@ -466,9 +511,122 @@ var builtins = [
466
511
  a11y: { semantics: "group" },
467
512
  validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
468
513
  summarize: (value) => value ? "Yes" : "No"
514
+ },
515
+ {
516
+ type: "dropdown",
517
+ label: "Dropdown",
518
+ category: "choice",
519
+ value: { json: true },
520
+ operators: ["eq", "neq", "in", "answered", "empty"],
521
+ a11y: { semantics: "control" },
522
+ validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
523
+ summarize: (value, field) => optionLabel(field, String(value))
524
+ },
525
+ {
526
+ type: "number",
527
+ label: "Number",
528
+ category: "text",
529
+ value: { json: true },
530
+ operators: numberOps2,
531
+ a11y: { semantics: "control", inputMode: "decimal" },
532
+ validate: (value, field) => {
533
+ if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
534
+ const { min, max, step } = numberLimits(field);
535
+ if (min !== void 0 && max !== void 0 && (value < min || value > max)) {
536
+ return `Enter a number from ${min} to ${max}.`;
537
+ }
538
+ if (min !== void 0 && value < min) return `Enter ${min} or more.`;
539
+ if (max !== void 0 && value > max) return `Enter ${max} or less.`;
540
+ if (step !== void 0 && !onStep(value, min ?? 0, step)) {
541
+ return step === 1 && Number.isInteger(min ?? 0) ? "Enter a whole number." : `Enter a number in steps of ${step}.`;
542
+ }
543
+ return null;
544
+ },
545
+ summarize: (value) => String(value),
546
+ samples: (field) => {
547
+ const { min, max } = numberLimits(field);
548
+ return [min, max, 0, 1].filter((v) => v !== void 0);
549
+ }
550
+ },
551
+ {
552
+ type: "date",
553
+ label: "Date",
554
+ category: "date",
555
+ value: { json: true },
556
+ operators: ["eq", "neq", "answered", "empty"],
557
+ a11y: { semantics: "control" },
558
+ validate: (value, field) => {
559
+ if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
560
+ const date = value.trim();
561
+ const { min, max } = dateLimits(field);
562
+ if (min && max && (date < min || date > max)) return `Choose a date from ${min} to ${max}.`;
563
+ if (min && date < min) return `Choose ${min} or later.`;
564
+ if (max && date > max) return `Choose ${max} or earlier.`;
565
+ return null;
566
+ },
567
+ normalize: (value) => typeof value === "string" ? value.trim() : value,
568
+ samples: (field) => {
569
+ const { min, max } = dateLimits(field);
570
+ return [min ?? max ?? "2000-01-01"];
571
+ }
572
+ },
573
+ {
574
+ type: "consent",
575
+ label: "Consent checkbox",
576
+ category: "choice",
577
+ value: { json: true },
578
+ operators: ["answered", "empty"],
579
+ a11y: { semantics: "group" },
580
+ // Unchecked is unanswered, so a required consent must be checked.
581
+ isEmpty: (value) => value === void 0 || value === null || value === false,
582
+ validate: (value) => value === true ? null : "Check the box to agree.",
583
+ summarize: () => "Agreed",
584
+ samples: () => [true]
469
585
  }
470
586
  ];
471
- for (const manifest of [...builtins, ...structuredFieldTypes]) registerFieldType(manifest);
587
+ function scaleLabels(field) {
588
+ const out = {};
589
+ const low = field.props?.lowLabel;
590
+ const high = field.props?.highLabel;
591
+ if (typeof low === "string" && low.trim()) out.low = low.trim();
592
+ if (typeof high === "string" && high.trim()) out.high = high.trim();
593
+ return out;
594
+ }
595
+ function scaleEndLabel(field, value, min, max) {
596
+ const { low, high } = scaleLabels(field);
597
+ return value === min ? low : value === max ? high : void 0;
598
+ }
599
+ var finite = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
600
+ function numberLimits(field) {
601
+ const out = {};
602
+ const min = finite(field.props?.min);
603
+ const max = finite(field.props?.max);
604
+ const step = finite(field.props?.step);
605
+ if (min !== void 0) out.min = min;
606
+ if (max !== void 0) out.max = max;
607
+ if (step !== void 0 && step > 0) out.step = step;
608
+ return out;
609
+ }
610
+ function onStep(value, base, step) {
611
+ const n = (value - base) / step;
612
+ return Math.abs(n - Math.round(n)) < 1e-9 * Math.max(1, Math.abs(n));
613
+ }
614
+ function isIsoDate(value) {
615
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
616
+ const [y, m, d] = value.split("-").map(Number);
617
+ if (y < 1 || m < 1 || m > 12 || d < 1) return false;
618
+ const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
619
+ return d <= days[m - 1];
620
+ }
621
+ function dateLimits(field) {
622
+ const out = {};
623
+ const min = field.props?.min;
624
+ const max = field.props?.max;
625
+ if (typeof min === "string" && isIsoDate(min)) out.min = min;
626
+ if (typeof max === "string" && isIsoDate(max)) out.max = max;
627
+ return out;
628
+ }
629
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
472
630
 
473
631
  // src/logic.ts
474
632
  function own(record, key) {
@@ -756,7 +914,7 @@ async function googleAccessToken(account, options = {}) {
756
914
  if (!clientEmail || !privateKey) throw new DeliveryError("The Google service account email or private key is not set");
757
915
  const scope = options.scope ?? SHEETS_SCOPE;
758
916
  const now = options.now?.() ?? Date.now();
759
- const cacheKey = `${clientEmail} ${scope}`;
917
+ const cacheKey = `${clientEmail} ${scope} ${await sha256Hex(privateKey.replace(/\\n/g, "\n").trim())}`;
760
918
  const hit = cache.get(cacheKey);
761
919
  if (hit && hit.expiresAt - 6e4 > now) return hit.token;
762
920
  const iat = Math.floor(now / 1e3);
@@ -787,6 +945,7 @@ var SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets";
787
945
  var DEFAULT_SHEET_NAME = "Responses";
788
946
  var DEDUPE_WINDOW = 500;
789
947
  var HEADER_SCAN_ROWS = 100;
948
+ var HEADER_WRITE_ATTEMPTS = 3;
790
949
  function mergeHeader(existing, keys, width = 0) {
791
950
  const header = existing.map((h) => h == null ? "" : String(h));
792
951
  while (header.length < width) header.push("");
@@ -882,35 +1041,52 @@ function sheetsDestination(options) {
882
1041
  });
883
1042
  const keys = [...new Set(rows.flatMap((r) => r.columns))];
884
1043
  async function setUp(previous) {
885
- const meta = await call(
886
- "read",
887
- `${base}?fields=${encodeURIComponent("sheets.properties(sheetId,title,gridProperties.columnCount)")}`
888
- );
889
- let sheet = meta.sheets?.map((s) => s.properties).find((p) => p?.title === sheetName);
1044
+ const readTabs = async () => {
1045
+ const meta = await call(
1046
+ "read",
1047
+ `${base}?fields=${encodeURIComponent("sheets.properties(sheetId,title,gridProperties.columnCount)")}`
1048
+ );
1049
+ return meta.sheets?.map((s) => s.properties).find((p) => p?.title === sheetName);
1050
+ };
1051
+ let sheet = await readTabs();
890
1052
  if (!sheet) {
891
- const added = await batchUpdate("add tab", [{ addSheet: { properties: { title: sheetName } } }]);
892
- sheet = added.replies?.[0]?.addSheet?.properties ?? { sheetId: 0, title: sheetName };
893
- }
894
- const read = await call(
895
- "read header",
896
- `${range(`A1:ZZZ${HEADER_SCAN_ROWS}`)}?majorDimension=COLUMNS`
897
- );
898
- const used = read.values ?? [];
899
- const existing = used.map((column) => column[0] ?? "");
900
- const header = mergeHeader(existing, keys, used.length);
901
- if (!header.includes("id")) {
902
- if (used.length) header.push("id");
903
- else header.unshift("id");
1053
+ try {
1054
+ const added = await batchUpdate("add tab", [{ addSheet: { properties: { title: sheetName } } }]);
1055
+ sheet = added.replies?.[0]?.addSheet?.properties ?? { sheetId: 0, title: sheetName };
1056
+ } catch (error) {
1057
+ if (!(error instanceof DeliveryError) || error.status !== 400) throw error;
1058
+ sheet = await readTabs();
1059
+ if (!sheet) throw error;
1060
+ }
904
1061
  }
1062
+ const readHeader = async () => {
1063
+ const read = await call(
1064
+ "read header",
1065
+ `${range(`A1:ZZZ${HEADER_SCAN_ROWS}`)}?majorDimension=COLUMNS`
1066
+ );
1067
+ const used2 = read.values ?? [];
1068
+ return { used: used2.length, existing: used2.map((column) => column[0] ?? "") };
1069
+ };
905
1070
  let columns = sheet.gridProperties?.columnCount ?? 26;
906
- if (header.length > columns) {
907
- await batchUpdate("add columns", [
908
- { appendDimension: { sheetId: sheet.sheetId, dimension: "COLUMNS", length: header.length - columns } }
909
- ]);
910
- columns = header.length;
911
- }
912
- const changed = header.length !== existing.length || header.some((h, i) => h !== String(existing[i] ?? ""));
913
- if (changed) {
1071
+ let { used, existing } = await readHeader();
1072
+ let header = [];
1073
+ for (let attempt = 0; ; attempt++) {
1074
+ header = mergeHeader(existing, keys, used);
1075
+ if (!header.includes("id")) {
1076
+ if (used) header.push("id");
1077
+ else header.unshift("id");
1078
+ }
1079
+ const changed = header.length !== existing.length || header.some((h, i) => h !== String(existing[i] ?? ""));
1080
+ if (!changed) break;
1081
+ if (attempt >= HEADER_WRITE_ATTEMPTS) {
1082
+ throw new DeliveryError("Google Sheets header kept changing while it was written", { retryable: true });
1083
+ }
1084
+ if (header.length > columns) {
1085
+ await batchUpdate("add columns", [
1086
+ { appendDimension: { sheetId: sheet.sheetId, dimension: "COLUMNS", length: header.length - columns } }
1087
+ ]);
1088
+ columns = header.length;
1089
+ }
914
1090
  await call("write header", `${range("A1")}?valueInputOption=RAW`, {
915
1091
  method: "PUT",
916
1092
  body: JSON.stringify({ values: [header] })
@@ -925,6 +1101,7 @@ function sheetsDestination(options) {
925
1101
  }
926
1102
  ]);
927
1103
  }
1104
+ ({ used, existing } = await readHeader());
928
1105
  }
929
1106
  return {
930
1107
  spreadsheet_id: spreadsheetId,