toolroll 0.9.34 → 0.9.36
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/CHANGELOG.md +44 -0
- package/README.md +7 -0
- package/dist/assignment.d.ts +1 -0
- package/dist/assignment.js +2 -2
- package/dist/browser-workspace.d.ts +3 -3
- package/dist/builder.js +4 -7
- package/dist/chat-acceptance.d.ts +1 -1
- package/dist/contracts/contract.d.ts +33 -5
- package/dist/contracts/contract.js +143 -16
- package/dist/contracts/flow-propose.d.ts +325 -0
- package/dist/contracts/flow-propose.js +41 -0
- package/dist/contracts/flow.d.ts +19400 -0
- package/dist/contracts/flow.js +359 -0
- package/dist/contracts/handoff.d.ts +91 -0
- package/dist/contracts/handoff.js +82 -0
- package/dist/contracts/proof.d.ts +136 -0
- package/dist/contracts/proof.js +110 -0
- package/dist/contracts/verification-receipt.d.ts +148 -0
- package/dist/contracts/verification-receipt.js +87 -0
- package/dist/demo.js +28 -11
- package/dist/desktop-update.d.ts +2 -2
- package/dist/evidence.d.ts +3 -40
- package/dist/evidence.js +5 -1
- package/dist/flow-gallery.d.ts +3 -2
- package/dist/flow-share.d.ts +10 -32
- package/dist/flow-share.js +65 -83
- package/dist/flow-starters.d.ts +2 -1
- package/dist/flow-triggers.d.ts +15 -72
- package/dist/flow-triggers.js +94 -83
- package/dist/flows.d.ts +37 -218
- package/dist/flows.js +350 -385
- package/dist/mate-tools.js +14 -44
- package/dist/mcp-connect.d.ts +3 -0
- package/dist/mcp-connect.js +16 -4
- package/dist/operate.js +31 -2
- package/dist/plan.d.ts +5 -5
- package/dist/plan.js +5 -25
- package/dist/process-recovery-eligibility.d.ts +2 -2
- package/dist/process-recovery-eligibility.js +13 -8
- package/dist/proof.d.ts +15 -91
- package/dist/proof.js +90 -249
- package/dist/result-evidence-readers.d.ts +42 -0
- package/dist/result-evidence-readers.js +91 -0
- package/dist/serve.js +8 -99
- package/dist/setup-guide.d.ts +2 -2
- package/dist/surface.js +8 -1
- package/dist/task-review-brief.d.ts +75 -0
- package/dist/task-review-brief.js +179 -0
- package/dist/text-limits.d.ts +70 -0
- package/dist/text-limits.js +70 -0
- package/dist/verification-evidence.js +24 -12
- package/package.json +1 -1
package/dist/flows.js
CHANGED
|
@@ -49,14 +49,9 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import { TEXT_LIMITS } from "./text-limits.js";
|
|
51
51
|
import { createHash } from "node:crypto";
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
export const FLOW_MERGE_METHODS = ["squash", "merge", "rebase"];
|
|
56
|
-
/** Where a choose option that ends the card leads. */
|
|
57
|
-
export const FLOW_END = "end";
|
|
58
|
-
/** How many options a choose zone offers. */
|
|
59
|
-
export const CHOICES_MIN = 2, CHOICES_MAX = 4;
|
|
52
|
+
import { parseContract, readVersioned } from "./contracts/contract.js";
|
|
53
|
+
import { CHOICES_MAX, CHOICES_MIN, FLOW_ALIASES, FLOW_COLORS, FLOW_DEFINITION_VERSION, FLOW_END, FLOW_MERGE_METHODS, FLOW_STAGE_KINDS, flowDefinitionSchema, flowStepsSchema, HEADERS_MAX, LONGEST_WAIT_MINUTES, savedFlowDefinitionSchema, SCRIPT_LANGUAGES, SCRIPT_NAME, SURE_AT_MAX, SURE_AT_MIN, ZONE_ID, } from "./contracts/flow.js";
|
|
54
|
+
export { CHOICES_MAX, CHOICES_MIN, FLOW_COLORS, FLOW_END, FLOW_MERGE_METHODS, FLOW_STAGE_KINDS, LONGEST_WAIT_MINUTES, SCRIPT_LANGUAGES, SCRIPT_NAME };
|
|
60
55
|
/** What each kind is called and does, in the words the canvas uses. */
|
|
61
56
|
export const FLOW_KIND_WORDS = {
|
|
62
57
|
inbox: { label: "Holding", about: "Cards wait here until someone moves them." },
|
|
@@ -78,134 +73,12 @@ export const FLOW_KIND_WORDS = {
|
|
|
78
73
|
choose: { label: "Person chooses", about: "Sends the card's owner what was done with 2 to 4 buttons you name, in their chat apps and here. Each leads to a zone or ignores the card; a reply instead goes where replies go, as the note." },
|
|
79
74
|
done: { label: "Done", about: "The end of the flow." },
|
|
80
75
|
};
|
|
81
|
-
const ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
82
|
-
/** A project script's name: short, lowercase, dashes — how zones and chat refer to it. */
|
|
83
|
-
export const SCRIPT_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
84
|
-
/** The languages a project script is written in (v90). */
|
|
85
|
-
export const SCRIPT_LANGUAGES = ["shell", "python", "node"];
|
|
86
76
|
export const LANGUAGE_WORDS = { shell: "Shell", python: "Python", node: "Node" };
|
|
87
|
-
const text = (value, cap) => {
|
|
88
|
-
if (value === null || value === undefined)
|
|
89
|
-
return null;
|
|
90
|
-
if (typeof value !== "string")
|
|
91
|
-
throw new Error("Zone text must be plain text.");
|
|
92
|
-
const trimmed = value.trim();
|
|
93
|
-
if (trimmed.length > cap)
|
|
94
|
-
throw new Error(`Zone text is up to ${cap} characters.`);
|
|
95
|
-
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(trimmed))
|
|
96
|
-
throw new Error("Zone text cannot contain control characters.");
|
|
97
|
-
return trimmed === "" ? null : trimmed;
|
|
98
|
-
};
|
|
99
|
-
const coordinate = (value, min, max, fallback) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(max, Math.max(min, value))) : fallback;
|
|
100
77
|
/** How an answer is named to Jev: its words as a short key. */
|
|
101
78
|
export const sortKeyOf = (answer) => answer.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "answer";
|
|
102
79
|
/** The default for "sure enough to act alone". */
|
|
103
80
|
export const SORT_SURE_AT = 0.8;
|
|
104
|
-
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
105
81
|
const HEADER = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
|
|
106
|
-
/** A web request's settings, checked: an http(s) address whose scheme and host are written out, not filled in. */
|
|
107
|
-
function validateRequest(input, title) {
|
|
108
|
-
const raw = (input ?? {});
|
|
109
|
-
const method = METHODS.includes(raw["method"]) ? raw["method"] : "POST";
|
|
110
|
-
const url = text(raw["url"], 2000);
|
|
111
|
-
if (url === null)
|
|
112
|
-
throw new Error(`Zone ${title}: give the address it calls.`);
|
|
113
|
-
const origin = /^(https?):\/\/([^/?#]*)/i.exec(url);
|
|
114
|
-
if (origin === null)
|
|
115
|
-
throw new Error(`Zone ${title}: the address must start with https:// or http://.`);
|
|
116
|
-
if (origin[2].includes("{{") || origin[2].includes("@") || origin[2] === "")
|
|
117
|
-
throw new Error(`Zone ${title}: write the address's host out in full; fill-ins go after it.`);
|
|
118
|
-
const headers = {};
|
|
119
|
-
for (const [name, value] of Object.entries((raw["headers"] ?? {}))) {
|
|
120
|
-
if (!HEADER.test(name))
|
|
121
|
-
throw new Error(`Zone ${title}: “${name}” isn't a header name.`);
|
|
122
|
-
const said = text(value, 500);
|
|
123
|
-
if (said !== null)
|
|
124
|
-
headers[name] = said;
|
|
125
|
-
}
|
|
126
|
-
if (Object.keys(headers).length > 10)
|
|
127
|
-
throw new Error(`Zone ${title}: up to 10 headers.`);
|
|
128
|
-
return { method, url, headers, body: method === "GET" || method === "DELETE" ? null : text(raw["body"], 8000) };
|
|
129
|
-
}
|
|
130
|
-
/** An email's settings, checked. */
|
|
131
|
-
function validateEmail(input, title) {
|
|
132
|
-
const raw = (input ?? {});
|
|
133
|
-
const to = text(raw["to"], 500), subject = text(raw["subject"], 200), body = text(raw["body"], 8000);
|
|
134
|
-
if (to === null)
|
|
135
|
-
throw new Error(`Zone ${title}: say who it goes to, for example {{card.email}}.`);
|
|
136
|
-
if (subject === null)
|
|
137
|
-
throw new Error(`Zone ${title}: give the email a subject.`);
|
|
138
|
-
if (body === null)
|
|
139
|
-
throw new Error(`Zone ${title}: write what the email says.`);
|
|
140
|
-
return { to, subject, body };
|
|
141
|
-
}
|
|
142
|
-
/** A tool call's settings, checked: the arguments are a JSON object. */
|
|
143
|
-
function validateTool(input, title) {
|
|
144
|
-
const raw = (input ?? {});
|
|
145
|
-
const server = text(raw["server"], 64), name = text(raw["name"], 100);
|
|
146
|
-
if (server === null || name === null)
|
|
147
|
-
throw new Error(`Zone ${title}: choose the tool it uses.`);
|
|
148
|
-
const args = text(raw["args"], 4000) ?? "{}";
|
|
149
|
-
let parsed;
|
|
150
|
-
try {
|
|
151
|
-
parsed = JSON.parse(args);
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
throw new Error(`Zone ${title}: the arguments aren't valid JSON.`);
|
|
155
|
-
}
|
|
156
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
157
|
-
throw new Error(`Zone ${title}: the arguments are a JSON object, like {"text": "{{stage.draft}}"}.`);
|
|
158
|
-
return { server, name, args };
|
|
159
|
-
}
|
|
160
|
-
/** A sort zone's settings, checked. Answers name zones by id; `ids` says which exist. */
|
|
161
|
-
function validateSort(input, title) {
|
|
162
|
-
const raw = (input ?? {});
|
|
163
|
-
const question = text(raw["question"], 300);
|
|
164
|
-
if (question === null)
|
|
165
|
-
throw new Error(`Zone ${title}: write the question it sorts by.`);
|
|
166
|
-
const answers = Array.isArray(raw["answers"]) ? raw["answers"] : [];
|
|
167
|
-
if (answers.length < 2 || answers.length > 12)
|
|
168
|
-
throw new Error(`Zone ${title}: give it 2 to 12 answers.`);
|
|
169
|
-
const keys = new Set();
|
|
170
|
-
const checked = answers.map(one => {
|
|
171
|
-
const answer = one;
|
|
172
|
-
const name = text(answer["answer"], 40);
|
|
173
|
-
if (name === null)
|
|
174
|
-
throw new Error(`Zone ${title}: every answer needs a name.`);
|
|
175
|
-
const means = text(answer["means"], 200) ?? name;
|
|
176
|
-
const to = text(answer["to"], 32);
|
|
177
|
-
if (to === null)
|
|
178
|
-
throw new Error(`Zone ${title}: say where “${name}” goes.`);
|
|
179
|
-
const key = sortKeyOf(name);
|
|
180
|
-
if (keys.has(key))
|
|
181
|
-
throw new Error(`Zone ${title}: two answers are called ${name}.`);
|
|
182
|
-
keys.add(key);
|
|
183
|
-
return { answer: name, means, to };
|
|
184
|
-
});
|
|
185
|
-
const sureAt = typeof raw["sureAt"] === "number" && Number.isFinite(raw["sureAt"]) ? Math.round(Math.min(0.99, Math.max(0.5, raw["sureAt"])) * 100) / 100 : SORT_SURE_AT;
|
|
186
|
-
const notes = Array.isArray(raw["notes"]) ? raw["notes"] : [];
|
|
187
|
-
if (notes.length > 3)
|
|
188
|
-
throw new Error(`Zone ${title}: it can note up to 3 other things.`);
|
|
189
|
-
const noteIds = new Set(["route"]);
|
|
190
|
-
const checkedNotes = notes.map(one => {
|
|
191
|
-
const note = one;
|
|
192
|
-
const noteQuestion = text(note["question"], 300);
|
|
193
|
-
if (noteQuestion === null)
|
|
194
|
-
throw new Error(`Zone ${title}: every extra note needs a question.`);
|
|
195
|
-
const kind = note["kind"] === "score" ? "score" : "yes-no";
|
|
196
|
-
let id = typeof note["id"] === "string" && ID.test(note["id"]) ? note["id"] : sortKeyOf(noteQuestion).slice(0, 24);
|
|
197
|
-
for (let n = 2; noteIds.has(id); n++)
|
|
198
|
-
id = `${sortKeyOf(noteQuestion).slice(0, 20)}-${n}`;
|
|
199
|
-
noteIds.add(id);
|
|
200
|
-
const levels = kind === "score" ? (Array.isArray(note["levels"]) ? note["levels"] : []).map(level => text(level, 120)).filter((level) => level !== null) : null;
|
|
201
|
-
if (levels !== null && (levels.length < 2 || levels.length > 10))
|
|
202
|
-
throw new Error(`Zone ${title}: a score needs 2 to 10 levels, lowest first.`);
|
|
203
|
-
return { id, kind, question: noteQuestion, levels };
|
|
204
|
-
});
|
|
205
|
-
return { question, answers: checked, sureAt, notes: checkedNotes };
|
|
206
|
-
}
|
|
207
|
-
/** The longest a zone waits or a limit runs: 30 days. */
|
|
208
|
-
export const LONGEST_WAIT_MINUTES = 30 * 24 * 60;
|
|
209
82
|
const UNITS = [[/^(m|min|mins|minute|minutes)$/, 1], [/^(h|hr|hrs|hour|hours)$/, 60], [/^(d|day|days)$/, 24 * 60], [/^(w|wk|wks|week|weeks)$/, 7 * 24 * 60]];
|
|
210
83
|
/** "3 days", "4 hours", "90 minutes", "1 week" (or a number of minutes) as minutes; null when it isn't one. */
|
|
211
84
|
export function durationMinutes(value) {
|
|
@@ -228,29 +101,12 @@ export function durationWords(minutes) {
|
|
|
228
101
|
const days = Math.floor(minutes / (24 * 60)), hours = Math.floor((minutes % (24 * 60)) / 60), rest = minutes % 60;
|
|
229
102
|
return [days > 0 ? plural(days, "day") : "", hours > 0 ? plural(hours, "hour") : "", rest > 0 ? plural(rest, "minute") : ""].filter(one => one !== "").join(" ");
|
|
230
103
|
}
|
|
231
|
-
/** A Wait zone's settings, checked. */
|
|
232
|
-
function validateWait(input, title) {
|
|
233
|
-
const raw = (input ?? {});
|
|
234
|
-
if (raw["for"] === "hours") {
|
|
235
|
-
const from = clockTime(raw["from"]), to = clockTime(raw["to"]);
|
|
236
|
-
if (from === null || to === null || from === to)
|
|
237
|
-
throw new Error(`Zone ${title}: say the hours it waits for, like 22:00 to 06:00.`);
|
|
238
|
-
const zone = typeof raw["timeZone"] === "string" && raw["timeZone"].trim() !== "" ? raw["timeZone"].trim() : null;
|
|
239
|
-
if (zone !== null && !knownTimeZone(zone))
|
|
240
|
-
throw new Error(`Zone ${title}: ${zone} isn't a time zone, like Europe/London.`);
|
|
241
|
-
return { for: "hours", minutes: 0, from, to, ...(zone === null ? {} : { timeZone: zone }) };
|
|
242
|
-
}
|
|
243
|
-
const minutes = durationMinutes(raw["minutes"]);
|
|
244
|
-
if (minutes === null)
|
|
245
|
-
throw new Error(`Zone ${title}: say how long it waits, from 1 minute to 30 days.`);
|
|
246
|
-
return { for: raw["for"] === "time" ? "time" : "reply", minutes };
|
|
247
|
-
}
|
|
248
104
|
/** "22:00", "9:30" as "HH:MM"; null when it isn't a time of day. */
|
|
249
105
|
export function clockTime(value) {
|
|
250
106
|
const match = typeof value === "string" ? /^\s*([01]?[0-9]|2[0-3]):([0-5][0-9])\s*$/.exec(value) : null;
|
|
251
107
|
return match === null ? null : `${match[1].padStart(2, "0")}:${match[2]}`;
|
|
252
108
|
}
|
|
253
|
-
function knownTimeZone(zone) {
|
|
109
|
+
export function knownTimeZone(zone) {
|
|
254
110
|
try {
|
|
255
111
|
new Intl.DateTimeFormat("en-GB", { timeZone: zone });
|
|
256
112
|
return true;
|
|
@@ -267,116 +123,278 @@ export function withinHours(wait, now) {
|
|
|
267
123
|
const from = minutesOf(wait.from), to = minutesOf(wait.to);
|
|
268
124
|
return from < to ? at >= from && at < to : at >= from || at < to;
|
|
269
125
|
}
|
|
270
|
-
/** A zone's time limit, checked; null when it has none. Only Holding and "Person decides" zones move a card on. */
|
|
271
|
-
function validateLimit(input, kind, title) {
|
|
272
|
-
if (input === undefined || input === null)
|
|
273
|
-
return null;
|
|
274
|
-
const raw = input;
|
|
275
|
-
if (kind === "wait" || kind === "done")
|
|
276
|
-
throw new Error(`Zone ${title}: ${kind === "wait" ? "a Wait zone has its own time" : "the end has no time limit"}.`);
|
|
277
|
-
const minutes = durationMinutes(raw["minutes"]);
|
|
278
|
-
if (minutes === null)
|
|
279
|
-
throw new Error(`Zone ${title}: say how long a card may wait, from 1 minute to 30 days.`);
|
|
280
|
-
const to = text(raw["to"], 32);
|
|
281
|
-
if (to !== null && kind !== "inbox" && kind !== "approval" && kind !== "choose")
|
|
282
|
-
throw new Error(`Zone ${title}: only Holding, "Person decides" and "Person chooses" zones move a card on when it waits too long; other zones remind.`);
|
|
283
|
-
return { minutes, to };
|
|
284
|
-
}
|
|
285
126
|
/** Who decides at an approval zone: its named person, the flow's owner, or null for anyone who approves on the project. */
|
|
286
127
|
export function deciderOf(stage, flow) {
|
|
287
128
|
return stage.toOwner === true ? flow.owner : stage.approver;
|
|
288
129
|
}
|
|
289
|
-
/** A flow
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
130
|
+
/** A flow refused by its contract: every problem, each naming its path (`stages[2].instructions: required`). */
|
|
131
|
+
export class FlowContractError extends Error {
|
|
132
|
+
lines;
|
|
133
|
+
constructor(lines) {
|
|
134
|
+
super(lines.join("\n"));
|
|
135
|
+
this.lines = lines;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ------------------------------------------------------------------ reading a drawing
|
|
139
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
140
|
+
/** Text as every release has kept it: trimmed, and blank as none. Anything else is left for the schema to name. */
|
|
141
|
+
const said = (value) => value === undefined || value === null ? null : typeof value === "string" ? value.trim() === "" ? null : value.trim() : value;
|
|
142
|
+
const coordinate = (value, min, max, fallback) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(max, Math.max(min, value))) : fallback;
|
|
143
|
+
const given = (value) => value !== undefined && value !== null;
|
|
144
|
+
/** A sort zone's settings as every release has kept them: an answer's meaning defaults to its name, sureAt to 0.8 (within
|
|
145
|
+
* 0.5–0.99), and each extra note gets a free id. */
|
|
146
|
+
function canonicalSort(input) {
|
|
147
|
+
const raw = isRecord(input) ? input : {};
|
|
148
|
+
const answers = Array.isArray(raw["answers"]) ? raw["answers"] : given(raw["answers"]) ? raw["answers"] : [];
|
|
149
|
+
const sureAt = typeof raw["sureAt"] === "number" && Number.isFinite(raw["sureAt"]) ? Math.round(Math.min(SURE_AT_MAX, Math.max(SURE_AT_MIN, raw["sureAt"])) * 100) / 100 : SORT_SURE_AT;
|
|
150
|
+
const noteIds = new Set(["route"]);
|
|
151
|
+
const notes = Array.isArray(raw["notes"]) ? raw["notes"].map(one => {
|
|
152
|
+
const note = isRecord(one) ? one : {};
|
|
153
|
+
const question = said(note["question"]);
|
|
154
|
+
const kind = note["kind"] === "score" ? "score" : "yes-no";
|
|
155
|
+
const words = typeof question === "string" ? question : "";
|
|
156
|
+
let id = typeof note["id"] === "string" && ZONE_ID.test(note["id"]) ? note["id"] : sortKeyOf(words).slice(0, 24);
|
|
157
|
+
for (let n = 2; noteIds.has(id); n++)
|
|
158
|
+
id = `${sortKeyOf(words).slice(0, 20)}-${n}`;
|
|
159
|
+
noteIds.add(id);
|
|
160
|
+
const levels = kind === "score" ? (Array.isArray(note["levels"]) ? note["levels"] : []).map(said).filter(level => level !== null) : null;
|
|
161
|
+
return { id, kind, question, levels };
|
|
162
|
+
}) : given(raw["notes"]) ? raw["notes"] : [];
|
|
163
|
+
return {
|
|
164
|
+
question: said(raw["question"]),
|
|
165
|
+
answers: Array.isArray(answers) ? answers.map(one => {
|
|
166
|
+
const answer = isRecord(one) ? one : {};
|
|
167
|
+
const name = said(answer["answer"]);
|
|
168
|
+
return { answer: name, means: said(answer["means"]) ?? name, to: said(answer["to"]) };
|
|
169
|
+
}) : answers,
|
|
170
|
+
sureAt, notes,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** A script's or teammate's answers as every release has kept them: trimmed, none left out. */
|
|
174
|
+
function canonicalRoutes(input) {
|
|
175
|
+
if (!given(input))
|
|
176
|
+
return undefined;
|
|
177
|
+
if (!Array.isArray(input))
|
|
178
|
+
return input;
|
|
179
|
+
if (input.length === 0)
|
|
180
|
+
return undefined;
|
|
181
|
+
return input.map(one => {
|
|
182
|
+
const row = isRecord(one) ? one : {};
|
|
183
|
+
return { answer: typeof row["answer"] === "string" ? row["answer"].trim() : row["answer"] ?? "", to: typeof row["to"] === "string" ? row["to"].trim() : row["to"] ?? "" };
|
|
338
184
|
});
|
|
185
|
+
}
|
|
186
|
+
/** A Wait zone's settings: "hours" between two times of day, or a reply or a set time for so long. */
|
|
187
|
+
function canonicalWait(input) {
|
|
188
|
+
const raw = isRecord(input) ? input : {};
|
|
189
|
+
if (raw["for"] === "hours") {
|
|
190
|
+
const zone = typeof raw["timeZone"] === "string" && raw["timeZone"].trim() !== "" ? raw["timeZone"].trim() : null;
|
|
191
|
+
return { for: "hours", minutes: 0, from: clockTime(raw["from"]) ?? raw["from"], to: clockTime(raw["to"]) ?? raw["to"], ...(zone === null ? {} : { timeZone: zone }) };
|
|
192
|
+
}
|
|
193
|
+
return { for: raw["for"] === "time" ? "time" : "reply", minutes: durationMinutes(raw["minutes"]) };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* One zone of a version 1 drawing, as every release from 0.9.0 has read it: a zone saved before a field existed gets
|
|
197
|
+
* that field's default, text is trimmed (blank is none), the canvas place is clamped, a field another kind uses is
|
|
198
|
+
* set aside, and a value no release accepted is left as it is for the schema to name.
|
|
199
|
+
*/
|
|
200
|
+
function canonicalStage(input, index) {
|
|
201
|
+
if (!isRecord(input))
|
|
202
|
+
return input;
|
|
203
|
+
const stage = input;
|
|
204
|
+
const kind = stage["kind"];
|
|
205
|
+
const zone = isRecord(stage["zone"]) ? stage["zone"] : {};
|
|
206
|
+
const planning = stage["planning"] === "required" || stage["planning"] === "skip" || stage["planning"] === "auto" ? stage["planning"] : null;
|
|
207
|
+
const runIn = stage["runIn"] === undefined || stage["runIn"] === null ? undefined : stage["runIn"];
|
|
208
|
+
const routes = canonicalRoutes(stage["routes"]);
|
|
209
|
+
const secrets = !given(stage["secrets"]) ? undefined : Array.isArray(stage["secrets"]) ? stage["secrets"].length === 0 ? undefined : stage["secrets"].every(one => typeof one === "string") ? [...new Set(stage["secrets"])] : stage["secrets"] : stage["secrets"];
|
|
210
|
+
const limit = !given(stage["limit"]) ? undefined : isRecord(stage["limit"]) ? { minutes: durationMinutes(stage["limit"]["minutes"]), to: said(stage["limit"]["to"]) } : stage["limit"];
|
|
211
|
+
const request = isRecord(stage["request"]) ? stage["request"] : {};
|
|
212
|
+
const method = ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(request["method"]) ? request["method"] : "POST";
|
|
213
|
+
const email = isRecord(stage["email"]) ? stage["email"] : {};
|
|
214
|
+
const tool = isRecord(stage["tool"]) ? stage["tool"] : {};
|
|
215
|
+
return {
|
|
216
|
+
id: stage["id"], title: said(stage["title"]), kind,
|
|
217
|
+
zone: { x: coordinate(zone["x"], -20000, 20000, index * 320), y: coordinate(zone["y"], -20000, 20000, 0), w: coordinate(zone["w"], 220, 1200, 280), h: coordinate(zone["h"], 160, 1600, 360), color: FLOW_COLORS.includes(zone["color"]) ? zone["color"] : "slate" },
|
|
218
|
+
instructions: said(stage["instructions"]),
|
|
219
|
+
planning: kind === "task" ? planning ?? "auto" : null,
|
|
220
|
+
approver: kind === "approval" && stage["toOwner"] !== true ? said(stage["approver"]) : null,
|
|
221
|
+
...(kind === "approval" && stage["toOwner"] === true ? { toOwner: true } : {}),
|
|
222
|
+
message: said(stage["message"]),
|
|
223
|
+
close: kind === "update" ? stage["close"] !== false : null,
|
|
224
|
+
script: kind === "check" ? said(stage["script"]) : null,
|
|
225
|
+
// Left out, a script runs where every script ran before v90: in a copy of the card's work.
|
|
226
|
+
...(kind === "check" ? { ...(runIn === undefined ? {} : { runIn }), ...(routes === undefined ? {} : { routes }), ...(secrets === undefined ? {} : { secrets }) } : {}),
|
|
227
|
+
sort: kind === "sort" ? canonicalSort(stage["sort"]) : null,
|
|
228
|
+
...(kind === "request" ? { request: { method, url: said(request["url"]), headers: Object.fromEntries(Object.entries(isRecord(request["headers"]) ? request["headers"] : {}).flatMap(([name, value]) => { const one = said(value); return one === null ? [] : [[name, one]]; })), body: method === "GET" || method === "DELETE" ? null : said(request["body"]) } } : {}),
|
|
229
|
+
...(kind === "email" ? { email: { to: said(email["to"]), subject: said(email["subject"]), body: said(email["body"]) } } : {}),
|
|
230
|
+
...(kind === "tool" ? { tool: { server: said(tool["server"]), name: said(tool["name"]), args: said(tool["args"]) ?? "{}" } } : {}),
|
|
231
|
+
...(kind === "wait" ? { wait: canonicalWait(stage["wait"]) } : {}),
|
|
232
|
+
// true is a squash, the default; false or none is no merge.
|
|
233
|
+
...(kind === "pull-request" && given(stage["merge"]) && stage["merge"] !== false ? { merge: stage["merge"] === true ? "squash" : stage["merge"] } : {}),
|
|
234
|
+
...((kind === "approval" || kind === "teammate") && given(stage["teammate"]) && stage["teammate"] !== "" ? { teammate: stage["teammate"] } : {}),
|
|
235
|
+
...(kind === "teammate" && routes !== undefined ? { routes } : {}),
|
|
236
|
+
...(kind === "teammate" && stage["reply"] === true ? { reply: true } : {}),
|
|
237
|
+
...(kind === "choose" ? { options: Array.isArray(stage["options"]) ? stage["options"].map(one => { const row = isRecord(one) ? one : {}; return { label: said(row["label"]), to: said(row["to"]) }; }) : given(stage["options"]) ? stage["options"] : [] } : {}),
|
|
238
|
+
...(kind === "task" && given(stage["repo"]) && stage["repo"] !== "" ? { repo: said(stage["repo"]) } : {}),
|
|
239
|
+
...(limit === undefined ? {} : { limit }),
|
|
240
|
+
next: kind === "sort" || kind === "choose" ? null : said(stage["next"]),
|
|
241
|
+
onFail: said(stage["onFail"]),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/** A version 1 drawing as every release has read it (canonicalStage); its start is the first zone unless it names one. */
|
|
245
|
+
function canonicalDefinition(input) {
|
|
246
|
+
const stages = Array.isArray(input["stages"]) ? input["stages"].map(canonicalStage) : input["stages"];
|
|
247
|
+
const ids = Array.isArray(stages) ? stages.map(one => (isRecord(one) ? one["id"] : undefined)) : [];
|
|
248
|
+
const start = typeof input["start"] === "string" && ids.includes(input["start"]) ? input["start"] : ids[0];
|
|
249
|
+
return { version: Object.prototype.hasOwnProperty.call(input, "version") ? input["version"] : FLOW_DEFINITION_VERSION, start, stages };
|
|
250
|
+
}
|
|
251
|
+
const issue = (path, what, kind = "invalid") => ({ path, kind, line: `${path}: ${what}` });
|
|
252
|
+
/** What JSON Schema can't say about a drawing, each named by its path: paths lead to zones that exist and never back into
|
|
253
|
+
* their own zone, answers and options have names of their own, a web address's host is written out, tool arguments are a
|
|
254
|
+
* JSON object, an hours wait's times differ in a real time zone, and a merge comes after a person's decision. */
|
|
255
|
+
export function drawingProblems(definition) {
|
|
256
|
+
const problems = [];
|
|
339
257
|
const ids = new Set();
|
|
340
|
-
|
|
258
|
+
definition.stages.forEach((stage, index) => {
|
|
341
259
|
if (ids.has(stage.id))
|
|
342
|
-
|
|
260
|
+
problems.push(issue(`stages[${index}].id`, `two zones are called ${stage.id}`));
|
|
343
261
|
ids.add(stage.id);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (stage.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
262
|
+
});
|
|
263
|
+
definition.stages.forEach((stage, index) => {
|
|
264
|
+
const at = `stages[${index}]`;
|
|
265
|
+
const target = (path, to) => {
|
|
266
|
+
if (to !== null && !ids.has(to))
|
|
267
|
+
problems.push(issue(`${at}.${path}`, `there's no zone called ${to}`));
|
|
268
|
+
else if (to === stage.id && path !== "next" && path !== "onFail")
|
|
269
|
+
problems.push(issue(`${at}.${path}`, "can't lead back into the same zone"));
|
|
270
|
+
};
|
|
271
|
+
target("next", stage.next);
|
|
272
|
+
target("onFail", stage.onFail);
|
|
273
|
+
if (stage.limit !== undefined)
|
|
274
|
+
target("limit.to", stage.limit.to);
|
|
275
|
+
stage.sort?.answers.forEach((one, n) => target(`sort.answers[${n}].to`, one.to));
|
|
276
|
+
stage.routes?.forEach((one, n) => target(`routes[${n}].to`, one.to));
|
|
277
|
+
stage.options?.forEach((one, n) => {
|
|
278
|
+
if (one.to !== FLOW_END)
|
|
279
|
+
target(`options[${n}].to`, one.to);
|
|
280
|
+
else if (ids.has(FLOW_END))
|
|
281
|
+
problems.push(issue(`${at}.options[${n}].to`, `an option that ends the card can't be told apart from the zone called ${FLOW_END}; rename that zone`));
|
|
282
|
+
});
|
|
283
|
+
const twice = (path, names, key) => {
|
|
284
|
+
const seen = new Set();
|
|
285
|
+
names.forEach((name, n) => {
|
|
286
|
+
if (seen.has(key(name)))
|
|
287
|
+
problems.push(issue(`${at}.${path}[${n}]`, `two are called ${name}`));
|
|
288
|
+
seen.add(key(name));
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
if (stage.sort !== null)
|
|
292
|
+
twice("sort.answers", stage.sort.answers.map(one => one.answer), sortKeyOf);
|
|
293
|
+
if (stage.routes !== undefined)
|
|
294
|
+
twice("routes", stage.routes.map(one => one.answer), name => name.toLowerCase());
|
|
295
|
+
if (stage.options !== undefined)
|
|
296
|
+
twice("options", stage.options.map(one => one.label), name => name.toLowerCase());
|
|
297
|
+
if (stage.request !== undefined) {
|
|
298
|
+
const origin = /^(https?):\/\/([^/?#]*)/i.exec(stage.request.url);
|
|
299
|
+
if (origin === null)
|
|
300
|
+
problems.push(issue(`${at}.request.url`, "must start with https:// or http://"));
|
|
301
|
+
else if (origin[2].includes("{{") || origin[2].includes("@") || origin[2] === "")
|
|
302
|
+
problems.push(issue(`${at}.request.url`, "write the address's host out in full; fill-ins go after it"));
|
|
303
|
+
const names = Object.keys(stage.request.headers);
|
|
304
|
+
for (const name of names)
|
|
305
|
+
if (!HEADER.test(name))
|
|
306
|
+
problems.push(issue(`${at}.request.headers`, `“${name}” isn't a header name`));
|
|
307
|
+
if (names.length > HEADERS_MAX)
|
|
308
|
+
problems.push(issue(`${at}.request.headers`, `at most ${HEADERS_MAX} headers`, "too-many"));
|
|
309
|
+
}
|
|
310
|
+
if (stage.tool !== undefined) {
|
|
311
|
+
let args;
|
|
312
|
+
try {
|
|
313
|
+
args = JSON.parse(stage.tool.args);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
args = undefined;
|
|
317
|
+
}
|
|
318
|
+
if (!isRecord(args))
|
|
319
|
+
problems.push(issue(`${at}.tool.args`, `must be a JSON object, like {"text": "{{stage.draft}}"}`));
|
|
320
|
+
}
|
|
321
|
+
if (stage.wait !== undefined && (stage.wait.for === "hours") !== (stage.wait.minutes === 0))
|
|
322
|
+
problems.push(issue(`${at}.wait.minutes`, stage.wait.for === "hours" ? "must be 0: an hours wait waits for the clock" : "say how long it waits, from 1 minute to 30 days"));
|
|
323
|
+
if (stage.wait !== undefined && (stage.wait.for === "hours") !== (stage.wait.from !== undefined && stage.wait.to !== undefined))
|
|
324
|
+
problems.push(issue(`${at}.wait`, stage.wait.for === "hours" ? "say the hours it waits for, like 22:00 to 06:00" : "only an hours wait has from and to"));
|
|
325
|
+
if (stage.wait?.for === "hours") {
|
|
326
|
+
if (stage.wait.from === stage.wait.to)
|
|
327
|
+
problems.push(issue(`${at}.wait.to`, "must differ from from, like 22:00 to 06:00"));
|
|
328
|
+
if (stage.wait.timeZone !== undefined && !knownTimeZone(stage.wait.timeZone))
|
|
329
|
+
problems.push(issue(`${at}.wait.timeZone`, `${stage.wait.timeZone} isn't a time zone, like Europe/London`));
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
if (problems.length > 0)
|
|
333
|
+
return problems;
|
|
373
334
|
// A zone that merges comes after a person's decision on every path to it: never merged without one.
|
|
374
|
-
const unapproved = reachableWithout(stages, start, one => one.kind === "approval");
|
|
375
|
-
|
|
335
|
+
const unapproved = reachableWithout(definition.stages, definition.start, one => one.kind === "approval");
|
|
336
|
+
definition.stages.forEach((stage, index) => {
|
|
376
337
|
if (stage.merge !== undefined && unapproved.has(stage.id))
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
return
|
|
338
|
+
problems.push(issue(`stages[${index}].merge`, "a “Person decides” zone must come before it on every path"));
|
|
339
|
+
});
|
|
340
|
+
return problems;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Read a flow's drawing: the canvas's, a step list's once built, or one saved earlier (`stored`, which keeps working under
|
|
344
|
+
* rules added since: its instructions keep the length they were saved with). A version 1 drawing is read as every release
|
|
345
|
+
* has read it (canonicalStage), then the schema checks it, then drawingProblems; a newer version is refused plainly.
|
|
346
|
+
*/
|
|
347
|
+
export function readFlowDefinition(input, options = {}) {
|
|
348
|
+
const body = isRecord(input) ? canonicalDefinition(input) : input;
|
|
349
|
+
const read = readVersioned(options.stored === true ? savedFlowDefinitionSchema : flowDefinitionSchema, body, {}, FLOW_ALIASES);
|
|
350
|
+
// The start is the first zone unless a zone it names exists: a problem with it is a problem with the zones, said there.
|
|
351
|
+
if (!read.ok)
|
|
352
|
+
return { ok: false, issues: read.issues.some(one => one.path.startsWith("stages")) ? read.issues.filter(one => one.path !== "start") : read.issues };
|
|
353
|
+
const problems = drawingProblems(read.value);
|
|
354
|
+
return problems.length === 0 ? read : { ok: false, issues: problems };
|
|
355
|
+
}
|
|
356
|
+
/** The saved drawing's fields in the words steps and flow files use for them. */
|
|
357
|
+
const STEP_WORDS = [
|
|
358
|
+
[/^tool\.args/, "args"], [/^tool\.server/, "server"], [/^tool\.name/, "tool"], [/^request\.(url|headers|body|method)/, "$1"], [/^email\.(to|subject|body)/, "$1"],
|
|
359
|
+
[/^sort\.answers\[(\d+)\]\.to/, "answers[$1].goesTo"], [/^sort\.answers/, "answers"], [/^sort\.question/, "question"], [/^sort\.sureAt/, "sureAt"], [/^sort\.notes/, "alsoNote"],
|
|
360
|
+
[/^(routes|options)\[(\d+)\]\.to/, "$1[$2].goesTo"], [/^limit\.to/, "thenMoveTo"], [/^limit(\.minutes)?/, "remindAfter"], [/^wait\.to/, "until"], [/^wait\.(from|timeZone)/, "$1"], [/^wait(\.minutes)?/, "wait"],
|
|
361
|
+
[/^approver/, "decider"], [/^toOwner/, "decider"],
|
|
362
|
+
];
|
|
363
|
+
/** A refusal of the drawing a step list or flow file became, in its words: `stages[2].sort.answers[0].to` is
|
|
364
|
+
* `steps[2].answers[0].goesTo` (each zone is the step at its index), and a zone's failure path is the step's own name for it. */
|
|
365
|
+
export function inStepWords(line, list, kinds) {
|
|
366
|
+
const match = /^stages\[(\d+)\](?:\.([^:]*))?:(.*)$/s.exec(line);
|
|
367
|
+
if (match === null)
|
|
368
|
+
return line;
|
|
369
|
+
const index = Number(match[1]), kind = kinds[index];
|
|
370
|
+
let field = match[2] ?? "";
|
|
371
|
+
if (field === "onFail")
|
|
372
|
+
field = kind === "sort" ? "ifNotSure" : kind === "wait" ? "ifNoReply" : kind === "choose" ? "ifReplied" : "ifFails";
|
|
373
|
+
else
|
|
374
|
+
for (const [from, to] of STEP_WORDS)
|
|
375
|
+
if (from.test(field)) {
|
|
376
|
+
field = field.replace(from, to);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
return `${list}[${index}]${field === "" ? "" : `.${field}`}:${match[3]}`;
|
|
380
|
+
}
|
|
381
|
+
/** A flow as drawn on the canvas, checked whole (readFlowDefinition). Throws FlowContractError, naming every path. */
|
|
382
|
+
export function validateFlowDefinition(input, options = {}) {
|
|
383
|
+
const read = readFlowDefinition(input, options);
|
|
384
|
+
if (!read.ok)
|
|
385
|
+
throw new FlowContractError(read.issues.map(one => one.line));
|
|
386
|
+
return read.value;
|
|
387
|
+
}
|
|
388
|
+
/** A drawing's refusal as the canvas shows it: each line as it is, after the name of the zone it is about, so a person
|
|
389
|
+
* finds it on the canvas (`Build · stages[1].instructions: required`). */
|
|
390
|
+
export function withZoneNames(lines, input) {
|
|
391
|
+
const stages = isRecord(input) && Array.isArray(input["stages"]) ? input["stages"] : [];
|
|
392
|
+
return lines.map(line => {
|
|
393
|
+
const index = /^stages\[(\d+)\]/.exec(line)?.[1];
|
|
394
|
+
const stage = index === undefined ? undefined : stages[Number(index)];
|
|
395
|
+
const title = isRecord(stage) && typeof stage["title"] === "string" && stage["title"].trim() !== "" ? stage["title"].trim() : null;
|
|
396
|
+
return title === null ? line : `${title} · ${line}`;
|
|
397
|
+
}).join("\n");
|
|
380
398
|
}
|
|
381
399
|
/** The instructions 0.9.26 and earlier read from a saved zone: they re-check this length on every read, and a flow with a
|
|
382
400
|
* longer one won't load. A saved zone keeps its first part under it, and the rest in `instructionsMore`, which they ignore. */
|
|
@@ -431,14 +449,6 @@ export function flowDefinitionFromStore(json) {
|
|
|
431
449
|
});
|
|
432
450
|
return JSON.stringify({ ...raw, stages: whole });
|
|
433
451
|
}
|
|
434
|
-
/** A Pull request zone's merge: true is squash, the default. */
|
|
435
|
-
function validateMerge(value, title) {
|
|
436
|
-
if (value === true)
|
|
437
|
-
return "squash";
|
|
438
|
-
if (typeof value === "string" && FLOW_MERGE_METHODS.includes(value))
|
|
439
|
-
return value;
|
|
440
|
-
throw new Error(`Zone ${title}: merge by squash, merge or rebase.`);
|
|
441
|
-
}
|
|
442
452
|
/** The zones a card can reach from `start` without passing through a zone `stop` picks (those zones aren't included). */
|
|
443
453
|
export function reachableWithout(stages, start, stop) {
|
|
444
454
|
const seen = new Set();
|
|
@@ -461,82 +471,6 @@ export function choiceTargets(stage) {
|
|
|
461
471
|
export function replyTarget(stage) {
|
|
462
472
|
return stage.onFail ?? choiceTargets(stage)[0] ?? null;
|
|
463
473
|
}
|
|
464
|
-
/** A choose zone's options, checked: 2 to 4, each with short words of its own and where it leads. */
|
|
465
|
-
function validateChoices(input, title) {
|
|
466
|
-
const raw = Array.isArray(input) ? input : [];
|
|
467
|
-
if (raw.length < CHOICES_MIN || raw.length > CHOICES_MAX)
|
|
468
|
-
throw new Error(`Zone ${title}: give it ${CHOICES_MIN} to ${CHOICES_MAX} options.`);
|
|
469
|
-
const seen = new Set();
|
|
470
|
-
return raw.map(one => {
|
|
471
|
-
const row = (one ?? {});
|
|
472
|
-
const label = text(row["label"], 40);
|
|
473
|
-
const to = text(row["to"], 32);
|
|
474
|
-
if (label === null || to === null)
|
|
475
|
-
throw new Error(`Zone ${title}: each option needs a few words and where it leads.`);
|
|
476
|
-
if (seen.has(label.toLowerCase()))
|
|
477
|
-
throw new Error(`Zone ${title}: two options are called ${label}.`);
|
|
478
|
-
seen.add(label.toLowerCase());
|
|
479
|
-
return { label, to };
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
/** A build zone's project, when it isn't the flow's: its path. Whether the flow's owner may file there is checked when work is filed. */
|
|
483
|
-
function validateRepo(value, title) {
|
|
484
|
-
const repo = text(value, 1000);
|
|
485
|
-
if (repo === null)
|
|
486
|
-
throw new Error(`Zone ${title}: choose the project it builds in.`);
|
|
487
|
-
return repo;
|
|
488
|
-
}
|
|
489
|
-
/** A teammate's handle on a zone (v92): which teammate works it. Whether it exists is the project's to say, when a card arrives. */
|
|
490
|
-
function validateTeammate(value, title) {
|
|
491
|
-
if (typeof value !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(value))
|
|
492
|
-
throw new Error(`Zone ${title}: choose a teammate by its short name, like maya.`);
|
|
493
|
-
return value;
|
|
494
|
-
}
|
|
495
|
-
/** The answers a zone may pick, each with the zone it leads to (a script's "goto:" line, or a teammate's choice). */
|
|
496
|
-
function validateRoutes(stage, title) {
|
|
497
|
-
const rawRoutes = stage["routes"] === undefined || stage["routes"] === null ? [] : stage["routes"];
|
|
498
|
-
if (!Array.isArray(rawRoutes) || rawRoutes.length > 12)
|
|
499
|
-
throw new Error(`Zone ${title}: pick from up to 12 answers.`);
|
|
500
|
-
const seen = new Set();
|
|
501
|
-
const routes = rawRoutes.map(one => {
|
|
502
|
-
const row = (one ?? {});
|
|
503
|
-
const answer = typeof row["answer"] === "string" ? row["answer"].trim() : "";
|
|
504
|
-
const to = typeof row["to"] === "string" ? row["to"].trim() : "";
|
|
505
|
-
if (!/^[^\n]{1,40}$/.test(answer) || to === "")
|
|
506
|
-
throw new Error(`Zone ${title}: each answer needs a short name and the zone it leads to.`);
|
|
507
|
-
if (seen.has(answer.toLowerCase()))
|
|
508
|
-
throw new Error(`Zone ${title}: two answers are called ${answer}.`);
|
|
509
|
-
seen.add(answer.toLowerCase());
|
|
510
|
-
return { answer, to };
|
|
511
|
-
});
|
|
512
|
-
return routes.length === 0 ? {} : { routes };
|
|
513
|
-
}
|
|
514
|
-
/** A code zone's settings (v90): where it runs, its answers, and its secrets. Throws in plain words. */
|
|
515
|
-
function validateCode(stage, title) {
|
|
516
|
-
// Left out, it runs where every script ran before v90: in a copy of the card's work (and the zone's digest stays the same).
|
|
517
|
-
const runIn = stage["runIn"] === "folder" || stage["runIn"] === "copy" ? stage["runIn"] : stage["runIn"] === undefined || stage["runIn"] === null ? undefined : null;
|
|
518
|
-
if (runIn === null)
|
|
519
|
-
throw new Error(`Zone ${title}: choose where the script runs: a clean folder or a copy of the card's work.`);
|
|
520
|
-
const rawRoutes = stage["routes"] === undefined || stage["routes"] === null ? [] : stage["routes"];
|
|
521
|
-
if (!Array.isArray(rawRoutes) || rawRoutes.length > 12)
|
|
522
|
-
throw new Error(`Zone ${title}: a script picks from up to 12 answers.`);
|
|
523
|
-
const seen = new Set();
|
|
524
|
-
const routes = rawRoutes.map(one => {
|
|
525
|
-
const row = (one ?? {});
|
|
526
|
-
const answer = typeof row["answer"] === "string" ? row["answer"].trim() : "";
|
|
527
|
-
const to = typeof row["to"] === "string" ? row["to"].trim() : "";
|
|
528
|
-
if (!/^[^\n]{1,40}$/.test(answer) || to === "")
|
|
529
|
-
throw new Error(`Zone ${title}: each answer needs a short name and the zone it leads to.`);
|
|
530
|
-
if (seen.has(answer.toLowerCase()))
|
|
531
|
-
throw new Error(`Zone ${title}: two answers are called ${answer}.`);
|
|
532
|
-
seen.add(answer.toLowerCase());
|
|
533
|
-
return { answer, to };
|
|
534
|
-
});
|
|
535
|
-
const rawSecrets = stage["secrets"] === undefined || stage["secrets"] === null ? [] : stage["secrets"];
|
|
536
|
-
if (!Array.isArray(rawSecrets) || rawSecrets.length > 10 || rawSecrets.some(one => typeof one !== "string" || !/^[A-Z][A-Z0-9_]{0,39}$/.test(one)))
|
|
537
|
-
throw new Error(`Zone ${title}: name up to 10 saved secrets in capitals, like API_TOKEN.`);
|
|
538
|
-
return { ...(runIn === undefined ? {} : { runIn }), ...(routes.length === 0 ? {} : { routes }), ...(rawSecrets.length === 0 ? {} : { secrets: [...new Set(rawSecrets)] }) };
|
|
539
|
-
}
|
|
540
474
|
/** What a card's work is held to: the zones' steps and paths, never where they sit on the canvas. */
|
|
541
475
|
export function flowDigest(definition) {
|
|
542
476
|
const terms = definition.stages.map(({ zone: _zone, ...rest }) => rest);
|
|
@@ -637,28 +571,29 @@ const idOf = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").r
|
|
|
637
571
|
const slugOf = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 28) || "zone";
|
|
638
572
|
const overlaps = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
639
573
|
/** A wait step's settings: what it waits for and how long (3 days when neither the step nor the zone it keeps says). */
|
|
640
|
-
function waitFromStep(step, old,
|
|
574
|
+
function waitFromStep(step, old, at) {
|
|
641
575
|
if ((step.waitFor ?? old?.for) === "hours") {
|
|
642
576
|
const from = clockTime(step.from ?? old?.from ?? "22:00"), to = clockTime(step.until ?? old?.to ?? "06:00");
|
|
643
577
|
if (from === null || to === null)
|
|
644
|
-
throw new
|
|
645
|
-
|
|
578
|
+
throw new FlowContractError([`${at}.from: say the hours it waits for, like from 22:00 until 06:00`]);
|
|
579
|
+
const timeZone = step.timeZone ?? old?.timeZone;
|
|
580
|
+
return { for: "hours", minutes: 0, from, to, ...(timeZone === undefined ? {} : { timeZone }) };
|
|
646
581
|
}
|
|
647
582
|
const minutes = step.wait === undefined ? old?.minutes ?? 3 * 24 * 60 : durationMinutes(step.wait);
|
|
648
583
|
if (minutes === null)
|
|
649
|
-
throw new
|
|
584
|
+
throw new FlowContractError([`${at}.wait: say how long it waits, like "3 days" or "4 hours" (up to 30 days)`]);
|
|
650
585
|
return { for: step.waitFor ?? old?.for ?? "reply", minutes };
|
|
651
586
|
}
|
|
652
587
|
/** A step's time limit: the one it gives, the one its zone had, or none ("none" removes one). */
|
|
653
|
-
function limitFromStep(step, old,
|
|
588
|
+
function limitFromStep(step, old, at, find) {
|
|
654
589
|
if (step.remindAfter === undefined)
|
|
655
|
-
return old === null ? null : { ...old, ...(step.thenMoveTo === undefined ? {} : { to: step.thenMoveTo.trim() === "" ? null : find(step.thenMoveTo) }) };
|
|
590
|
+
return old === null ? null : { ...old, ...(step.thenMoveTo === undefined ? {} : { to: step.thenMoveTo.trim() === "" ? null : find(step.thenMoveTo, "thenMoveTo") }) };
|
|
656
591
|
if (typeof step.remindAfter === "string" && /^\s*(none|never|no|off)?\s*$/i.test(step.remindAfter))
|
|
657
592
|
return null;
|
|
658
593
|
const minutes = durationMinutes(step.remindAfter);
|
|
659
594
|
if (minutes === null)
|
|
660
|
-
throw new
|
|
661
|
-
return { minutes, to: typeof step.thenMoveTo === "string" && step.thenMoveTo.trim() !== "" ? find(step.thenMoveTo) : step.thenMoveTo === undefined ? old?.to ?? null : null };
|
|
595
|
+
throw new FlowContractError([`${at}.remindAfter: say when to remind, like "2 days" (up to 30 days)`]);
|
|
596
|
+
return { minutes, to: typeof step.thenMoveTo === "string" && step.thenMoveTo.trim() !== "" ? find(step.thenMoveTo, "thenMoveTo") : step.thenMoveTo === undefined ? old?.to ?? null : null };
|
|
662
597
|
}
|
|
663
598
|
/** What a draft zone asks for when the steps leave it out. */
|
|
664
599
|
export const DRAFT_DEFAULT = "Write a short, friendly reply to the person who sent this card, in plain words.";
|
|
@@ -671,69 +606,92 @@ function defaultInstructions(kind, earlier) {
|
|
|
671
606
|
}
|
|
672
607
|
/** Steps as the lead's flow tool and `toolroll flows` take them: "me" as a decider is the person asking, stored by name. */
|
|
673
608
|
export function stepsFor(steps, name) {
|
|
674
|
-
return Array.isArray(steps) ? steps.map(step => step
|
|
609
|
+
return Array.isArray(steps) ? steps.map(step => isRecord(step) && typeof step["decider"] === "string" && /^(me|myself|i|you|the operator)$/i.test(step["decider"].trim()) ? { ...step, decider: name } : step) : steps;
|
|
610
|
+
}
|
|
611
|
+
/** An edit's steps with what a kept step leaves out of its kind and name filled from the zone it keeps (by id), so each
|
|
612
|
+
* reads as the one schema its kind has. Anything else it leaves out carries over when the flow is drawn (flowFromSteps). */
|
|
613
|
+
export function withKeptSteps(steps, previous) {
|
|
614
|
+
if (previous === null || !Array.isArray(steps))
|
|
615
|
+
return steps;
|
|
616
|
+
const kept = new Map(previous.stages.map(one => [one.id, one]));
|
|
617
|
+
const asked = new Set();
|
|
618
|
+
return steps.map(step => {
|
|
619
|
+
if (!isRecord(step) || typeof step["id"] !== "string")
|
|
620
|
+
return step;
|
|
621
|
+
const id = idOf(step["id"]);
|
|
622
|
+
const old = asked.has(id) ? undefined : kept.get(id);
|
|
623
|
+
asked.add(id);
|
|
624
|
+
if (old === undefined)
|
|
625
|
+
return step;
|
|
626
|
+
return {
|
|
627
|
+
...step,
|
|
628
|
+
...(step["kind"] === undefined ? { kind: old.kind } : {}),
|
|
629
|
+
...(step["title"] === undefined || (typeof step["title"] === "string" && step["title"].trim() === "") ? { title: old.title } : {}),
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
/** A flow's steps, read by their one schema: `steps[0].routes[0]: unknown key 'to' (did you mean goesTo?)`. */
|
|
634
|
+
export function readFlowSteps(input, previous = null) {
|
|
635
|
+
const read = parseContract(flowStepsSchema, { steps: withKeptSteps(input, previous) }, FLOW_ALIASES);
|
|
636
|
+
return read.ok ? { ok: true, value: read.value.steps } : read;
|
|
675
637
|
}
|
|
676
638
|
/**
|
|
677
639
|
* A flow from an ordered list of steps: ids from names, each step leading
|
|
678
640
|
* to the next, a Done zone at the end when none is listed, and a decision
|
|
679
641
|
* sending work back to the nearest earlier step that does work. New zones
|
|
680
|
-
* are laid out in rows; zones kept from `previous` keep their place.
|
|
642
|
+
* are laid out in rows; zones kept from `previous` keep their place. Throws
|
|
643
|
+
* FlowContractError, each line naming the step's path (`steps[2].next: ...`).
|
|
681
644
|
*/
|
|
682
645
|
export function flowFromSteps(input, previous = null) {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
throw new Error("A flow has 1 to 24 steps.");
|
|
646
|
+
const read = readFlowSteps(input, previous);
|
|
647
|
+
if (!read.ok)
|
|
648
|
+
throw new FlowContractError(read.issues.map(one => one.line));
|
|
687
649
|
const kept = new Map((previous?.stages ?? []).map(one => [one.id, one]));
|
|
688
650
|
const used = new Set();
|
|
689
|
-
const drafts =
|
|
690
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
691
|
-
throw new Error(`Step ${index + 1} isn't a step.`);
|
|
692
|
-
const step = raw;
|
|
651
|
+
const drafts = read.value.map((step, index) => {
|
|
693
652
|
const asked = typeof step.id === "string" ? idOf(step.id) : "";
|
|
694
653
|
const old = asked !== "" && !used.has(asked) ? kept.get(asked) ?? null : null;
|
|
695
|
-
const title =
|
|
654
|
+
const title = step.title.trim() !== "" ? step.title.trim() : old?.title ?? "";
|
|
696
655
|
if (title === "")
|
|
697
|
-
throw new
|
|
698
|
-
const kind = step.kind
|
|
699
|
-
if (!FLOW_STAGE_KINDS.includes(kind))
|
|
700
|
-
throw new Error(`Step ${title}: choose what it does.`);
|
|
656
|
+
throw new FlowContractError([`steps[${index}].title: must not be empty`]);
|
|
657
|
+
const kind = step.kind;
|
|
701
658
|
// A new step keeps an id it is given (so the other steps can point at it by that id); otherwise its name makes one.
|
|
702
|
-
const given =
|
|
659
|
+
const given = ZONE_ID.test(asked) && !used.has(asked) ? asked : null;
|
|
703
660
|
let id = old?.id ?? given ?? slugOf(title);
|
|
704
661
|
for (let n = 2; old === null && used.has(id); n++)
|
|
705
662
|
id = `${slugOf(title).slice(0, 25)}-${n}`;
|
|
706
663
|
used.add(id);
|
|
707
664
|
const same = old !== null && old.kind === kind;
|
|
708
|
-
return { step, old: same ? old : null, id, title, kind:
|
|
665
|
+
return { step, old: same ? old : null, id, title, kind, at: `steps[${index}]` };
|
|
709
666
|
});
|
|
710
667
|
if (!drafts.some(one => one.kind === "done")) {
|
|
711
668
|
let id = "done";
|
|
712
669
|
for (let n = 2; used.has(id); n++)
|
|
713
670
|
id = `done-${n}`;
|
|
714
|
-
drafts.push({ step: {}, old: kept.get(id)?.kind === "done" ? kept.get(id) : null, id, title: "Done", kind: "done" });
|
|
671
|
+
drafts.push({ step: { title: "Done", kind: "done" }, old: kept.get(id)?.kind === "done" ? kept.get(id) : null, id, title: "Done", kind: "done", at: `steps[${drafts.length}]` });
|
|
715
672
|
}
|
|
716
|
-
const
|
|
673
|
+
const findFrom = (at) => (ref, path) => {
|
|
717
674
|
const wanted = ref.trim().toLowerCase();
|
|
718
675
|
const hit = drafts.find(one => one.id === ref.trim() || one.id === idOf(ref) || one.title.toLowerCase() === wanted);
|
|
719
676
|
if (hit === undefined)
|
|
720
|
-
throw new
|
|
677
|
+
throw new FlowContractError([`${at}.${path}: there's no step called ${ref}`]);
|
|
721
678
|
return hit.id;
|
|
722
679
|
};
|
|
723
680
|
const stages = [];
|
|
724
|
-
drafts.forEach(({ step, old, id, title, kind }, index) => {
|
|
681
|
+
drafts.forEach(({ step, old, id, title, kind, at }, index) => {
|
|
682
|
+
const find = findFrom(at);
|
|
725
683
|
const earlier = stages.slice();
|
|
726
684
|
const following = drafts.slice(index + 1).find(() => true) ?? null;
|
|
727
685
|
const next = kind === "done" || kind === "sort" || kind === "choose" ? null
|
|
728
|
-
: typeof step.next === "string" && step.next.trim() !== "" ? find(step.next,
|
|
686
|
+
: typeof step.next === "string" && step.next.trim() !== "" ? find(step.next, "next")
|
|
729
687
|
: following?.id ?? drafts.find(one => one.kind === "done").id;
|
|
730
688
|
const keptFail = old?.onFail !== null && old?.onFail !== undefined && drafts.some(one => one.id === old.onFail) ? old.onFail : null;
|
|
731
689
|
const worker = [...earlier].reverse().find(one => one.kind === "task" || one.kind === "report");
|
|
732
|
-
const notSure = kind === "sort"
|
|
733
|
-
: kind === "wait"
|
|
734
|
-
: kind === "choose" ? step.ifReplied : step.ifFails;
|
|
690
|
+
const [failKey, notSure] = kind === "sort" ? ["ifNotSure", step.ifNotSure]
|
|
691
|
+
: kind === "wait" ? ["ifNoReply", step.ifNoReply]
|
|
692
|
+
: kind === "choose" ? ["ifReplied", step.ifReplied] : ["ifFails", step.ifFails];
|
|
735
693
|
const onFail = kind === "done" ? null
|
|
736
|
-
: typeof notSure === "string" && notSure.trim() !== "" ? find(notSure,
|
|
694
|
+
: typeof notSure === "string" && notSure.trim() !== "" ? find(notSure, failKey)
|
|
737
695
|
: keptFail ?? (kind === "approval" ? worker?.id ?? (drafts[0].id === id ? null : drafts[0].id)
|
|
738
696
|
// A reply to a choice says what to change: it goes back to the work before it, as its note.
|
|
739
697
|
: kind === "choose" ? worker?.id ?? null
|
|
@@ -744,6 +702,7 @@ export function flowFromSteps(input, previous = null) {
|
|
|
744
702
|
const approver = kind !== "approval" || toOwner ? null
|
|
745
703
|
: step.decider === undefined ? old?.approver ?? null
|
|
746
704
|
: step.decider === null || /^(anyone|any approver|anybody)$/i.test(step.decider.trim()) ? null : step.decider.trim();
|
|
705
|
+
const routesOf = (routes) => routes.length === 0 ? {} : { routes: routes.map((one, n) => ({ answer: one.answer.trim(), to: find(one.goesTo, `routes[${n}].goesTo`) })) };
|
|
747
706
|
stages.push({
|
|
748
707
|
id, title, kind,
|
|
749
708
|
zone: old?.zone ?? { x: 0, y: 0, w: 260, h: kind === "done" || kind === "notify" || kind === "send" ? 220 : 300, color: KIND_COLORS[kind] },
|
|
@@ -763,27 +722,27 @@ export function flowFromSteps(input, previous = null) {
|
|
|
763
722
|
// Left out, a code step runs in a copy of the project (the card's work, or the main branch), as scripts always have.
|
|
764
723
|
...(kind === "check" ? {
|
|
765
724
|
...(step.runIn !== undefined ? { runIn: step.runIn } : old?.runIn === undefined ? {} : { runIn: old.runIn }),
|
|
766
|
-
...(step.routes !== undefined ? step.routes
|
|
725
|
+
...(step.routes !== undefined ? routesOf(step.routes) : old?.routes === undefined ? {} : { routes: old.routes }),
|
|
767
726
|
...(step.secrets !== undefined ? step.secrets.length === 0 ? {} : { secrets: step.secrets } : old?.secrets === undefined ? {} : { secrets: old.secrets }),
|
|
768
727
|
} : {}),
|
|
769
|
-
sort: kind === "sort" ? sortFromStep(step, old?.sort ?? null,
|
|
728
|
+
sort: kind === "sort" ? sortFromStep(step, old?.sort ?? null, find) : null,
|
|
770
729
|
...(kind === "request" ? { request: { method: step.method ?? old?.request?.method ?? "POST", url: step.url ?? old?.request?.url ?? "", headers: step.headers ?? old?.request?.headers ?? {}, body: step.body ?? old?.request?.body ?? null } } : {}),
|
|
771
730
|
...(kind === "email" ? { email: { to: step.to ?? old?.email?.to ?? "{{card.email}}", subject: step.subject ?? old?.email?.subject ?? "Re: {{card.title}}", body: step.body ?? old?.email?.body ?? "" } } : {}),
|
|
772
731
|
...(kind === "tool" ? { tool: { server: step.server ?? old?.tool?.server ?? "", name: step.tool ?? old?.tool?.name ?? "", args: typeof step.args === "string" ? step.args : step.args !== undefined ? JSON.stringify(step.args) : old?.tool?.args ?? "{}" } } : {}),
|
|
773
|
-
...(kind === "wait" ? { wait: waitFromStep(step, old?.wait ?? null,
|
|
732
|
+
...(kind === "wait" ? { wait: waitFromStep(step, old?.wait ?? null, at) } : {}),
|
|
774
733
|
...(kind === "pull-request" && (step.merge === undefined ? old?.merge !== undefined : step.merge !== false) ? { merge: step.merge === undefined || step.merge === true || step.merge === false ? old?.merge ?? "squash" : step.merge } : {}),
|
|
775
734
|
// v92: "nobody" (or "none") takes a teammate off an approval step.
|
|
776
735
|
...((kind === "approval" || kind === "teammate") && (step.teammate === undefined ? old?.teammate !== undefined : !/^(nobody|none|no one)$/i.test(step.teammate.trim())) ? { teammate: idOf(step.teammate ?? old.teammate) } : {}),
|
|
777
|
-
...(kind === "teammate" ? step.routes !== undefined ? step.routes
|
|
736
|
+
...(kind === "teammate" ? step.routes !== undefined ? routesOf(step.routes) : old?.routes === undefined ? {} : { routes: old.routes } : {}),
|
|
778
737
|
...(kind === "teammate" && (step.reply ?? old?.reply) === true ? { reply: true } : {}),
|
|
779
|
-
...(kind === "choose" ? { options: choicesFromStep(step, old?.options ?? null,
|
|
738
|
+
...(kind === "choose" ? { options: choicesFromStep(step, old?.options ?? null, at, find) } : {}),
|
|
780
739
|
...(kind === "task" && (step.repo ?? old?.repo) !== undefined && (step.repo ?? old?.repo).trim() !== "" ? { repo: (step.repo ?? old?.repo).trim() } : {}),
|
|
781
740
|
...(() => {
|
|
782
741
|
// "If no reply" on a choice is where it moves once the reminder comes.
|
|
783
742
|
const asked = kind === "choose" && step.thenMoveTo === undefined && typeof step.ifNoReply === "string" ? { ...step, thenMoveTo: step.ifNoReply } : step;
|
|
784
743
|
if (kind === "choose" && asked.thenMoveTo !== undefined && asked.remindAfter === undefined && old?.limit === undefined)
|
|
785
|
-
throw new
|
|
786
|
-
const limit = kind === "wait" || kind === "done" ? null : limitFromStep(asked, old?.limit ?? null,
|
|
744
|
+
throw new FlowContractError([`${at}.remindAfter: say how long to wait for a choice first (like "2 days")`]);
|
|
745
|
+
const limit = kind === "wait" || kind === "done" ? null : limitFromStep(asked, old?.limit ?? null, at, find);
|
|
787
746
|
return limit === null ? {} : { limit };
|
|
788
747
|
})(),
|
|
789
748
|
next, onFail,
|
|
@@ -852,29 +811,35 @@ export function flowFromSteps(input, previous = null) {
|
|
|
852
811
|
stage.zone = at;
|
|
853
812
|
placed.push(at);
|
|
854
813
|
});
|
|
855
|
-
|
|
814
|
+
// The drawing is checked as the canvas's is; its zones are the steps, in order, so a zone's problem names its step.
|
|
815
|
+
const drawn = readFlowDefinition({ version: FLOW_DEFINITION_VERSION, start: stages[0].id, stages });
|
|
816
|
+
if (!drawn.ok)
|
|
817
|
+
throw new FlowContractError(drawn.issues.map(one => inStepWords(one.line, "steps", stages.map(stage => stage.kind))));
|
|
818
|
+
return drawn.value;
|
|
856
819
|
}
|
|
857
820
|
/** A choose step's options: each with its words and the step it goes to ("end", or no step, ignores the card); kept ones carry over. */
|
|
858
|
-
function choicesFromStep(step, old,
|
|
859
|
-
if (
|
|
821
|
+
function choicesFromStep(step, old, at, find) {
|
|
822
|
+
if (step.options === undefined) {
|
|
860
823
|
if (old !== null)
|
|
861
824
|
return old;
|
|
862
|
-
throw new
|
|
825
|
+
throw new FlowContractError([`${at}.options: give it ${CHOICES_MIN} to ${CHOICES_MAX} options, each with a label and the step it goes to (or "end")`]);
|
|
863
826
|
}
|
|
864
|
-
return step.options.map(one => {
|
|
865
|
-
const label =
|
|
866
|
-
const goes =
|
|
867
|
-
return { label, to: goes === "" || /^(end|ignore|ignored|stop|close)$/i.test(goes) ? FLOW_END : find(goes) };
|
|
827
|
+
return step.options.map((one, n) => {
|
|
828
|
+
const label = one.label.trim();
|
|
829
|
+
const goes = (one.goesTo ?? "").trim();
|
|
830
|
+
return { label, to: goes === "" || /^(end|ignore|ignored|stop|close)$/i.test(goes) ? FLOW_END : find(goes, `options[${n}].goesTo`) };
|
|
868
831
|
});
|
|
869
832
|
}
|
|
870
833
|
/** A sort step as the lead describes it: answers name the steps they go to; what it leaves out carries over from the zone it keeps. */
|
|
871
834
|
function sortFromStep(step, old, find) {
|
|
872
|
-
const answers =
|
|
873
|
-
? step.answers.map(one => ({ answer:
|
|
835
|
+
const answers = step.answers !== undefined
|
|
836
|
+
? step.answers.map((one, n) => ({ answer: one.answer.trim(), means: (one.means ?? "").trim() || one.answer.trim(), to: one.goesTo.trim() !== "" ? find(one.goesTo, `answers[${n}].goesTo`) : "" }))
|
|
874
837
|
: old?.answers ?? [];
|
|
875
838
|
// A percentage (80) or a fraction (0.8).
|
|
876
|
-
const sure =
|
|
877
|
-
const notes =
|
|
839
|
+
const sure = step.sureAt !== undefined ? (step.sureAt > 1 ? step.sureAt / 100 : step.sureAt) : old?.sureAt ?? SORT_SURE_AT;
|
|
840
|
+
const notes = step.alsoNote !== undefined ? step.alsoNote.map(one => one.kind === "score"
|
|
841
|
+
? { id: one.id ?? "", kind: "score", question: one.question.trim(), levels: one.levels ?? [] }
|
|
842
|
+
: { id: one.id ?? "", kind: "yes-no", question: one.question.trim(), levels: null })
|
|
878
843
|
: old?.notes ?? [];
|
|
879
844
|
return { question: step.question?.trim() || old?.question || "", answers, sureAt: sure, notes };
|
|
880
845
|
}
|