@larc-iu/plaid-client 0.2.0-alpha.1 → 0.2.0-alpha.12

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/src/provenance.js CHANGED
@@ -5,28 +5,53 @@
5
5
  * Flat scalar keys — the query engine matches flat metadata well, nested
6
6
  * objects poorly.
7
7
  *
8
- * Three states (the PRESENCE of the `prov` key, not its value, is the
9
- * discriminator; absent keys mean "human"):
8
+ * Two axes. ORIGIN is the `prov` key: absent means a trusted person (a
9
+ * verifier) made it; 'inferred' means an algorithm or service did;
10
+ * 'contributed' means a person whose work the project reviews (a
11
+ * contributor) did. TRUST is `provConfirmed`: true once a verifier vouched
12
+ * for the value. Together they give four states:
10
13
  *
11
- * human — no prov keys; a person made it.
14
+ * human — no prov keys; a verifier made it.
12
15
  * machine — { prov: 'inferred', provSource: '<producer>' };
13
- * an algorithm/service made it, no human has vouched.
14
- * verified — same + { provConfirmed: true }; machine-made and a
15
- * human confirmed (or edited) it. provSource stays so
16
- * machine origin remains traceable.
16
+ * an algorithm/service made it, nobody has vouched.
17
+ * contributed — { prov: 'contributed', provSource: 'user:<id>' };
18
+ * a contributor made it, no verifier has vouched.
19
+ * verified — either origin + { provConfirmed: true }; a verifier
20
+ * confirmed (or edited) it. prov/provSource stay so
21
+ * the origin remains traceable.
17
22
  *
18
23
  * The write contract every machine writer must follow:
19
24
  * 1. Machine writers may freely replace MACHINE (unverified) material.
20
- * 2. Machine writers must never modify or delete human or verified
21
- * material unless explicitly told to overwrite (an explicit, per-run,
22
- * user-facing opt-in — for services, a declared boolean `overwrite`
23
- * parameter).
24
- * 3. Any human edit of a machine annotation verifies it: the edit also
25
- * stamps { provConfirmed: true } (see verifyOnEdit).
25
+ * 2. Machine writers must never modify or delete human, contributed or
26
+ * verified material unless explicitly told to overwrite (an explicit,
27
+ * per-run, user-facing opt-in — for services, a declared boolean
28
+ * `overwrite` parameter). A contributor's work is a person's work.
29
+ * 3. A verifier's edit of machine or contributed material verifies it:
30
+ * the edit also stamps { provConfirmed: true } (see verifyOnEdit).
31
+ * A contributor's edit of anything marks it contributed: the edit
32
+ * merges stampContributed(userId) and drops any earlier confirmation
33
+ * (see contributeOnEdit). Who is a contributor is the PROJECT's call,
34
+ * recorded once for every app (see "Review" below); a service running
35
+ * as a contributor should stamp likewise.
36
+ *
37
+ * REVIEW: which people are contributors. A project records it under the
38
+ * reserved `plaid` config namespace, so every app that writes on a person's
39
+ * behalf reads the same answer:
40
+ *
41
+ * config.plaid.review = { users: [userId, ...], roles: ['writer', ...] }
42
+ *
43
+ * `users` names people whose work is reviewed whatever their role; `roles`
44
+ * names whole project roles ('reader' | 'writer' | 'maintainer'; an admin
45
+ * without an explicit role counts as a maintainer). Either may be absent.
46
+ * Nothing here grants or denies access: the ACL lists stay the permission
47
+ * model, and this only says whose work needs a verifier's look. Apps expose
48
+ * it as a per-member mark on their access screens (readReview, isReviewed,
49
+ * withReviewedUser) and derive the writer's policy from it (writerPolicy).
26
50
  *
27
51
  * Producer naming: 'service:<serviceId>' for services (use serviceSource),
