@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.
- package/README.md +75 -1
- package/dist/destinations.cjs +210 -33
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.js +210 -33
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-CPLtKVew.d.cts → engine-BkNuzlna.d.cts} +2 -1
- package/dist/{engine-DOrPgFDw.d.ts → engine-Dz9J5ej2.d.ts} +2 -1
- package/dist/index.cjs +758 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +195 -4
- package/dist/index.d.ts +195 -4
- package/dist/index.js +739 -24
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +672 -19
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +5 -1
- package/dist/server.d.ts +5 -1
- package/dist/server.js +672 -19
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +674 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +674 -19
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/destinations.js
CHANGED
|
@@ -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
|
|
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:
|
|
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) =>
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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,
|