@larc-iu/plaid-client 0.2.0-alpha.1 → 0.2.0-alpha.11
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/index.d.ts +581 -105
- package/package.json +1 -1
- package/src/http.js +254 -35
- package/src/index.js +1171 -365
- package/src/provenance.js +259 -27
- package/src/serviceSchema.js +50 -30
- package/src/services.js +202 -37
- package/src/sse.js +51 -1
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
14
|
+
* human — no prov keys; a verifier made it.
|
|
12
15
|
* machine — { prov: 'inferred', provSource: '<producer>' };
|
|
13
|
-
* an algorithm/service made it,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
21
|
-
* material unless explicitly told to overwrite (an explicit,
|
|
22
|
-
* user-facing opt-in — for services, a declared boolean
|
|
23
|
-
* parameter).
|
|
24
|
-
* 3.
|
|
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,
|
|
29
|
-
* 'gloss:doc-frequency'
|
|
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,17 @@ 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
|
-
/**
|
|
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
|
|
102
|
+
/** The four provenance states returned by provState. */
|
|
65
103
|
export const PROV_STATES = Object.freeze({
|
|
66
104
|
HUMAN: 'human',
|
|
67
105
|
MACHINE: 'machine',
|
|
106
|
+
CONTRIBUTED: 'contributed',
|
|
68
107
|
VERIFIED: 'verified',
|
|
69
108
|
});
|
|
70
109
|
|
|
@@ -105,35 +144,228 @@ export const confirmedInferred = (source, extras = {}) => ({
|
|
|
105
144
|
*/
|
|
106
145
|
export const PROV_CONFIRMED = Object.freeze({ [PROV.confirmedKey]: true });
|
|
107
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The metadata fragment a contributor's work carries:
|
|
149
|
+
* { prov: 'contributed', provSource: 'user:<userId>' }.
|
|
150
|
+
* @param {string} userId
|
|
151
|
+
*/
|
|
152
|
+
export const stampContributed = (userId) => ({
|
|
153
|
+
[PROV.key]: PROV.CONTRIBUTED,
|
|
154
|
+
[PROV.sourceKey]: userSource(userId),
|
|
155
|
+
});
|
|
156
|
+
|
|
108
157
|
/**
|
|
109
158
|
* Classify an entity's metadata into one of PROV_STATES.
|
|
110
159
|
* @param {Object|null|undefined} metadata
|
|
111
|
-
* @returns {'human'|'machine'|'verified'}
|
|
160
|
+
* @returns {'human'|'machine'|'contributed'|'verified'}
|
|
112
161
|
*/
|
|
113
162
|
export const provState = (metadata) => {
|
|
114
163
|
if (!metadata || metadata[PROV.key] == null) return PROV_STATES.HUMAN;
|
|
115
|
-
|
|
164
|
+
if (metadata[PROV.confirmedKey]) return PROV_STATES.VERIFIED;
|
|
165
|
+
return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV_STATES.CONTRIBUTED : PROV_STATES.MACHINE;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Where an entity came from, confirmed or not: null for a verifier's own
|
|
170
|
+
* work, PROV.INFERRED for a machine's, PROV.CONTRIBUTED for a contributor's.
|
|
171
|
+
* What a verified entity's tooltip needs, since provState folds both
|
|
172
|
+
* origins into 'verified'.
|
|
173
|
+
* @returns {null|'inferred'|'contributed'}
|
|
174
|
+
*/
|
|
175
|
+
export const provOrigin = (metadata) => {
|
|
176
|
+
if (!metadata || metadata[PROV.key] == null) return null;
|
|
177
|
+
return metadata[PROV.key] === PROV.CONTRIBUTED ? PROV.CONTRIBUTED : PROV.INFERRED;
|
|
116
178
|
};
|
|
117
179
|
|
|
118
180
|
/**
|
|
119
|
-
* Whether this entity is machine-made and not yet
|
|
120
|
-
*
|
|
121
|
-
* that a human confirmation gesture acts on. Complement of isProtected.
|
|
181
|
+
* Whether this entity is machine-made and not yet verified — the material
|
|
182
|
+
* machine writers may replace. Complement of isProtected.
|
|
122
183
|
*/
|
|
123
184
|
export const isMachine = (metadata) => provState(metadata) === PROV_STATES.MACHINE;
|
|
124
185
|
|
|
125
186
|
/**
|
|
126
187
|
* Whether a machine writer must leave this entity alone (write-contract
|
|
127
|
-
* rule 2): true for human-made and
|
|
188
|
+
* rule 2): true for human-made, contributed and verified material.
|
|
128
189
|
*/
|
|
129
190
|
export const isProtected = (metadata) => !isMachine(metadata);
|
|
130
191
|
|
|
131
192
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
193
|
+
* Whether a verifier still has to look at this entity: machine-made or
|
|
194
|
+
* contributed, and not yet confirmed. What review UIs mark and sweep, and
|
|
195
|
+
* what a verifier's confirmation gesture acts on.
|
|
135
196
|
*/
|
|
136
|
-
export const
|
|
197
|
+
export const needsReview = (metadata) => {
|
|
198
|
+
const s = provState(metadata);
|
|
199
|
+
return s === PROV_STATES.MACHINE || s === PROV_STATES.CONTRIBUTED;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The metadata fragment a VERIFIER's edit of this entity should merge in
|
|
204
|
+
* (write-contract rule 3): PROV_CONFIRMED when the entity needs review,
|
|
205
|
+
* else null (nothing to do).
|
|
206
|
+
*/
|
|
207
|
+
export const verifyOnEdit = (metadata) => (needsReview(metadata) ? PROV_CONFIRMED : null);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The metadata fragment a CONTRIBUTOR's edit of this entity should merge in
|
|
211
|
+
* (write-contract rule 3): the contributed stamp, plus provConfirmed: null
|
|
212
|
+
* so an earlier confirmation is dropped (a patch deletes null-valued keys;
|
|
213
|
+
* see mergeMetadata for a full replace). Prediction extras a machine
|
|
214
|
+
* recorded stay, as history.
|
|
215
|
+
* @param {Object|null|undefined} metadata - unused today; the fragment is
|
|
216
|
+
* the same whatever the entity was, and the parameter keeps the shape of
|
|
217
|
+
* verifyOnEdit so callers can swap one for the other
|
|
218
|
+
* @param {string} userId
|
|
219
|
+
*/
|
|
220
|
+
// eslint-disable-next-line no-unused-vars
|
|
221
|
+
export const contributeOnEdit = (metadata, userId) => ({
|
|
222
|
+
...stampContributed(userId),
|
|
223
|
+
[PROV.confirmedKey]: null,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Merge a metadata fragment the way the server's PATCH does — a null value
|
|
228
|
+
* deletes the key — for callers that keep a local copy or send a full
|
|
229
|
+
* replacement (setMetadata). Returns a new object.
|
|
230
|
+
*/
|
|
231
|
+
export const mergeMetadata = (metadata, fragment) => {
|
|
232
|
+
const out = { ...(metadata || {}) };
|
|
233
|
+
for (const [k, v] of Object.entries(fragment || {})) {
|
|
234
|
+
if (v === null) delete out[k];
|
|
235
|
+
else out[k] = v;
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
};
|
|
137
239
|
|
|
138
240
|
/** Canonical provSource for a service: 'service:<serviceId>'. */
|
|
139
241
|
export const serviceSource = (serviceId) => `service:${serviceId}`;
|
|
242
|
+
|
|
243
|
+
/** Canonical provSource for a contributor: 'user:<userId>'. */
|
|
244
|
+
export const userSource = (userId) => `user:${userId}`;
|
|
245
|
+
|
|
246
|
+
// ---- review: whose work is reviewed (a project-config norm) ----
|
|
247
|
+
|
|
248
|
+
/** The config key, under the `plaid` namespace, holding the review lists. */
|
|
249
|
+
export const REVIEW_KEY = 'review';
|
|
250
|
+
|
|
251
|
+
/** The project roles a review list may name. */
|
|
252
|
+
export const PROJECT_ROLES = Object.freeze(['reader', 'writer', 'maintainer']);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The project's review lists, normalized: { users: string[], roles: string[] }.
|
|
256
|
+
* @param {Object|null|undefined} config - a project's `config`
|
|
257
|
+
*/
|
|
258
|
+
export const readReview = (config) => {
|
|
259
|
+
const raw = config?.plaid?.[REVIEW_KEY];
|
|
260
|
+
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
261
|
+
return { users: list(raw?.users), roles: list(raw?.roles) };
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A person's role in a project from its ACL lists: 'maintainer' | 'writer' |
|
|
266
|
+
* 'reader' | null. An admin with no explicit entry is a maintainer, as the
|
|
267
|
+
* permission model treats them.
|
|
268
|
+
* @param {Object} project - with `maintainers`, `writers`, `readers`
|
|
269
|
+
* @param {string} userId
|
|
270
|
+
* @param {Object} [opts]
|
|
271
|
+
* @param {boolean} [opts.isAdmin]
|
|
272
|
+
*/
|
|
273
|
+
export const projectRole = (project, userId, { isAdmin = false } = {}) => {
|
|
274
|
+
const inList = (l) => Array.isArray(l) && userId != null && l.includes(userId);
|
|
275
|
+
if (inList(project?.maintainers)) return 'maintainer';
|
|
276
|
+
if (inList(project?.writers)) return 'writer';
|
|
277
|
+
if (inList(project?.readers)) return 'reader';
|
|
278
|
+
return isAdmin ? 'maintainer' : null;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Whether this person's work is reviewed in this project: named in
|
|
283
|
+
* review.users, or holding a role named in review.roles.
|
|
284
|
+
*/
|
|
285
|
+
export const isReviewed = (project, userId, { isAdmin = false } = {}) => {
|
|
286
|
+
if (!project || userId == null) return false;
|
|
287
|
+
const { users, roles } = readReview(project.config);
|
|
288
|
+
if (users.includes(userId)) return true;
|
|
289
|
+
const role = projectRole(project, userId, { isAdmin });
|
|
290
|
+
return role != null && roles.includes(role);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The review lists with one person added to or removed from `users`
|
|
295
|
+
* (`roles` untouched). Pure; write the result with
|
|
296
|
+
* projects.setConfig(id, 'plaid', REVIEW_KEY, next).
|
|
297
|
+
*/
|
|
298
|
+
export const withReviewedUser = (review, userId, reviewed) => {
|
|
299
|
+
const { users, roles } = readReview({ plaid: { [REVIEW_KEY]: review } });
|
|
300
|
+
const next = users.filter((u) => u !== userId);
|
|
301
|
+
if (reviewed) next.push(userId);
|
|
302
|
+
return { users: next, roles };
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ---- the writer's policy: what a person's writes carry ----
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* What one writer's writes carry and what their review gestures act on,
|
|
309
|
+
* given who they are: `contributorId` is their user id when their work is
|
|
310
|
+
* reviewed (isReviewed), else null for a verifier. Apps derive this once per
|
|
311
|
+
* document and route every human write through it, so the convention's
|
|
312
|
+
* rule 3 has one implementation.
|
|
313
|
+
*
|
|
314
|
+
* @param {string|null} contributorId
|
|
315
|
+
* @returns {{
|
|
316
|
+
* contributorId: string|null,
|
|
317
|
+
* isContributor: boolean,
|
|
318
|
+
* createStamp: Object|null,
|
|
319
|
+
* editStamp: (metadata: Object) => Object|null,
|
|
320
|
+
* confirmStamp: (metadata: Object) => Object|null,
|
|
321
|
+
* adoptStamp: (source: string, detail?: Object) => Object,
|
|
322
|
+
* reviewable: (metadata: Object) => boolean,
|
|
323
|
+
* reviewableState: (state: string) => boolean,
|
|
324
|
+
* }}
|
|
325
|
+
*/
|
|
326
|
+
export const writerPolicy = (contributorId = null) => {
|
|
327
|
+
const id = contributorId || null;
|
|
328
|
+
const contributor = id != null;
|
|
329
|
+
// Material this writer's review gestures act on: a verifier reviews
|
|
330
|
+
// machine and contributed material; a contributor reviews machine
|
|
331
|
+
// proposals only, since their own vouching is itself a contribution.
|
|
332
|
+
const reviewable = (metadata) => (contributor ? isMachine(metadata) : needsReview(metadata));
|
|
333
|
+
const reviewableState = (state) =>
|
|
334
|
+
contributor
|
|
335
|
+
? state === PROV_STATES.MACHINE
|
|
336
|
+
: state === PROV_STATES.MACHINE || state === PROV_STATES.CONTRIBUTED;
|
|
337
|
+
return Object.freeze({
|
|
338
|
+
contributorId: id,
|
|
339
|
+
isContributor: contributor,
|
|
340
|
+
/** The metadata a NEW entity carries: null for a verifier. */
|
|
341
|
+
createStamp: contributor ? stampContributed(id) : null,
|
|
342
|
+
/**
|
|
343
|
+
* The fragment an EDIT merges over the entity's metadata, or null when
|
|
344
|
+
* there is nothing to merge: a verifier confirms what needs review; a
|
|
345
|
+
* contributor's edit marks the entity contributed, dropping any earlier
|
|
346
|
+
* confirmation.
|
|
347
|
+
*/
|
|
348
|
+
editStamp: (metadata) => (contributor ? contributeOnEdit(metadata, id) : verifyOnEdit(metadata)),
|
|
349
|
+
/**
|
|
350
|
+
* The fragment an explicit confirm gesture merges, or null when there is
|
|
351
|
+
* nothing for this writer to confirm: PROV_CONFIRMED for a verifier, the
|
|
352
|
+
* contributed stamp for a contributor accepting a machine proposal.
|
|
353
|
+
*/
|
|
354
|
+
confirmStamp: (metadata) => {
|
|
355
|
+
if (!reviewable(metadata)) return null;
|
|
356
|
+
return contributor ? contributeOnEdit(metadata, id) : PROV_CONFIRMED;
|
|
357
|
+
},
|
|
358
|
+
/**
|
|
359
|
+
* What an adopted suggestion is written with (a guess or a picked value
|
|
360
|
+
* into an empty cell): born-verified with the suggestion's producer as
|
|
361
|
+
* its source for a verifier; contributed for a contributor, with the
|
|
362
|
+
* producer kept as provDetail.guess. `detail` is the prediction extras.
|
|
363
|
+
*/
|
|
364
|
+
adoptStamp: (source, detail) =>
|
|
365
|
+
contributor
|
|
366
|
+
? { ...stampContributed(id), [PROV.detailKey]: { ...(detail || {}), guess: source } }
|
|
367
|
+
: confirmedInferred(source, { detail }),
|
|
368
|
+
reviewable,
|
|
369
|
+
reviewableState,
|
|
370
|
+
});
|
|
371
|
+
};
|
package/src/serviceSchema.js
CHANGED
|
@@ -43,11 +43,17 @@
|
|
|
43
43
|
* without an explicit `overwrite` parameter. See the manual, "Provenance".
|
|
44
44
|
*/
|
|
45
45
|
export const TASKS = Object.freeze({
|
|
46
|
-
TOKENIZE:
|
|
47
|
-
PARSE:
|
|
48
|
-
TRANSCRIBE:
|
|
46
|
+
TOKENIZE: "tokenize",
|
|
47
|
+
PARSE: "parse",
|
|
48
|
+
TRANSCRIBE: "transcribe",
|
|
49
49
|
/** Create vocab links for unlinked tokens. */
|
|
50
|
-
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
|
+
/** A conversational assistant over a project (chat turns; see plaid-igt-agent). */
|
|
56
|
+
ASSIST: "assist",
|
|
51
57
|
});
|
|
52
58
|
|
|
53
59
|
/**
|
|
@@ -55,8 +61,8 @@ export const TASKS = Object.freeze({
|
|
|
55
61
|
* declared `tasks` array. Drop once all services advertise `tasks`.
|
|
56
62
|
*/
|
|
57
63
|
const LEGACY_TASK_PREFIXES = Object.freeze({
|
|
58
|
-
[TASKS.TOKENIZE]:
|
|
59
|
-
[TASKS.TRANSCRIBE]:
|
|
64
|
+
[TASKS.TOKENIZE]: "tok:",
|
|
65
|
+
[TASKS.TRANSCRIBE]: "asr:",
|
|
60
66
|
});
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -72,7 +78,11 @@ export function servesTask(service, task) {
|
|
|
72
78
|
return declared.includes(task);
|
|
73
79
|
}
|
|
74
80
|
const prefix = LEGACY_TASK_PREFIXES[task];
|
|
75
|
-
return
|
|
81
|
+
return (
|
|
82
|
+
!!prefix &&
|
|
83
|
+
typeof service?.serviceId === "string" &&
|
|
84
|
+
service.serviceId.startsWith(prefix)
|
|
85
|
+
);
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
/**
|
|
@@ -102,7 +112,7 @@ export function getParamSchema(service) {
|
|
|
102
112
|
* @returns {string}
|
|
103
113
|
*/
|
|
104
114
|
export function getServiceSummary(service) {
|
|
105
|
-
return service?.extras?.summary || service?.description ||
|
|
115
|
+
return service?.extras?.summary || service?.description || "";
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
/** Valid option values for an enum/multiselect param. */
|
|
@@ -117,20 +127,25 @@ function optionValues(param) {
|
|
|
117
127
|
*/
|
|
118
128
|
function defaultForParam(param) {
|
|
119
129
|
const opts = optionValues(param);
|
|
120
|
-
if (param.type ===
|
|
121
|
-
if (param.default != null && opts.includes(param.default))
|
|
122
|
-
|
|
130
|
+
if (param.type === "enum") {
|
|
131
|
+
if (param.default != null && opts.includes(param.default))
|
|
132
|
+
return param.default;
|
|
133
|
+
return opts[0] ?? "";
|
|
123
134
|
}
|
|
124
|
-
if (param.type ===
|
|
135
|
+
if (param.type === "multiselect") {
|
|
125
136
|
const arr = Array.isArray(param.default) ? param.default : [];
|
|
126
137
|
return opts.length ? arr.filter((x) => opts.includes(x)) : arr;
|
|
127
138
|
}
|
|
128
|
-
if (param.default !== undefined && param.default !== null)
|
|
139
|
+
if (param.default !== undefined && param.default !== null)
|
|
140
|
+
return param.default;
|
|
129
141
|
switch (param.type) {
|
|
130
|
-
case
|
|
131
|
-
|
|
132
|
-
case
|
|
133
|
-
|
|
142
|
+
case "number":
|
|
143
|
+
return typeof param.min === "number" ? param.min : 0;
|
|
144
|
+
case "boolean":
|
|
145
|
+
return false;
|
|
146
|
+
case "string":
|
|
147
|
+
default:
|
|
148
|
+
return "";
|
|
134
149
|
}
|
|
135
150
|
}
|
|
136
151
|
|
|
@@ -167,43 +182,48 @@ export function coerceParamValues(schema, raw) {
|
|
|
167
182
|
if (v === undefined) v = defaultForParam(param);
|
|
168
183
|
|
|
169
184
|
switch (param.type) {
|
|
170
|
-
case
|
|
185
|
+
case "number": {
|
|
171
186
|
// Blank / nullish counts as "missing" → the param's default (matches the
|
|
172
187
|
// Python client, where float('') raises and falls back to the default).
|
|
173
188
|
let n;
|
|
174
|
-
if (
|
|
189
|
+
if (
|
|
190
|
+
v === "" ||
|
|
191
|
+
v == null ||
|
|
192
|
+
(typeof v === "string" && v.trim() === "")
|
|
193
|
+
) {
|
|
175
194
|
n = defaultForParam(param);
|
|
176
195
|
} else {
|
|
177
|
-
n = typeof v ===
|
|
196
|
+
n = typeof v === "number" ? v : Number(v);
|
|
178
197
|
if (Number.isNaN(n)) n = defaultForParam(param);
|
|
179
198
|
}
|
|
180
|
-
if (typeof param.min ===
|
|
181
|
-
if (typeof param.max ===
|
|
199
|
+
if (typeof param.min === "number") n = Math.max(param.min, n);
|
|
200
|
+
if (typeof param.max === "number") n = Math.min(param.max, n);
|
|
182
201
|
v = n;
|
|
183
202
|
break;
|
|
184
203
|
}
|
|
185
|
-
case
|
|
186
|
-
v = v === true || v ===
|
|
204
|
+
case "boolean":
|
|
205
|
+
v = v === true || v === "true";
|
|
187
206
|
break;
|
|
188
|
-
case
|
|
207
|
+
case "enum": {
|
|
189
208
|
const opts = optionValues(param);
|
|
190
209
|
if (opts.length && !opts.includes(v)) v = defaultForParam(param);
|
|
191
210
|
break;
|
|
192
211
|
}
|
|
193
|
-
case
|
|
212
|
+
case "multiselect": {
|
|
194
213
|
const opts = optionValues(param);
|
|
195
|
-
const arr = Array.isArray(v) ? v :
|
|
214
|
+
const arr = Array.isArray(v) ? v : v == null || v === "" ? [] : [v];
|
|
196
215
|
v = opts.length ? arr.filter((x) => opts.includes(x)) : arr;
|
|
197
216
|
break;
|
|
198
217
|
}
|
|
199
|
-
case
|
|
218
|
+
case "string":
|
|
200
219
|
default:
|
|
201
|
-
v = v == null ?
|
|
220
|
+
v = v == null ? "" : String(v);
|
|
202
221
|
break;
|
|
203
222
|
}
|
|
204
223
|
|
|
205
224
|
if (param.required) {
|
|
206
|
-
const empty =
|
|
225
|
+
const empty =
|
|
226
|
+
v === "" || v == null || (Array.isArray(v) && v.length === 0);
|
|
207
227
|
if (empty) errors[k] = `${param.label || k} is required`;
|
|
208
228
|
}
|
|
209
229
|
values[k] = v;
|