28
- * 'rule:<name>' for built-in rule algorithms, app-specific ids like
29
- * 'gloss:doc-frequency' or 'flex-import' otherwise.
52
+ * 'rule:<name>' for built-in rule algorithms, 'user:<userId>' for a
53
+ * contributor (use userSource), app-specific ids like 'gloss:doc-frequency'
54
+ * or 'flex-import' otherwise.
30
55
  *
31
56
  * PREDICTION EXTRAS. A producer may also record how confident it was and
32
57
  * what else it considered, in two reserved slots split along the
@@ -43,6 +68,17 @@
43
68
  * human edits (history is valuable), so a consumer must not present
44
69
  * provProb as confidence in the CURRENT value once the entity is verified
45
70
  * (provConfirmed is exactly the flag to check).
71
+ *
72
+ * Recommended provDetail keys, so consumers (review dashboards, ranked
73
+ * alternatives) can read any producer's output:
74
+ * - value (spans) / form (morpheme tokens): the value the producer
75
+ * wrote, mirroring the entity's own field. The entity may be edited
76
+ * later; this copy is what makes "accepted as-is" vs "corrected"
77
+ * answerable once the entity is verified.
78
+ * - valueProbs / formProbs: a top-k {label: probability} map over the
79
+ * alternatives the producer considered, the chosen label included
80
+ * (provProb, when given, is that label's entry duplicated flat).
81
+ * - model, and anything else (language, adapter, raw scores).
46
82
  */
47
83
 
48
84
  /** The flat metadata keys of the provenance convention. */
@@ -57,14 +93,36 @@ export const PROV = Object.freeze({
57
93
  probKey: 'provProb',
58
94
  /** Open map of producer extras (alternatives, model version, raw scores). */
59
95
  detailKey: 'provDetail',
60
- /** The (currently only) value of `prov`. Presence, not value, decides. */
96
+ /** `prov` for machine-made material. Any value but CONTRIBUTED reads as machine. */
61
97
  INFERRED: 'inferred',
98
+ /** `prov` for a contributor's material. */
99
+ CONTRIBUTED: 'contributed',
62
100
  });
63
101
 
64
- /** The three provenance states returned by provState. */
102
+ /**
103
+ * Every provenance key, for a caller that has to name them as a set rather
104
+ * than read one: what a token layer declares under
105
+ * `config.plaid.preserveOnSplit` so a token born of a split inherits the
106
+ * origin of the token it came from, and what a reshape carries by hand.
107
+ *
108
+ * Provenance is the one metadata family Plaid itself owns. The user cannot
109
+ * maintain it, and in a shared project usually cannot even see it, since it
110
+ * may belong to an app they are not using. Everything else on an entity is
111
+ * the user's own content and no structural operation should touch it.
112
+ */
113
+ export const PROVENANCE_KEYS = Object.freeze([
114
+ PROV.key,
115
+ PROV.sourceKey,
116
+ PROV.confirmedKey,
117
+ PROV.probKey,
118
+ PROV.detailKey,
119
+ ]);
120
+
121
+ /** The four provenance states returned by provState. */
65
122
  export const PROV_STATES = Object.freeze({
66
123
  HUMAN: 'human',
67
124
  MACHINE: 'machine',
125
+ CONTRIBUTED: 'contributed',
68
126
  VERIFIED: 'verified',
69
127
  });
70
128
 
@@ -105,35 +163,228 @@ export const confirmedInferred = (source, extras = {}) => ({
105
163
  */
106
164
  export const PROV_CONFIRMED = Object.freeze({ [PROV.confirmedKey]: true });
107
165
 
166
+ /**
167
+ * The metadata fragment a contributor's work carries:
168
+ * { prov: 'contributed', provSource: 'user:<userId>' }.
169
+ * @param {string} userId
170
+ */
171
+ export const stampContributed = (userId) => ({
172
+ [PROV.key]: PROV.CONTRIBUTED,
173
+ [PROV.sourceKey]: userSource(userId),
174
+ });
175
+
108
176
  /**
109
177
  * Classify an entity's metadata into one of PROV_STATES.
110
178
  * @param {Object|null|undefined} metadata
111
- * @returns {'human'|'machine'|'verified'}
179
+ * @returns {'human'|'machine'|'contributed'|'verified'}
112
180
  */
113
181
  export const provState = (metadata) => {
114
182
  if (!metadata || metadata[PROV.key] == null) return PROV_STATES.HUMAN;
115
- return metadata[PROV.confirmedKey] ? PROV_STATES.VERIFIED : PROV_STATES.MACHINE;
183
+ if (metadata[PROV.confirmedKey]) return PROV_STATES.VERIFIED;
184
+ return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV_STATES.CONTRIBUTED : PROV_STATES.MACHINE;
116
185
  };
117
186
 
118
187
  /**
119
- * Whether this entity is machine-made and not yet human-verified — the
120
- * material that still needs review, that machine writers may replace, and
121
- * that a human confirmation gesture acts on. Complement of isProtected.
188
+ * Where an entity came from, confirmed or not: null for a verifier's own
189
+ * work, PROV.INFERRED for a machine's, PROV.CONTRIBUTED for a contributor's.
190
+ * What a verified entity's tooltip needs, since provState folds both
191
+ * origins into 'verified'.
192
+ * @returns {null|'inferred'|'contributed'}
193
+ */
194
+ export const provOrigin = (metadata) => {
195
+ if (!metadata || metadata[PROV.key] == null) return null;
196
+ return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV.CONTRIBUTED : PROV.INFERRED;
197
+ };
198
+
199
+ /**
200
+ * Whether this entity is machine-made and not yet verified — the material
201
+ * machine writers may replace. Complement of isProtected.
122
202
  */
123
203
  export const isMachine = (metadata) => provState(metadata) === PROV_STATES.MACHINE;
124
204
 
125
205
  /**
126
206
  * Whether a machine writer must leave this entity alone (write-contract
127
- * rule 2): true for human-made and human-verified material.
207
+ * rule 2): true for human-made, contributed and verified material.
128
208
  */
129
209
  export const isProtected = (metadata) => !isMachine(metadata);
130
210
 
131
211
  /**
132
- * The metadata fragment a HUMAN edit of this entity should merge in
133
- * (write-contract rule 3): PROV_CONFIRMED when the entity is machine-made
134
- * and unverified, else null (nothing to do).
212
+ * Whether a verifier still has to look at this entity: machine-made or
213
+ * contributed, and not yet confirmed. What review UIs mark and sweep, and
214
+ * what a verifier's confirmation gesture acts on.
215
+ */
216
+ export const needsReview = (metadata) => {
217
+ const s = provState(metadata);
218
+ return s === PROV_STATES.MACHINE || s === PROV_STATES.CONTRIBUTED;
219
+ };
220
+
221
+ /**
222
+ * The metadata fragment a VERIFIER's edit of this entity should merge in
223
+ * (write-contract rule 3): PROV_CONFIRMED when the entity needs review,
224
+ * else null (nothing to do).
225
+ */
226
+ export const verifyOnEdit = (metadata) => (needsReview(metadata) ? PROV_CONFIRMED : null);
227
+
228
+ /**
229
+ * The metadata fragment a CONTRIBUTOR's edit of this entity should merge in
230
+ * (write-contract rule 3): the contributed stamp, plus provConfirmed: null
231
+ * so an earlier confirmation is dropped (a patch deletes null-valued keys;
232
+ * see mergeMetadata for a full replace). Prediction extras a machine
233
+ * recorded stay, as history.
234
+ * @param {Object|null|undefined} metadata - unused today; the fragment is
235
+ * the same whatever the entity was, and the parameter keeps the shape of
236
+ * verifyOnEdit so callers can swap one for the other
237
+ * @param {string} userId
238
+ */
239
+ // eslint-disable-next-line no-unused-vars
240
+ export const contributeOnEdit = (metadata, userId) => ({
241
+ ...stampContributed(userId),
242
+ [PROV.confirmedKey]: null,
243
+ });
244
+
245
+ /**
246
+ * Merge a metadata fragment the way the server's PATCH does — a null value
247
+ * deletes the key — for callers that keep a local copy or send a full
248
+ * replacement (setMetadata). Returns a new object.
135
249
  */
136
- export const verifyOnEdit = (metadata) => (isMachine(metadata) ? PROV_CONFIRMED : null);
250
+ export const mergeMetadata = (metadata, fragment) => {
251
+ const out = { ...(metadata || {}) };
252
+ for (const [k, v] of Object.entries(fragment || {})) {
253
+ if (v === null) delete out[k];
254
+ else out[k] = v;
255
+ }
256
+ return out;
257
+ };
137
258
 
138
259
  /** Canonical provSource for a service: 'service:<serviceId>'. */
139
260
  export const serviceSource = (serviceId) => `service:${serviceId}`;
261
+
262
+ /** Canonical provSource for a contributor: 'user:<userId>'. */
263
+ export const userSource = (userId) => `user:${userId}`;
264
+
265
+ // ---- review: whose work is reviewed (a project-config norm) ----
266
+
267
+ /** The config key, under the `plaid` namespace, holding the review lists. */
268
+ export const REVIEW_KEY = 'review';
269
+
270
+ /** The project roles a review list may name. */
271
+ export const PROJECT_ROLES = Object.freeze(['reader', 'writer', 'maintainer']);
272
+
273
+ /**
274
+ * The project's review lists, normalized: { users: string[], roles: string[] }.
275
+ * @param {Object|null|undefined} config - a project's `config`
276
+ */
277
+ export const readReview = (config) => {
278
+ const raw = config?.plaid?.[REVIEW_KEY];
279
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
280
+ return { users: list(raw?.users), roles: list(raw?.roles) };
281
+ };
282
+
283
+ /**
284
+ * A person's role in a project from its ACL lists: 'maintainer' | 'writer' |
285
+ * 'reader' | null. An admin with no explicit entry is a maintainer, as the
286
+ * permission model treats them.
287
+ * @param {Object} project - with `maintainers`, `writers`, `readers`
288
+ * @param {string} userId
289
+ * @param {Object} [opts]
290
+ * @param {boolean} [opts.isAdmin]
291
+ */
292
+ export const projectRole = (project, userId, { isAdmin = false } = {}) => {
293
+ const inList = (l) => Array.isArray(l) && userId != null && l.includes(userId);
294
+ if (inList(project?.maintainers)) return 'maintainer';
295
+ if (inList(project?.writers)) return 'writer';
296
+ if (inList(project?.readers)) return 'reader';
297
+ return isAdmin ? 'maintainer' : null;
298
+ };
299
+
300
+ /**
301
+ * Whether this person's work is reviewed in this project: named in
302
+ * review.users, or holding a role named in review.roles.
303
+ */
304
+ export const isReviewed = (project, userId, { isAdmin = false } = {}) => {
305
+ if (!project || userId == null) return false;
306
+ const { users, roles } = readReview(project.config);
307
+ if (users.includes(userId)) return true;
308
+ const role = projectRole(project, userId, { isAdmin });
309
+ return role != null && roles.includes(role);
310
+ };
311
+
312
+ /**
313
+ * The review lists with one person added to or removed from `users`
314
+ * (`roles` untouched). Pure; write the result with
315
+ * projects.setConfig(id, 'plaid', REVIEW_KEY, next).
316
+ */
317
+ export const withReviewedUser = (review, userId, reviewed) => {
318
+ const { users, roles } = readReview({ plaid: { [REVIEW_KEY]: review } });
319
+ const next = users.filter((u) => u !== userId);
320
+ if (reviewed) next.push(userId);
321
+ return { users: next, roles };
322
+ };
323
+
324
+ // ---- the writer's policy: what a person's writes carry ----
325
+
326
+ /**
327
+ * What one writer's writes carry and what their review gestures act on,
328
+ * given who they are: `contributorId` is their user id when their work is
329
+ * reviewed (isReviewed), else null for a verifier. Apps derive this once per
330
+ * document and route every human write through it, so the convention's
331
+ * rule 3 has one implementation.
332
+ *
333
+ * @param {string|null} contributorId
334
+ * @returns {{
335
+ * contributorId: string|null,
336
+ * isContributor: boolean,
337
+ * createStamp: Object|null,
338
+ * editStamp: (metadata: Object) => Object|null,
339
+ * confirmStamp: (metadata: Object) => Object|null,
340
+ * adoptStamp: (source: string, detail?: Object) => Object,
341
+ * reviewable: (metadata: Object) => boolean,
342
+ * reviewableState: (state: string) => boolean,
343
+ * }}
344
+ */
345
+ export const writerPolicy = (contributorId = null) => {
346
+ const id = contributorId || null;
347
+ const contributor = id != null;
348
+ // Material this writer's review gestures act on: a verifier reviews
349
+ // machine and contributed material; a contributor reviews machine
350
+ // proposals only, since their own vouching is itself a contribution.
351
+ const reviewable = (metadata) => (contributor ? isMachine(metadata) : needsReview(metadata));
352
+ const reviewableState = (state) =>
353
+ contributor
354
+ ? state === PROV_STATES.MACHINE
355
+ : state === PROV_STATES.MACHINE || state === PROV_STATES.CONTRIBUTED;
356
+ return Object.freeze({
357
+ contributorId: id,
358
+ isContributor: contributor,
359
+ /** The metadata a NEW entity carries: null for a verifier. */
360
+ createStamp: contributor ? stampContributed(id) : null,
361
+ /**
362
+ * The fragment an EDIT merges over the entity's metadata, or null when
363
+ * there is nothing to merge: a verifier confirms what needs review; a
364
+ * contributor's edit marks the entity contributed, dropping any earlier
365
+ * confirmation.
366
+ */
367
+ editStamp: (metadata) => (contributor ? contributeOnEdit(metadata, id) : verifyOnEdit(metadata)),
368
+ /**
369
+ * The fragment an explicit confirm gesture merges, or null when there is
370
+ * nothing for this writer to confirm: PROV_CONFIRMED for a verifier, the
371
+ * contributed stamp for a contributor accepting a machine proposal.
372
+ */
373
+ confirmStamp: (metadata) => {
374
+ if (!reviewable(metadata)) return null;
375
+ return contributor ? contributeOnEdit(metadata, id) : PROV_CONFIRMED;
376
+ },
377
+ /**
378
+ * What an adopted suggestion is written with (a guess or a picked value
379
+ * into an empty cell): born-verified with the suggestion's producer as
380
+ * its source for a verifier; contributed for a contributor, with the
381
+ * producer kept as provDetail.guess. `detail` is the prediction extras.
382
+ */
383
+ adoptStamp: (source, detail) =>
384
+ contributor
385
+ ? { ...stampContributed(id), [PROV.detailKey]: { ...(detail || {}), guess: source } }
386
+ : confirmedInferred(source, { detail }),
387
+ reviewable,
388
+ reviewableState,
389
+ });
390
+ };
package/src/roles.js CHANGED
@@ -25,6 +25,15 @@ export const PLAID_NAMESPACE = 'plaid';
25
25
  export const ROLE_KEY = 'role';
26
26
 
27
27
  /** The fixed role inventory. */
28
+ /**
29
+ * Layer config key naming the metadata keys a token born of a SPLIT inherits
30
+ * from the token it came from. Plaid honors it without knowing what the keys
31
+ * mean; see the manual's `Metadata Preserved Across a Split`. An app declares
32
+ * it so that a split in ANY app, including one that has never heard of these
33
+ * keys, does not silently drop them.
34
+ */
35
+ export const PRESERVE_ON_SPLIT_KEY = 'preserveOnSplit';
36
+
28
37
  export const ROLES = Object.freeze({
29
38
  BASELINE: 'baseline',
30
39
  SENTENCE: 'sentence',
@@ -21,7 +21,7 @@
21
21
  * parameters: [ // ordered; rendered into a form
22
22
  * { key, label, type, description?, default?, required?,
23
23
  * options?: [{value, label}], // enum / multiselect
24
- * min?, max?, step?, // number
24
+ * min?, max?, step?, slider?, // number (slider: drag, don't type)
25
25
  * placeholder?, multiline? } // string
26
26
  * ]
27
27
  * }
@@ -43,11 +43,27 @@
43
43
  * without an explicit `overwrite` parameter. See the manual, "Provenance".
44
44
  */
45
45
  export const TASKS = Object.freeze({
46
- TOKENIZE: 'tokenize',
47
- PARSE: 'parse',
48
- TRANSCRIBE: 'transcribe',
46
+ TOKENIZE: "tokenize",
47
+ PARSE: "parse",
48
+ TRANSCRIBE: "transcribe",
49
49
  /** Create vocab links for unlinked tokens. */
50
- LINK_VOCAB: 'link-vocab',
50
+ LINK_VOCAB: "link-vocab",
51
+ /** Propose an interlinear analysis (morpheme segmentation + glosses) for words. */
52
+ ANALYZE: "analyze",
53
+ /** Propose a free translation for each sentence. */
54
+ TRANSLATE: "translate",
55
+ /**
56
+ * Propose time-aligned speech regions for a recording.
57
+ *
58
+ * The odd one out: a detect-speech service RETURNS its regions and writes
59
+ * nothing, because a time-aligned segment is a stretch of the baseline and
60
+ * cannot exist without text. The response carries
61
+ * `{segments: [{timeBegin, timeEnd, speaker?}]}` (seconds), and the app
62
+ * holds them as proposals until a person types into one.
63
+ */
64
+ DETECT_SPEECH: "detect-speech",
65
+ /** A conversational assistant over a project (chat turns; see plaid-agent). */
66
+ ASSIST: "assist",
51
67
  });
52
68
 
53
69
  /**
@@ -55,8 +71,8 @@ export const TASKS = Object.freeze({
55
71
  * declared `tasks` array. Drop once all services advertise `tasks`.
56
72
  */
57
73
  const LEGACY_TASK_PREFIXES = Object.freeze({
58
- [TASKS.TOKENIZE]: 'tok:',
59
- [TASKS.TRANSCRIBE]: 'asr:',
74
+ [TASKS.TOKENIZE]: "tok:",
75
+ [TASKS.TRANSCRIBE]: "asr:",
60
76
  });
61
77
 
62
78
  /**
@@ -72,7 +88,11 @@ export function servesTask(service, task) {
72
88
  return declared.includes(task);
73
89
  }
74
90
  const prefix = LEGACY_TASK_PREFIXES[task];
75
- return !!prefix && typeof service?.serviceId === 'string' && service.serviceId.startsWith(prefix);
91
+ return (
92
+ !!prefix &&
93
+ typeof service?.serviceId === "string" &&
94
+ service.serviceId.startsWith(prefix)
95
+ );
76
96
  }
77
97
 
78
98
  /**
@@ -102,7 +122,7 @@ export function getParamSchema(service) {
102
122
  * @returns {string}
103
123
  */
104
124
  export function getServiceSummary(service) {
105
- return service?.extras?.summary || service?.description || '';
125
+ return service?.extras?.summary || service?.description || "";
106
126
  }
107
127
 
108
128
  /** Valid option values for an enum/multiselect param. */
@@ -117,20 +137,25 @@ function optionValues(param) {
117
137
  */
118
138
  function defaultForParam(param) {
119
139
  const opts = optionValues(param);
120
- if (param.type === 'enum') {
121
- if (param.default != null && opts.includes(param.default)) return param.default;
122
- return opts[0] ?? '';
140
+ if (param.type === "enum") {
141
+ if (param.default != null && opts.includes(param.default))
142
+ return param.default;
143
+ return opts[0] ?? "";
123
144
  }
124
- if (param.type === 'multiselect') {
145
+ if (param.type === "multiselect") {
125
146
  const arr = Array.isArray(param.default) ? param.default : [];
126
147
  return opts.length ? arr.filter((x) => opts.includes(x)) : arr;
127
148
  }
128
- if (param.default !== undefined && param.default !== null) return param.default;
149
+ if (param.default !== undefined && param.default !== null)
150
+ return param.default;
129
151
  switch (param.type) {
130
- case 'number': return typeof param.min === 'number' ? param.min : 0;
131
- case 'boolean': return false;
132
- case 'string':
133
- default: return '';
152
+ case "number":
153
+ return typeof param.min === "number" ? param.min : 0;
154
+ case "boolean":
155
+ return false;
156
+ case "string":
157
+ default:
158
+ return "";
134
159
  }
135
160
  }
136
161
 
@@ -167,43 +192,48 @@ export function coerceParamValues(schema, raw) {
167
192
  if (v === undefined) v = defaultForParam(param);
168
193
 
169
194
  switch (param.type) {
170
- case 'number': {
195
+ case "number": {
171
196
  // Blank / nullish counts as "missing" → the param's default (matches the
172
197
  // Python client, where float('') raises and falls back to the default).
173
198
  let n;
174
- if (v === '' || v == null || (typeof v === 'string' && v.trim() === '')) {
199
+ if (
200
+ v === "" ||
201
+ v == null ||
202
+ (typeof v === "string" && v.trim() === "")
203
+ ) {
175
204
  n = defaultForParam(param);
176
205
  } else {
177
- n = typeof v === 'number' ? v : Number(v);
206
+ n = typeof v === "number" ? v : Number(v);
178
207
  if (Number.isNaN(n)) n = defaultForParam(param);
179
208
  }
180
- if (typeof param.min === 'number') n = Math.max(param.min, n);
181
- if (typeof param.max === 'number') n = Math.min(param.max, n);
209
+ if (typeof param.min === "number") n = Math.max(param.min, n);
210
+ if (typeof param.max === "number") n = Math.min(param.max, n);
182
211
  v = n;
183
212
  break;
184
213
  }
185
- case 'boolean':
186
- v = v === true || v === 'true';
214
+ case "boolean":
215
+ v = v === true || v === "true";
187
216
  break;
188
- case 'enum': {
217
+ case "enum": {
189
218
  const opts = optionValues(param);
190
219
  if (opts.length && !opts.includes(v)) v = defaultForParam(param);
191
220
  break;
192
221
  }
193
- case 'multiselect': {
222
+ case "multiselect": {
194
223
  const opts = optionValues(param);
195
- const arr = Array.isArray(v) ? v : (v == null || v === '' ? [] : [v]);
224
+ const arr = Array.isArray(v) ? v : v == null || v === "" ? [] : [v];
196
225
  v = opts.length ? arr.filter((x) => opts.includes(x)) : arr;
197
226
  break;
198
227
  }
199
- case 'string':
228
+ case "string":
200
229
  default:
201
- v = v == null ? '' : String(v);
230
+ v = v == null ? "" : String(v);
202
231
  break;
203
232
  }
204
233
 
205
234
  if (param.required) {
206
- const empty = v === '' || v == null || (Array.isArray(v) && v.length === 0);
235
+ const empty =
236
+ v === "" || v == null || (Array.isArray(v) && v.length === 0);
207
237
  if (empty) errors[k] = `${param.label || k} is required`;
208
238
  }
209
239
  values[k] = v;