@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/README.md
CHANGED
|
@@ -5,7 +5,7 @@ can be stored, diffed, versioned, and evaluated identically in a browser, in Rea
|
|
|
5
5
|
server. Zero dependencies.
|
|
6
6
|
|
|
7
7
|
**Status: 0.1, release R1.** Field types: `short-text`, `long-text`, `email`, `phone`, `rating`,
|
|
8
|
-
`nps`, `single-choice`, `multi-choice`, `yes-no`. Logic: show, hide, jump to a field or an ending. Layouts
|
|
8
|
+
`nps`, `single-choice`, `multi-choice`, `yes-no`, `dropdown`, `number`, `date`, `consent`. Logic: show, hide, jump to a field or an ending. Layouts
|
|
9
9
|
(rendered by `genesis-forms-web`): focus (one question at a time) and classic. The full plan is in
|
|
10
10
|
[docs/FORMS.md](../../docs/FORMS.md).
|
|
11
11
|
|
|
@@ -85,6 +85,19 @@ space, `-`, `(`, `)` and `.`, 7 to 15 digits, at most 32 characters. Operators:
|
|
|
85
85
|
`answered`, `empty`. Its manifest carries `autocomplete: "tel"` and `inputMode: "tel"` (email:
|
|
86
86
|
`"email"` for both).
|
|
87
87
|
|
|
88
|
+
### Dropdown, number, date and consent
|
|
89
|
+
|
|
90
|
+
| Type and builder | Value | Notes |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| `dropdown`, `f.dropdown(key, { options })` | one option's value | Like single choice, from a compact menu. Operators: `eq`, `neq`, `in`, `answered`, `empty`. |
|
|
93
|
+
| `number`, `f.number(key, { min, max, step })` | `number` | All limits optional; `step` counts from `min`, else 0 (`step: 1` for whole numbers). Supports `lt`/`gt` logic. `numberLimits(field)`. |
|
|
94
|
+
| `date`, `f.date(key, { min, max })` | `"YYYY-MM-DD"` | A real calendar day within the optional limits. Operators: `eq`, `neq`, `answered`, `empty`. `isIsoDate`, `dateLimits(field)`. |
|
|
95
|
+
| `consent`, `f.consent(key, { text })` | `true` | One checkbox; unchecked is unanswered, so `required` means it must be checked. Summarized as "Agreed". |
|
|
96
|
+
|
|
97
|
+
`f.rating` also takes `lowLabel`, `highLabel` (words under the ends, such as "Poor" and "Great") and
|
|
98
|
+
`display: "numbers" | "stars"`; `f.nps` takes the labels too. `summarizeValue` adds an end's label:
|
|
99
|
+
`"5/5 (Great)"`. Helpers: `scaleLabels(field)`, `ratingDisplay(field)`.
|
|
100
|
+
|
|
88
101
|
### Structured field types
|
|
89
102
|
|
|
90
103
|
Four built-in types collect more than one value for a question. `STRUCTURED_FIELD_TYPES` lists
|
|
@@ -116,6 +129,67 @@ Each type validates, normalizes (trimmed, blanks dropped, notes in time order),
|
|
|
116
129
|
samples for `coverage()`. Helpers: `listLimits`, `tableColumns`, `tableLimits`, `formatTimestamp`
|
|
117
130
|
and `parseTimestamp`; types `TableColumn`, `MediaNote` and `Highlight`.
|
|
118
131
|
|
|
132
|
+
### Calculated fields
|
|
133
|
+
|
|
134
|
+
A `calculated` field shows a value worked out from other answers, such as a total, an average or a
|
|
135
|
+
band, and the respondent can't change it. Its props are `{ formula, format?, decimals? }`, with
|
|
136
|
+
`format` one of `"number"`, `"percent"` (`0.25` shows `25%`) or `"text"`.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const budget = defineForm({
|
|
140
|
+
id: "budget",
|
|
141
|
+
title: "Weekly budget",
|
|
142
|
+
blocks: [
|
|
143
|
+
f.shortText("hours", { label: "Hours worked" }),
|
|
144
|
+
f.shortText("rate", { label: "Hourly rate" }),
|
|
145
|
+
f.table("costs", { label: "Costs", columns: [{ key: "item", label: "Item" }, { key: "amount", label: "Amount" }] }),
|
|
146
|
+
f.calculated("income", { label: "Income", formula: "hours * rate", decimals: 2 }),
|
|
147
|
+
f.calculated("left", { label: "Left over", formula: "income - sum(costs.amount)", decimals: 2 }),
|
|
148
|
+
f.calculated("status", { label: "Status", formula: "left >= 0 ? 'On budget' : 'Over budget'", format: "text" }),
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The formula language is small and is parsed and evaluated by this package, never run as code:
|
|
154
|
+
|
|
155
|
+
| Part | Written as |
|
|
156
|
+
| --- | --- |
|
|
157
|
+
| Answers | Their key (`hours`); one column of a table as `key.column` (`costs.amount`) |
|
|
158
|
+
| Values | Numbers (`12`, `0.5`) and text in quotes (`"High"`, `'High'`) |
|
|
159
|
+
| Arithmetic | `+ - * /` (also `− × ÷`), unary minus, parentheses |
|
|
160
|
+
| Comparisons | `== != < <= > >=` (also `= ≠ ≤ ≥`), true or false |
|
|
161
|
+
| A choice | `test ? a : b` |
|
|
162
|
+
| Functions | `sum`, `avg`, `min`, `max` of numbers, lists and table columns |
|
|
163
|
+
|
|
164
|
+
An unanswered question is blank, and arithmetic with a blank is blank, so a total stays empty until
|
|
165
|
+
its answers are in; dividing by zero is blank too. The functions skip blanks (`sum` of nothing is
|
|
166
|
+
`0`). Text holding a number (`"12"`, `"1,200"`) counts as that number, and yes / no as 1 / 0.
|
|
167
|
+
A list or table of more than `MAX_FORMULA_ITEMS` (10,000) items reads as blank. A formula is at
|
|
168
|
+
most `MAX_FORMULA_LENGTH` characters and 64 levels of parentheses, calls, signs and `? :` deep.
|
|
169
|
+
|
|
170
|
+
The engine works the values out from the answers on the path (an answer hidden by logic reads as
|
|
171
|
+
blank), again whenever an answer it reads changes, and stores them with the answers, so `engine.answers()` and a
|
|
172
|
+
checked submission hold them for later use. `setValue` on a calculated field does nothing, and
|
|
173
|
+
`validateSubmission` works the values out itself, dropping any the client sent. A calculated field
|
|
174
|
+
is never required. Logic may compare its value.
|
|
175
|
+
|
|
176
|
+
`parseForm` checks each formula: it must parse, every key must be a question of the form, a table
|
|
177
|
+
must be read by one of its columns, and a calculated field may read only calculated fields before
|
|
178
|
+
it, so values never go round in a circle. A problem is an issue on `blocks[i].props.formula`, such
|
|
179
|
+
as `The formula uses "wage", which is not the answer name of a question in this form.` or
|
|
180
|
+
`Expected a number, an answer or "(", but the formula ends there. (at character 9)`.
|
|
181
|
+
|
|
182
|
+
`parseFormula`, `evaluateFormula`, `formulaRefs` and `renameFormulaKey` work with formulas on their
|
|
183
|
+
own; `formatCalculated` shows a value as the field does. Builders offer the type only when the host
|
|
184
|
+
names it (`COMPUTED_FIELD_TYPES`).
|
|
185
|
+
|
|
186
|
+
### Props for the host
|
|
187
|
+
|
|
188
|
+
Props a field type doesn't use are kept and ignored: the engine, `parseForm` and
|
|
189
|
+
`validateSubmission` treat them as data for the app. A host can mark where an answer goes after a
|
|
190
|
+
submit, for example `props: { record_column: "nickname" }`, and read it from the definition when it
|
|
191
|
+
stores the response. The builder offers such props as `outputs` (see its README).
|
|
192
|
+
|
|
119
193
|
## On the server
|
|
120
194
|
|
|
121
195
|
```ts
|
package/dist/destinations.cjs
CHANGED
|
@@ -62,6 +62,11 @@ async function timedFetch(deps, url, init, signal) {
|
|
|
62
62
|
const timeout = AbortSignal.timeout(deps.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
63
63
|
const any = AbortSignal.any;
|
|
64
64
|
const combined = signal ? any ? any([timeout, signal]) : signal.aborted ? signal : timeout : timeout;
|
|
65
|
+
try {
|
|
66
|
+
new Request(url, { method: init.method ?? "GET", headers: init.headers ?? {} });
|
|
67
|
+
} catch {
|
|
68
|
+
throw new DeliveryError("Invalid request (a header value or the URL is malformed)", { retryable: false });
|
|
69
|
+
}
|
|
65
70
|
try {
|
|
66
71
|
return await (deps.fetch ?? fetch)(url, { ...init, signal: combined });
|
|
67
72
|
} catch (error) {
|
|
@@ -109,6 +114,39 @@ async function sha256Hex(text) {
|
|
|
109
114
|
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
// src/calculated.ts
|
|
118
|
+
var CALCULATED_TYPE = "calculated";
|
|
119
|
+
var MAX_CALCULATED_DECIMALS = 10;
|
|
120
|
+
function calculatedProps(field) {
|
|
121
|
+
const p = field.props ?? {};
|
|
122
|
+
const out = { formula: typeof p.formula === "string" ? p.formula : "" };
|
|
123
|
+
if (p.format === "number" || p.format === "percent" || p.format === "text") out.format = p.format;
|
|
124
|
+
if (typeof p.decimals === "number" && Number.isInteger(p.decimals) && p.decimals >= 0 && p.decimals <= MAX_CALCULATED_DECIMALS) {
|
|
125
|
+
out.decimals = p.decimals;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function formatCalculated(value, field) {
|
|
130
|
+
if (value === void 0 || value === null) return "";
|
|
131
|
+
const { format, decimals } = calculatedProps(field);
|
|
132
|
+
const fixed = (n) => decimals === void 0 ? String(Number(n.toFixed(6))) : n.toFixed(decimals);
|
|
133
|
+
if (typeof value === "number") return format === "percent" ? `${fixed(value * 100)}%` : fixed(value);
|
|
134
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
135
|
+
return typeof value === "string" ? value : "";
|
|
136
|
+
}
|
|
137
|
+
var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
138
|
+
var calculatedFieldType = {
|
|
139
|
+
type: CALCULATED_TYPE,
|
|
140
|
+
label: "Calculated",
|
|
141
|
+
category: "advanced",
|
|
142
|
+
value: { json: true },
|
|
143
|
+
operators: numberOps,
|
|
144
|
+
a11y: { semantics: "control" },
|
|
145
|
+
// The engine works the value out; any number, text or true / false it made is valid.
|
|
146
|
+
validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
|
|
147
|
+
summarize: (value, field) => formatCalculated(value, field)
|
|
148
|
+
};
|
|
149
|
+
|
|
112
150
|
// src/structured.ts
|
|
113
151
|
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
114
152
|
function intProp(field, name, fallback, floor = 1) {
|
|
@@ -340,7 +378,7 @@ function intRange(field, min, maxDefault) {
|
|
|
340
378
|
const max = typeof field.props?.max === "number" ? field.props.max : maxDefault;
|
|
341
379
|
return { min, max };
|
|
342
380
|
}
|
|
343
|
-
var
|
|
381
|
+
var numberOps2 = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
344
382
|
var textOps = ["eq", "neq", "contains", "answered", "empty"];
|
|
345
383
|
function maxLength(field, fallback) {
|
|
346
384
|
return typeof field.props?.maxLength === "number" ? field.props.maxLength : fallback;
|
|
@@ -418,23 +456,30 @@ var builtins = [
|
|
|
418
456
|
label: "Rating",
|
|
419
457
|
category: "scale",
|
|
420
458
|
value: { json: true },
|
|
421
|
-
operators:
|
|
459
|
+
operators: numberOps2,
|
|
422
460
|
a11y: { semantics: "group" },
|
|
423
461
|
validate: (value, field) => {
|
|
424
462
|
const { min, max } = intRange(field, 1, 5);
|
|
425
463
|
return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
|
|
426
464
|
},
|
|
427
|
-
summarize: (value, field) =>
|
|
465
|
+
summarize: (value, field) => {
|
|
466
|
+
const { max } = intRange(field, 1, 5);
|
|
467
|
+
const end = scaleEndLabel(field, value, 1, max);
|
|
468
|
+
return end ? `${value}/${max} (${end})` : `${value}/${max}`;
|
|
469
|
+
}
|
|
428
470
|
},
|
|
429
471
|
{
|
|
430
472
|
type: "nps",
|
|
431
473
|
label: "Net Promoter Score",
|
|
432
474
|
category: "scale",
|
|
433
475
|
value: { json: true },
|
|
434
|
-
operators:
|
|
476
|
+
operators: numberOps2,
|
|
435
477
|
a11y: { semantics: "group" },
|
|
436
478
|
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
|
|
437
|
-
summarize: (value) =>
|
|
479
|
+
summarize: (value, field) => {
|
|
480
|
+
const end = scaleEndLabel(field, value, 0, 10);
|
|
481
|
+
return end ? `${value}/10 (${end})` : `${value}/10`;
|
|
482
|
+
}
|
|
438
483
|
},
|
|
439
484
|
{
|
|
440
485
|
type: "single-choice",
|
|
@@ -468,9 +513,122 @@ var builtins = [
|
|
|
468
513
|
a11y: { semantics: "group" },
|
|
469
514
|
validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
|
|
470
515
|
summarize: (value) => value ? "Yes" : "No"
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
type: "dropdown",
|
|
519
|
+
label: "Dropdown",
|
|
520
|
+
category: "choice",
|
|
521
|
+
value: { json: true },
|
|
522
|
+
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
523
|
+
a11y: { semantics: "control" },
|
|
524
|
+
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
525
|
+
summarize: (value, field) => optionLabel(field, String(value))
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
type: "number",
|
|
529
|
+
label: "Number",
|
|
530
|
+
category: "text",
|
|
531
|
+
value: { json: true },
|
|
532
|
+
operators: numberOps2,
|
|
533
|
+
a11y: { semantics: "control", inputMode: "decimal" },
|
|
534
|
+
validate: (value, field) => {
|
|
535
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
|
|
536
|
+
const { min, max, step } = numberLimits(field);
|
|
537
|
+
if (min !== void 0 && max !== void 0 && (value < min || value > max)) {
|
|
538
|
+
return `Enter a number from ${min} to ${max}.`;
|
|
539
|
+
}
|
|
540
|
+
if (min !== void 0 && value < min) return `Enter ${min} or more.`;
|
|
541
|
+
if (max !== void 0 && value > max) return `Enter ${max} or less.`;
|
|
542
|
+
if (step !== void 0 && !onStep(value, min ?? 0, step)) {
|
|
543
|
+
return step === 1 && Number.isInteger(min ?? 0) ? "Enter a whole number." : `Enter a number in steps of ${step}.`;
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
},
|
|
547
|
+
summarize: (value) => String(value),
|
|
548
|
+
samples: (field) => {
|
|
549
|
+
const { min, max } = numberLimits(field);
|
|
550
|
+
return [min, max, 0, 1].filter((v) => v !== void 0);
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
type: "date",
|
|
555
|
+
label: "Date",
|
|
556
|
+
category: "date",
|
|
557
|
+
value: { json: true },
|
|
558
|
+
operators: ["eq", "neq", "answered", "empty"],
|
|
559
|
+
a11y: { semantics: "control" },
|
|
560
|
+
validate: (value, field) => {
|
|
561
|
+
if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
|
|
562
|
+
const date = value.trim();
|
|
563
|
+
const { min, max } = dateLimits(field);
|
|
564
|
+
if (min && max && (date < min || date > max)) return `Choose a date from ${min} to ${max}.`;
|
|
565
|
+
if (min && date < min) return `Choose ${min} or later.`;
|
|
566
|
+
if (max && date > max) return `Choose ${max} or earlier.`;
|
|
567
|
+
return null;
|
|
568
|
+
},
|
|
569
|
+
normalize: (value) => typeof value === "string" ? value.trim() : value,
|
|
570
|
+
samples: (field) => {
|
|
571
|
+
const { min, max } = dateLimits(field);
|
|
572
|
+
return [min ?? max ?? "2000-01-01"];
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
type: "consent",
|
|
577
|
+
label: "Consent checkbox",
|
|
578
|
+
category: "choice",
|
|
579
|
+
value: { json: true },
|
|
580
|
+
operators: ["answered", "empty"],
|
|
581
|
+
a11y: { semantics: "group" },
|
|
582
|
+
// Unchecked is unanswered, so a required consent must be checked.
|
|
583
|
+
isEmpty: (value) => value === void 0 || value === null || value === false,
|
|
584
|
+
validate: (value) => value === true ? null : "Check the box to agree.",
|
|
585
|
+
summarize: () => "Agreed",
|
|
586
|
+
samples: () => [true]
|
|
471
587
|
}
|
|
472
588
|
];
|
|
473
|
-
|
|
589
|
+
function scaleLabels(field) {
|
|
590
|
+
const out = {};
|
|
591
|
+
const low = field.props?.lowLabel;
|
|
592
|
+
const high = field.props?.highLabel;
|
|
593
|
+
if (typeof low === "string" && low.trim()) out.low = low.trim();
|
|
594
|
+
if (typeof high === "string" && high.trim()) out.high = high.trim();
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
function scaleEndLabel(field, value, min, max) {
|
|
598
|
+
const { low, high } = scaleLabels(field);
|
|
599
|
+
return value === min ? low : value === max ? high : void 0;
|
|
600
|
+
}
|
|
601
|
+
var finite = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
602
|
+
function numberLimits(field) {
|
|
603
|
+
const out = {};
|
|
604
|
+
const min = finite(field.props?.min);
|
|
605
|
+
const max = finite(field.props?.max);
|
|
606
|
+
const step = finite(field.props?.step);
|
|
607
|
+
if (min !== void 0) out.min = min;
|
|
608
|
+
if (max !== void 0) out.max = max;
|
|
609
|
+
if (step !== void 0 && step > 0) out.step = step;
|
|
610
|
+
return out;
|
|
611
|
+
}
|
|
612
|
+
function onStep(value, base, step) {
|
|
613
|
+
const n = (value - base) / step;
|
|
614
|
+
return Math.abs(n - Math.round(n)) < 1e-9 * Math.max(1, Math.abs(n));
|
|
615
|
+
}
|
|
616
|
+
function isIsoDate(value) {
|
|
617
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
618
|
+
const [y, m, d] = value.split("-").map(Number);
|
|
619
|
+
if (y < 1 || m < 1 || m > 12 || d < 1) return false;
|
|
620
|
+
const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
621
|
+
return d <= days[m - 1];
|
|
622
|
+
}
|
|
623
|
+
function dateLimits(field) {
|
|
624
|
+
const out = {};
|
|
625
|
+
const min = field.props?.min;
|
|
626
|
+
const max = field.props?.max;
|
|
627
|
+
if (typeof min === "string" && isIsoDate(min)) out.min = min;
|
|
628
|
+
if (typeof max === "string" && isIsoDate(max)) out.max = max;
|
|
629
|
+
return out;
|
|
630
|
+
}
|
|
631
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
|
|
474
632
|
|
|
475
633
|
// src/logic.ts
|
|
476
634
|
function own(record, key) {
|
|
@@ -758,7 +916,7 @@ async function googleAccessToken(account, options = {}) {
|
|
|
758
916
|
if (!clientEmail || !privateKey) throw new DeliveryError("The Google service account email or private key is not set");
|
|
759
917
|
const scope = options.scope ?? SHEETS_SCOPE;
|
|
760
918
|
const now = options.now?.() ?? Date.now();
|
|
761
|
-
const cacheKey = `${clientEmail} ${scope}`;
|
|
919
|
+
const cacheKey = `${clientEmail} ${scope} ${await sha256Hex(privateKey.replace(/\\n/g, "\n").trim())}`;
|
|
762
920
|
const hit = cache.get(cacheKey);
|
|
763
921
|
if (hit && hit.expiresAt - 6e4 > now) return hit.token;
|
|
764
922
|
const iat = Math.floor(now / 1e3);
|
|
@@ -789,6 +947,7 @@ var SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets";
|
|
|
789
947
|
var DEFAULT_SHEET_NAME = "Responses";
|
|
790
948
|
var DEDUPE_WINDOW = 500;
|
|
791
949
|
var HEADER_SCAN_ROWS = 100;
|
|
950
|
+
var HEADER_WRITE_ATTEMPTS = 3;
|
|
792
951
|
function mergeHeader(existing, keys, width = 0) {
|
|
793
952
|
const header = existing.map((h) => h == null ? "" : String(h));
|
|
794
953
|
while (header.length < width) header.push("");
|
|
@@ -884,35 +1043,52 @@ function sheetsDestination(options) {
|
|
|
884
1043
|
});
|
|
885
1044
|
const keys = [...new Set(rows.flatMap((r) => r.columns))];
|
|
886
1045
|
async function setUp(previous) {
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1046
|
+
const readTabs = async () => {
|
|
1047
|
+
const meta = await call(
|
|
1048
|
+
"read",
|
|
1049
|
+
`${base}?fields=${encodeURIComponent("sheets.properties(sheetId,title,gridProperties.columnCount)")}`
|
|
1050
|
+
);
|
|
1051
|
+
return meta.sheets?.map((s) => s.properties).find((p) => p?.title === sheetName);
|
|
1052
|
+
};
|
|
1053
|
+
let sheet = await readTabs();
|
|
892
1054
|
if (!sheet) {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const existing = used.map((column) => column[0] ?? "");
|
|
902
|
-
const header = mergeHeader(existing, keys, used.length);
|
|
903
|
-
if (!header.includes("id")) {
|
|
904
|
-
if (used.length) header.push("id");
|
|
905
|
-
else header.unshift("id");
|
|
1055
|
+
try {
|
|
1056
|
+
const added = await batchUpdate("add tab", [{ addSheet: { properties: { title: sheetName } } }]);
|
|
1057
|
+
sheet = added.replies?.[0]?.addSheet?.properties ?? { sheetId: 0, title: sheetName };
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
if (!(error instanceof DeliveryError) || error.status !== 400) throw error;
|
|
1060
|
+
sheet = await readTabs();
|
|
1061
|
+
if (!sheet) throw error;
|
|
1062
|
+
}
|
|
906
1063
|
}
|
|
1064
|
+
const readHeader = async () => {
|
|
1065
|
+
const read = await call(
|
|
1066
|
+
"read header",
|
|
1067
|
+
`${range(`A1:ZZZ${HEADER_SCAN_ROWS}`)}?majorDimension=COLUMNS`
|
|
1068
|
+
);
|
|
1069
|
+
const used2 = read.values ?? [];
|
|
1070
|
+
return { used: used2.length, existing: used2.map((column) => column[0] ?? "") };
|
|
1071
|
+
};
|
|
907
1072
|
let columns = sheet.gridProperties?.columnCount ?? 26;
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1073
|
+
let { used, existing } = await readHeader();
|
|
1074
|
+
let header = [];
|
|
1075
|
+
for (let attempt = 0; ; attempt++) {
|
|
1076
|
+
header = mergeHeader(existing, keys, used);
|
|
1077
|
+
if (!header.includes("id")) {
|
|
1078
|
+
if (used) header.push("id");
|
|
1079
|
+
else header.unshift("id");
|
|
1080
|
+
}
|
|
1081
|
+
const changed = header.length !== existing.length || header.some((h, i) => h !== String(existing[i] ?? ""));
|
|
1082
|
+
if (!changed) break;
|
|
1083
|
+
if (attempt >= HEADER_WRITE_ATTEMPTS) {
|
|
1084
|
+
throw new DeliveryError("Google Sheets header kept changing while it was written", { retryable: true });
|
|
1085
|
+
}
|
|
1086
|
+
if (header.length > columns) {
|
|
1087
|
+
await batchUpdate("add columns", [
|
|
1088
|
+
{ appendDimension: { sheetId: sheet.sheetId, dimension: "COLUMNS", length: header.length - columns } }
|
|
1089
|
+
]);
|
|
1090
|
+
columns = header.length;
|
|
1091
|
+
}
|
|
916
1092
|
await call("write header", `${range("A1")}?valueInputOption=RAW`, {
|
|
917
1093
|
method: "PUT",
|
|
918
1094
|
body: JSON.stringify({ values: [header] })
|
|
@@ -927,6 +1103,7 @@ function sheetsDestination(options) {
|
|
|
927
1103
|
}
|
|
928
1104
|
]);
|
|
929
1105
|
}
|
|
1106
|
+
({ used, existing } = await readHeader());
|
|
930
1107
|
}
|
|
931
1108
|
return {
|
|
932
1109
|
spreadsheet_id: spreadsheetId,
|