@larc-iu/plaid-client 0.1.0-alpha.8 → 0.1.0-alpha.9

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 CHANGED
@@ -4,18 +4,56 @@ interface Page<T = any> {
4
4
  nextCursor: string | null;
5
5
  }
6
6
 
7
+ /** One choice for an `enum` / `multiselect` service parameter. */
8
+ interface ServiceParamOption {
9
+ value: string;
10
+ label: string;
11
+ }
12
+
13
+ /** A single user-controllable argument a service advertises. */
14
+ interface ServiceParam {
15
+ /** Key the value is sent under in the request payload. */
16
+ key: string;
17
+ label: string;
18
+ type: 'string' | 'number' | 'boolean' | 'enum' | 'multiselect';
19
+ description?: string;
20
+ default?: any;
21
+ required?: boolean;
22
+ /** Required for `enum` / `multiselect`. */
23
+ options?: ServiceParamOption[];
24
+ /** `number` only. */
25
+ min?: number;
26
+ max?: number;
27
+ step?: number;
28
+ /** `string` only. */
29
+ placeholder?: string;
30
+ multiline?: boolean;
31
+ }
32
+
33
+ /** A service's standardized self-description (lives in `extras`). */
34
+ interface ServiceExtras {
35
+ schemaVersion?: number;
36
+ /** Tasks this service serves; from the TASKS vocabulary. */
37
+ tasks?: string[];
38
+ /** Rich human description (markdown), beyond the short `description`. */
39
+ summary?: string;
40
+ /** Ordered parameter schema, rendered into a form by the UI. */
41
+ parameters?: ServiceParam[];
42
+ [key: string]: any;
43
+ }
44
+
7
45
  interface ServiceInfo {
8
46
  serviceId: string;
9
47
  serviceName: string;
10
48
  description: string;
11
- extras?: any;
49
+ extras?: ServiceExtras;
12
50
  }
13
51
 
14
52
  interface DiscoveredService {
15
53
  serviceId: string;
16
54
  serviceName: string;
17
55
  description: string;
18
- extras: any;
56
+ extras: ServiceExtras;
19
57
  }
20
58
 
21
59
  interface ServiceRegistration {
@@ -40,6 +78,7 @@ interface VocabLinksBundle {
40
78
  create(vocabItem: string, tokens: any[], metadata?: any): Promise<any>;
41
79
  setMetadata(id: string, body: any): Promise<any>;
42
80
  deleteMetadata(id: string): Promise<any>;
81
+ patchMetadata(id: string, body: any): Promise<any>;
43
82
  get(id: string, asOf?: string): Promise<any>;
44
83
  delete(id: string): Promise<any>;
45
84
  }
@@ -61,6 +100,7 @@ interface VocabLayersBundle {
61
100
  interface RelationsBundle {
62
101
  setMetadata(relationId: string, body: any): Promise<any>;
63
102
  deleteMetadata(relationId: string): Promise<any>;
103
+ patchMetadata(relationId: string, body: any): Promise<any>;
64
104
  setTarget(relationId: string, spanId: string): Promise<any>;
65
105
  get(relationId: string, asOf?: string): Promise<any>;
66
106
  delete(relationId: string): Promise<any>;
@@ -91,6 +131,7 @@ interface SpansBundle {
91
131
  bulkDelete(body: any[]): Promise<any>;
92
132
  setMetadata(spanId: string, body: any): Promise<any>;
93
133
  deleteMetadata(spanId: string): Promise<any>;
134
+ patchMetadata(spanId: string, body: any): Promise<any>;
94
135
  }
95
136
 
96
137
  interface BatchBundle {
@@ -100,6 +141,7 @@ interface BatchBundle {
100
141
  interface TextsBundle {
101
142
  setMetadata(textId: string, body: any): Promise<any>;
102
143
  deleteMetadata(textId: string): Promise<any>;
144
+ patchMetadata(textId: string, body: any): Promise<any>;
103
145
  create(textLayerId: string, documentId: string, body: string, metadata?: any): Promise<any>;
104
146
  get(textId: string, asOf?: string): Promise<any>;
105
147
  delete(textId: string): Promise<any>;
@@ -144,6 +186,7 @@ interface DocumentsBundle {
144
186
  deleteMedia(documentId: string): Promise<any>;
145
187
  setMetadata(documentId: string, body: any): Promise<any>;
146
188
  deleteMetadata(documentId: string): Promise<any>;
189
+ patchMetadata(documentId: string, body: any): Promise<any>;
147
190
  audit(documentId: string, startTime?: string, endTime?: string, asOf?: string): Promise<any[]>;
148
191
  get(documentId: string, includeBody?: boolean, asOf?: string): Promise<any>;
149
192
  delete(documentId: string): Promise<any>;
@@ -198,6 +241,7 @@ interface TextLayersBundle {
198
241
  interface VocabItemsBundle {
199
242
  setMetadata(id: string, body: any): Promise<any>;
200
243
  deleteMetadata(id: string): Promise<any>;
244
+ patchMetadata(id: string, body: any): Promise<any>;
201
245
  create(vocabLayerId: string, form: string, metadata?: any): Promise<any>;
202
246
  get(id: string, asOf?: string): Promise<any>;
203
247
  delete(id: string): Promise<any>;
@@ -226,6 +270,7 @@ interface TokensBundle {
226
270
  shift(tokenId: string, begin?: number, end?: number): Promise<any>;
227
271
  setMetadata(tokenId: string, body: any): Promise<any>;
228
272
  deleteMetadata(tokenId: string): Promise<any>;
273
+ patchMetadata(tokenId: string, body: any): Promise<any>;
229
274
  }
230
275
 
231
276
  interface PlaidClientOptions {
@@ -248,6 +293,9 @@ export declare class PlaidClient {
248
293
  enterStrictMode(documentId: string): void;
249
294
  exitStrictMode(): void;
250
295
 
296
+ // Query
297
+ query(body: any): Promise<any>;
298
+
251
299
  vocabLinks: VocabLinksBundle;
252
300
  vocabLayers: VocabLayersBundle;
253
301
  relations: RelationsBundle;
@@ -268,3 +316,64 @@ export declare class PlaidClient {
268
316
  }
269
317
 
270
318
  export default PlaidClient;
319
+
320
+ // --- Unicode code-point helpers for text offsets ---------------------------
321
+ // Token begin/end offsets are 0-based Unicode code-point indices (not UTF-16).
322
+ /** Number of Unicode code points in `s` (not `s.length`). */
323
+ export function cpLength(s: string): number;
324
+ /** Substring of `s` by code-point indices [begin, end) (end optional). */
325
+ export function cpSlice(s: string, begin: number, end?: number): string;
326
+ /** Prebuilt slicer for many code-point slices of one string (spreads once). */
327
+ export function cpSlicer(s: string): (begin: number, end?: number) => string;
328
+ /** UTF-16 index -> code-point index in `s`. */
329
+ export function utf16ToCp(s: string, u: number): number;
330
+ /** Code-point index -> UTF-16 index in `s` (clamps past the end). */
331
+ export function cpToUtf16(s: string, cp: number): number;
332
+ /** Like indexOf, but the result and `fromCp` are code-point indices; -1 if absent. */
333
+ export function cpIndexOf(s: string, sub: string, fromCp?: number): number;
334
+
335
+ // --- Shared layer-role vocabulary (cross-app interoperability) --------------
336
+ // Substrate layers are tagged with a role at `config.plaid.role` (a scalar) so
337
+ // that different apps can share a project. See the manual, "Layer Interoperability".
338
+ /** The reserved config namespace for cross-app conventions. */
339
+ export const PLAID_NAMESPACE: 'plaid';
340
+ /** The config key, under `plaid`, holding a layer's role. */
341
+ export const ROLE_KEY: 'role';
342
+ /** The fixed role inventory; only these values are interoperable across apps. */
343
+ export const ROLES: {
344
+ readonly BASELINE: 'baseline';
345
+ readonly SENTENCE: 'sentence';
346
+ readonly WORD: 'word';
347
+ readonly SYNTACTIC_WORD: 'syntactic-word';
348
+ readonly MORPHEME: 'morpheme';
349
+ readonly TIME_ALIGNMENT: 'time-alignment';
350
+ };
351
+ /** The role recorded on a layer's `config`, or null if none. */
352
+ export function readRole(config?: object): string | null;
353
+ /** The first layer in `layers` carrying the given role, or null. */
354
+ export function findByRole<T extends { config?: object }>(layers: T[] | undefined, role: string): T | null;
355
+
356
+ // --- Service self-description helpers ----------------------------------------
357
+ // Standardize how a service advertises (in `extras`) the tasks it serves, a
358
+ // summary, and a parameter schema — so a UI can offer service selection, an
359
+ // argument form, and a summary at a fixed integration point. See the manual,
360
+ // "Describing a service".
361
+ /** The controlled task vocabulary — the fixed integration-point goals. */
362
+ export const TASKS: {
363
+ readonly TOKENIZE: 'tokenize';
364
+ readonly PARSE: 'parse';
365
+ readonly TRANSCRIBE: 'transcribe';
366
+ readonly LINK_VOCAB: 'link-vocab';
367
+ };
368
+ /** Whether a service serves a task (declared `extras.tasks`, legacy id-prefix fallback). */
369
+ export function servesTask(service: DiscoveredService, task: string): boolean;
370
+ /** The discovered services that serve `task`. */
371
+ export function filterServicesByTask(services: DiscoveredService[] | undefined, task: string): DiscoveredService[];
372
+ /** The parameter schema a service declares (ordered), or []. */
373
+ export function getParamSchema(service: DiscoveredService): ServiceParam[];
374
+ /** A service's human summary: `extras.summary`, else `description`, else ''. */
375
+ export function getServiceSummary(service: DiscoveredService): string;
376
+ /** Default form values keyed by param key. */
377
+ export function buildDefaultValues(schema: ServiceParam[]): Record<string, any>;
378
+ /** Coerce/validate raw form values against the schema. */
379
+ export function coerceParamValues(schema: ServiceParam[], raw: Record<string, any>): { values: Record<string, any>; errors: Record<string, string> };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larc-iu/plaid-client",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.1.0-alpha.9",
4
4
  "description": "JavaScript client for the Plaid annotation API",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Unicode code-point helpers for working with Plaid text offsets.
3
+ *
4
+ * Plaid token offsets (`begin` / `end`) are 0-based indices in Unicode CODE
5
+ * POINTS (begin inclusive, end exclusive) — NOT UTF-16 code units. JavaScript
6
+ * strings are UTF-16, so `.length`, `.slice`, `.substring`, `s[i]`,
7
+ * `String.prototype.indexOf`, and `Intl.Segmenter`'s `index` all count UTF-16
8
+ * code units, which disagree with code points for astral characters
9
+ * (>= U+10000 — emoji, and SMP scripts such as Gothic, cuneiform, CJK Ext-B).
10
+ *
11
+ * Use these to slice a text body by token offsets, and to compute offsets for
12
+ * new tokens, in code points. The spread/`for…of` string iterator yields code
13
+ * points, which is what makes this work.
14
+ */
15
+
16
+ /** Number of Unicode code points in `s` (not `s.length`, which is UTF-16). */
17
+ export function cpLength(s) {
18
+ return [...s].length;
19
+ }
20
+
21
+ /**
22
+ * Substring of `s` by CODE-POINT indices [begin, end) (end optional = to end).
23
+ * Mirrors `String.prototype.slice` semantics but in code points.
24
+ */
25
+ export function cpSlice(s, begin, end) {
26
+ return [...s].slice(begin, end).join('');
27
+ }
28
+
29
+ /**
30
+ * Prebuilt slicer for taking MANY code-point slices of the same string:
31
+ * spreads `s` into code points once, then each slice costs O(slice length).
32
+ * `cpSlice` spreads the whole string per call, which turns quadratic when a
33
+ * caller slices every token of a large text. Mirror of the server's
34
+ * `plaid.util.codepoint/cp-slicer`.
35
+ */
36
+ export function cpSlicer(s) {
37
+ const chars = [...(s ?? '')];
38
+ return (begin, end) => chars.slice(begin, end).join('');
39
+ }
40
+
41
+ /**
42
+ * Convert a UTF-16 index `u` into `s` to a code-point index — i.e. how many
43
+ * code points precede `u`. Inverse of `cpToUtf16`. Useful for converting a
44
+ * DOM/`indexOf`/`Intl.Segmenter` (UTF-16) position into a code-point offset.
45
+ */
46
+ export function utf16ToCp(s, u) {
47
+ return [...s.slice(0, u)].length;
48
+ }
49
+
50
+ /**
51
+ * Convert a code-point index `cp` into `s` to a UTF-16 index. Clamps to
52
+ * `s.length` when `cp` is past the end. Inverse of `utf16ToCp`.
53
+ */
54
+ export function cpToUtf16(s, cp) {
55
+ if (cp <= 0) return 0;
56
+ let u = 0;
57
+ let c = 0;
58
+ for (const ch of s) {
59
+ if (c >= cp) break;
60
+ u += ch.length; // 1 for BMP, 2 for an astral code point (surrogate pair)
61
+ c += 1;
62
+ }
63
+ return u;
64
+ }
65
+
66
+ /**
67
+ * Like `String.prototype.indexOf`, but the returned index and `fromCp` are
68
+ * CODE-POINT indices. Returns -1 when `sub` is not found.
69
+ */
70
+ export function cpIndexOf(s, sub, fromCp = 0) {
71
+ const u = s.indexOf(sub, cpToUtf16(s, fromCp));
72
+ return u < 0 ? -1 : utf16ToCp(s, u);
73
+ }
package/src/index.js CHANGED
@@ -71,6 +71,15 @@ class PlaidClient {
71
71
  this._request('DELETE', `/api/v1/vocab-links/${id}/metadata`, {
72
72
  skipResponseTransform: true,
73
73
  }),
74
+ /**
75
+ * Patch (shallow-merge) metadata for a vocab link. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
76
+ * @param {string} id - The resource ID
77
+ * @param {any} body - The metadata patch
78
+ */
79
+ patchMetadata: (id, body) =>
80
+ this._request('PATCH', `/api/v1/vocab-links/${id}/metadata`, {
81
+ rawBody: body, skipResponseTransform: true,
82
+ }),
74
83
  /**
75
84
  * Get a vocab link by ID
76
85
  * @param {string} id - The resource ID
@@ -205,6 +214,15 @@ class PlaidClient {
205
214
  this._request('DELETE', `/api/v1/relations/${relationId}/metadata`, {
206
215
  skipResponseTransform: true,
207
216
  }),
217
+ /**
218
+ * Patch (shallow-merge) metadata for a relation. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
219
+ * @param {string} relationId - The relation ID
220
+ * @param {any} body - The metadata patch
221
+ */
222
+ patchMetadata: (relationId, body) =>
223
+ this._request('PATCH', `/api/v1/relations/${relationId}/metadata`, {
224
+ rawBody: body, skipResponseTransform: true,
225
+ }),
208
226
  /**
209
227
  * Update the target span of a relation.
210
228
  * @param {string} relationId - The relation ID
@@ -416,6 +434,15 @@ class PlaidClient {
416
434
  this._request('DELETE', `/api/v1/spans/${spanId}/metadata`, {
417
435
  skipResponseTransform: true,
418
436
  }),
437
+ /**
438
+ * Patch (shallow-merge) metadata for a span. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
439
+ * @param {string} spanId - The span ID
440
+ * @param {any} body - The metadata patch
441
+ */
442
+ patchMetadata: (spanId, body) =>
443
+ this._request('PATCH', `/api/v1/spans/${spanId}/metadata`, {
444
+ rawBody: body, skipResponseTransform: true,
445
+ }),
419
446
  };
420
447
 
421
448
  this.batch = {
@@ -448,6 +475,15 @@ class PlaidClient {
448
475
  this._request('DELETE', `/api/v1/texts/${textId}/metadata`, {
449
476
  skipResponseTransform: true,
450
477
  }),
478
+ /**
479
+ * Patch (shallow-merge) metadata for a text. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
480
+ * @param {string} textId - The text ID
481
+ * @param {any} body - The metadata patch
482
+ */
483
+ patchMetadata: (textId, body) =>
484
+ this._request('PATCH', `/api/v1/texts/${textId}/metadata`, {
485
+ rawBody: body, skipResponseTransform: true,
486
+ }),
451
487
  /**
452
488
  * Create a new text in a document's text layer. A text is a container for
453
489
  * one long string in `body` for a given layer.
@@ -490,33 +526,37 @@ class PlaidClient {
490
526
 
491
527
  this.users = {
492
528
  /**
493
- * List all users. Transparently follows pagination cursors and returns
494
- * the full flat array.
529
+ * List (or search) users. Transparently follows pagination cursors and
530
+ * returns the full flat array. Admin-or-maintainer only.
495
531
  * Cannot be used inside a batch (auto-paginates across requests); throws if called while batching — use listPage() for a single page in a batch.
496
- * @param {string} [asOf] - Temporal query timestamp
532
+ * @param {object} [opts]
533
+ * @param {string} [opts.q] - Filter to usernames containing this text (case-insensitive)
534
+ * @param {string} [opts.asOf] - Temporal query timestamp
497
535
  */
498
- list: (asOf) =>
499
- listAll(this, '/api/v1/users', { query: { 'as-of': asOf } }),
536
+ list: ({ q, asOf } = {}) =>
537
+ listAll(this, '/api/v1/users', { query: { q, 'as-of': asOf } }),
500
538
  /**
501
- * Fetch a single page of users.
539
+ * Fetch a single page of users (optionally filtered by `q`).
502
540
  * @param {object} [opts]
541
+ * @param {string} [opts.q] - Filter to usernames containing this text (case-insensitive)
503
542
  * @param {number} [opts.limit] - Page size (1..1000; server default 100)
504
543
  * @param {string} [opts.cursor] - Opaque cursor from a previous page
505
544
  * @param {string} [opts.asOf] - Temporal query timestamp
506
545
  * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
507
546
  */
508
- listPage: ({ limit, cursor, asOf } = {}) =>
509
- listPage(this, '/api/v1/users', { limit, cursor, query: { 'as-of': asOf } }),
547
+ listPage: ({ q, limit, cursor, asOf } = {}) =>
548
+ listPage(this, '/api/v1/users', { limit, cursor, query: { q, 'as-of': asOf } }),
510
549
  /**
511
550
  * Async-iterate users page by page; yields each page's entries array.
512
551
  * @param {object} [opts]
552
+ * @param {string} [opts.q] - Filter to usernames containing this text (case-insensitive)
513
553
  * @param {number} [opts.pageSize] - Per-request page size
514
554
  * @param {string} [opts.asOf] - Temporal query timestamp
515
555
  * Cannot be used inside a batch (auto-paginates across requests); throws on first iteration if called while batching — use listPage() for a single page in a batch.
516
556
  * @returns {AsyncGenerator<Array>}
517
557
  */
518
- iterPages: ({ pageSize, asOf } = {}) =>
519
- iterPages(this, '/api/v1/users', { pageSize, query: { 'as-of': asOf } }),
558
+ iterPages: ({ q, pageSize, asOf } = {}) =>
559
+ iterPages(this, '/api/v1/users', { pageSize, query: { q, 'as-of': asOf } }),
520
560
  /**
521
561
  * Create a new user
522
562
  * @param {string} username - The username
@@ -550,11 +590,23 @@ class PlaidClient {
550
590
  queryParams: { 'as-of': asOf },
551
591
  }),
552
592
  /**
553
- * Delete a user
593
+ * Deactivate a user. Users are never hard-deleted: deactivation
594
+ * rejects their logins and tokens, strips their project memberships
595
+ * and vocab maintainerships, and revokes their API tokens. The user
596
+ * stays visible in listings with a deactivated-at timestamp.
597
+ * Reversible via activate(), which restores login only.
554
598
  * @param {string} id - The resource ID
555
599
  */
556
600
  delete: (id) =>
557
601
  this._request('DELETE', `/api/v1/users/${id}`),
602
+ /**
603
+ * Reactivate a deactivated user, restoring their ability to log in.
604
+ * Project memberships, vocab maintainerships, and API tokens removed
605
+ * at deactivation are NOT restored — re-grant them deliberately.
606
+ * @param {string} id - The resource ID
607
+ */
608
+ activate: (id) =>
609
+ this._request('POST', `/api/v1/users/${id}/activate`),
558
610
  /**
559
611
  * Modify a user. Admins may change the username, password, and admin
560
612
  * status of any user. All other users may only modify their own username
@@ -760,6 +812,15 @@ class PlaidClient {
760
812
  this._request('DELETE', `/api/v1/documents/${documentId}/metadata`, {
761
813
  skipResponseTransform: true,
762
814
  }),
815
+ /**
816
+ * Patch (shallow-merge) metadata for a document. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
817
+ * @param {string} documentId - The document ID
818
+ * @param {any} body - The metadata patch
819
+ */
820
+ patchMetadata: (documentId, body) =>
821
+ this._request('PATCH', `/api/v1/documents/${documentId}/metadata`, {
822
+ rawBody: body, skipResponseTransform: true,
823
+ }),
763
824
  /**
764
825
  * Get audit log for a document. Transparently follows pagination cursors
765
826
  * and returns the full flat array.
@@ -1086,6 +1147,15 @@ class PlaidClient {
1086
1147
  this._request('DELETE', `/api/v1/vocab-items/${id}/metadata`, {
1087
1148
  skipResponseTransform: true,
1088
1149
  }),
1150
+ /**
1151
+ * Patch (shallow-merge) metadata for a vocab item. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
1152
+ * @param {string} id - The resource ID
1153
+ * @param {any} body - The metadata patch
1154
+ */
1155
+ patchMetadata: (id, body) =>
1156
+ this._request('PATCH', `/api/v1/vocab-items/${id}/metadata`, {
1157
+ rawBody: body, skipResponseTransform: true,
1158
+ }),
1089
1159
  /**
1090
1160
  * Create a new vocab item
1091
1161
  * @param {string} vocabLayerId - The vocab layer ID
@@ -1194,10 +1264,16 @@ class PlaidClient {
1194
1264
  * using begin and end offsets. Tokens may be zero-width and may overlap.
1195
1265
  * For tokens sharing the same begin, precedence controls the linear
1196
1266
  * ordering.
1267
+ *
1268
+ * Offsets are 0-based indices in Unicode CODE POINTS (not UTF-16 code
1269
+ * units): a supplementary-plane character (emoji, SMP script) is one
1270
+ * position. JS strings are UTF-16, so do NOT use `str.length` /
1271
+ * `str.substring` to compute offsets — count code points instead
1272
+ * (e.g. `[...str].length`, or iterate with `codePointAt`).
1197
1273
  * @param {string} tokenLayerId - The token layer ID
1198
1274
  * @param {string} text - The text ID
1199
- * @param {number} begin - Start offset (inclusive)
1200
- * @param {number} end - End offset (exclusive)
1275
+ * @param {number} begin - Start offset, inclusive (Unicode code points)
1276
+ * @param {number} end - End offset, exclusive (Unicode code points)
1201
1277
  * @param {number} [precedence] - Ordering precedence
1202
1278
  * @param {any} [metadata] - Metadata map. Omit to leave unset; pass null to send JSON null.
1203
1279
  */
@@ -1224,8 +1300,8 @@ class PlaidClient {
1224
1300
  /**
1225
1301
  * Update a token.
1226
1302
  * @param {string} tokenId - The token ID
1227
- * @param {number} [begin] - New start offset
1228
- * @param {number} [end] - New end offset
1303
+ * @param {number} [begin] - New start offset, inclusive (Unicode code points)
1304
+ * @param {number} [end] - New end offset, exclusive (Unicode code points)
1229
1305
  * @param {?number} [precedence] - Ordering precedence. Omit (undefined)
1230
1306
  * to leave unchanged; pass a number to set; pass null explicitly to
1231
1307
  * CLEAR it (revert to no explicit ordering). bodyOf keeps null but
@@ -1248,10 +1324,10 @@ class PlaidClient {
1248
1324
  bulkDelete: (body) =>
1249
1325
  this._request('DELETE', '/api/v1/tokens/bulk', { body }),
1250
1326
  /**
1251
- * Split a token at a character offset. The original token becomes the left half
1252
- * (keeps its ID, spans, vocab-links); the new right token's ID is returned.
1327
+ * Split a token at a Unicode code-point offset. The original token becomes the
1328
+ * left half (keeps its ID, spans, vocab-links); the new right token's ID is returned.
1253
1329
  * @param {string} tokenId - The token ID
1254
- * @param {number} position - Offset to split at (strictly between begin and end)
1330
+ * @param {number} position - Code-point offset to split at (strictly between begin and end)
1255
1331
  */
1256
1332
  split: (tokenId, position) =>
1257
1333
  this._request('POST', `/api/v1/tokens/${tokenId}/split`, {
@@ -1274,8 +1350,8 @@ class PlaidClient {
1274
1350
  * auto-adjusted to preserve the partition; on non-overlapping layers a shift that
1275
1351
  * would create an overlap is rejected.
1276
1352
  * @param {string} tokenId - The token ID
1277
- * @param {number} [begin] - New start offset
1278
- * @param {number} [end] - New end offset
1353
+ * @param {number} [begin] - New start offset, inclusive (Unicode code points)
1354
+ * @param {number} [end] - New end offset, exclusive (Unicode code points)
1279
1355
  */
1280
1356
  shift: (tokenId, begin, end) =>
1281
1357
  this._request('POST', `/api/v1/tokens/${tokenId}/shift`, {
@@ -1298,6 +1374,15 @@ class PlaidClient {
1298
1374
  this._request('DELETE', `/api/v1/tokens/${tokenId}/metadata`, {
1299
1375
  skipResponseTransform: true,
1300
1376
  }),
1377
+ /**
1378
+ * Patch (shallow-merge) metadata for a token. Keys present in the body are set or overwritten; keys not present are left untouched; a key whose value is null is deleted. Merging is top-level only (nested objects are replaced wholesale, not deep-merged), so a literal null cannot be stored as a value. An empty body changes no metadata.
1379
+ * @param {string} tokenId - The token ID
1380
+ * @param {any} body - The metadata patch
1381
+ */
1382
+ patchMetadata: (tokenId, body) =>
1383
+ this._request('PATCH', `/api/v1/tokens/${tokenId}/metadata`, {
1384
+ rawBody: body, skipResponseTransform: true,
1385
+ }),
1301
1386
  };
1302
1387
 
1303
1388
  this.messages = {
@@ -1366,8 +1451,8 @@ class PlaidClient {
1366
1451
  * await client.query({
1367
1452
  * find: ['?s1', '?s2'],
1368
1453
  * where: [
1369
- * ['span', '?s1', { layer: 'pos', value: 'NOUN' }],
1370
- * ['span', '?s2', { layer: 'pos', value: 'VERB' }],
1454
+ * ['span', '?s1', { layer: posLayerId, value: 'NOUN' }],
1455
+ * ['span', '?s2', { layer: posLayerId, value: 'VERB' }],
1371
1456
  * ['covers', '?s1', '?t1'], ['covers', '?s2', '?t2'],
1372
1457
  * ['precedes', '?t1', '?t2'],
1373
1458
  * ],
@@ -1375,7 +1460,17 @@ class PlaidClient {
1375
1460
  * limit: 100,
1376
1461
  * });
1377
1462
  *
1378
- * @param {Object} body - The query AST ({find, where, scope?, limit?, return?}).
1463
+ * A `layer` is referenced by its id (its UUID) only not by name or
1464
+ * path. To match a layer by name, bind it with a `*-layer` clause (e.g.
1465
+ * `['span-layer', '?sl', { name: 'pos' }]`) and use the variable.
1466
+ *
1467
+ * Optional keys: `scope` (restrict to projects by id, `{projectIds}`), `orderBy`
1468
+ * (sort rows), and `bindings` (substitute `?name` placeholders with literals).
1469
+ * `return` may also be an aggregate spec `{group, aggregates}`. See the query
1470
+ * language reference.
1471
+ *
1472
+ * @param {Object} body - The query AST ({find, where, scope?, limit?, orderBy?,
1473
+ * return?, bindings?}).
1379
1474
  * @returns {Promise<Object>} For 'ids'/'entities': {columns, results, count, truncated}.
1380
1475
  * For 'count': {return: 'count', count}. Entity cells are full entity objects
1381
1476
  * (same shape as the GET endpoints).
@@ -1530,3 +1625,20 @@ class PlaidClient {
1530
1625
 
1531
1626
  export default PlaidClient;
1532
1627
  export { PlaidClient };
1628
+
1629
+ // Unicode code-point helpers for text offsets (token begin/end are code-point
1630
+ // indices). See ./codepoint.js.
1631
+ export { cpLength, cpSlice, cpSlicer, utf16ToCp, cpToUtf16, cpIndexOf } from './codepoint.js';
1632
+ export { PLAID_NAMESPACE, ROLE_KEY, ROLES, readRole, findByRole } from './roles.js';
1633
+ // Service self-description helpers: filter discovered services by task, read a
1634
+ // service's parameter schema/summary, and build/coerce form values. See
1635
+ // ./serviceSchema.js.
1636
+ export {
1637
+ TASKS,
1638
+ servesTask,
1639
+ filterServicesByTask,
1640
+ getParamSchema,
1641
+ getServiceSummary,
1642
+ buildDefaultValues,
1643
+ coerceParamValues,
1644
+ } from './serviceSchema.js';
package/src/roles.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared layer-role vocabulary for cross-app interoperability.
3
+ *
4
+ * Apps that share a Plaid project agree on the *substrate* — the text and token
5
+ * layers — by tagging each shared layer with a ROLE under the reserved `plaid`
6
+ * config namespace (`config.plaid.role`, a scalar). Annotations stay private to
7
+ * each app under that app's own namespace. See the Plaid manual, "Layer
8
+ * Interoperability". The role inventory is small and fixed:
9
+ *
10
+ * baseline the primary text layer
11
+ * sentence sentence token layer
12
+ * word orthographic-word token layer (CoNLL-U "token")
13
+ * syntactic-word grammatical words below the word (CoNLL-U "word" / MWT splits)
14
+ * morpheme morpheme token layer
15
+ * time-alignment media-timeline token layer
16
+ *
17
+ * Only these values are understood across apps; an app may store any string but
18
+ * loses interoperability for unknown values.
19
+ */
20
+
21
+ /** The reserved config namespace for cross-app conventions. */
22
+ export const PLAID_NAMESPACE = 'plaid';
23
+
24
+ /** The config key, under `plaid`, holding a layer's role. */
25
+ export const ROLE_KEY = 'role';
26
+
27
+ /** The fixed role inventory. */
28
+ export const ROLES = Object.freeze({
29
+ BASELINE: 'baseline',
30
+ SENTENCE: 'sentence',
31
+ WORD: 'word',
32
+ SYNTACTIC_WORD: 'syntactic-word',
33
+ MORPHEME: 'morpheme',
34
+ TIME_ALIGNMENT: 'time-alignment',
35
+ });
36
+
37
+ /**
38
+ * The role recorded on a layer's `config`, or null if none.
39
+ * @param {object} [config] a layer's `config` object
40
+ * @returns {string|null}
41
+ */
42
+ export function readRole(config) {
43
+ const v = config?.[PLAID_NAMESPACE]?.[ROLE_KEY];
44
+ return v == null ? null : v;
45
+ }
46
+
47
+ /**
48
+ * The first layer in `layers` carrying the given role, or null. The single
49
+ * "find a layer by its role" primitive — apps build their named finders
50
+ * (findWordTokenLayer, etc.) on top of this.
51
+ * @param {Array<{config?: object}>} [layers]
52
+ * @param {string} role
53
+ * @returns {object|null}
54
+ */
55
+ export function findByRole(layers, role) {
56
+ return (layers || []).find(l => readRole(l?.config) === role) || null;
57
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Service self-description: tasks, summary, and a parameter schema.
3
+ *
4
+ * The service framework's transport carries an opaque `extras` JSON map on every
5
+ * registered service (see services.js). This module standardizes what a service
6
+ * advertises in that map so apps can, at a fixed integration point (a "task"
7
+ * like tokenize / parse / transcribe):
8
+ *
9
+ * 1. SELECT one of several services that serve the task,
10
+ * 2. let the user SPECIFY arguments the service declares, and
11
+ * 3. show the user a service-provided SUMMARY.
12
+ *
13
+ * Shape of a service's `extras` (camelCase here; Python services author the
14
+ * snake_case equivalent and the client transform converts it — see the Plaid
15
+ * manual, "Describing a service"):
16
+ *
17
+ * {
18
+ * schemaVersion: 1,
19
+ * tasks: ["tokenize"], // controlled vocab; REPLACES tok:/asr: id prefixes
20
+ * summary: "## markdown …", // rich human description
21
+ * parameters: [ // ordered; rendered into a form
22
+ * { key, label, type, description?, default?, required?,
23
+ * options?: [{value, label}], // enum / multiselect
24
+ * min?, max?, step?, // number
25
+ * placeholder?, multiline? } // string
26
+ * ]
27
+ * }
28
+ *
29
+ * A parameter's `key` is a string VALUE, so it passes over the wire verbatim;
30
+ * the UI sends `{ [param.key]: value }` in the request data. Declare each `key`
31
+ * in your service's own convention (Python: snake_case; JS: camelCase) and read
32
+ * it back under that same key — request data is recased symmetrically, so a
33
+ * snake_case key round-trips unchanged to a JS UI and back.
34
+ */
35
+
36
+ /** The controlled task vocabulary — the fixed integration-point goals. */
37
+ export const TASKS = Object.freeze({
38
+ TOKENIZE: 'tokenize',
39
+ PARSE: 'parse',
40
+ TRANSCRIBE: 'transcribe',
41
+ /** Create vocab links for unlinked tokens. Services should stamp link
42
+ * metadata with the provenance convention ({ prov: 'inferred', provSource:
43
+ * 'service:<id>' }) so UIs can distinguish machine links until confirmed. */
44
+ LINK_VOCAB: 'link-vocab',
45
+ });
46
+
47
+ /**
48
+ * Legacy id-prefix → task map, for services that have not yet migrated to a
49
+ * declared `tasks` array. Drop once all services advertise `tasks`.
50
+ */
51
+ const LEGACY_TASK_PREFIXES = Object.freeze({
52
+ [TASKS.TOKENIZE]: 'tok:',
53
+ [TASKS.TRANSCRIBE]: 'asr:',
54
+ });
55
+
56
+ /**
57
+ * Does `service` serve `task`? Prefers the declared `extras.tasks` array; falls
58
+ * back to the legacy id-prefix convention for un-migrated services.
59
+ * @param {{serviceId?: string, extras?: {tasks?: string[]}}} service
60
+ * @param {string} task one of TASKS
61
+ * @returns {boolean}
62
+ */
63
+ export function servesTask(service, task) {
64
+ const declared = service?.extras?.tasks;
65
+ if (Array.isArray(declared) && declared.length) {
66
+ return declared.includes(task);
67
+ }
68
+ const prefix = LEGACY_TASK_PREFIXES[task];
69
+ return !!prefix && typeof service?.serviceId === 'string' && service.serviceId.startsWith(prefix);
70
+ }
71
+
72
+ /**
73
+ * The discovered services that serve `task`.
74
+ * @param {Array} services result of client.messages.discoverServices()
75
+ * @param {string} task one of TASKS
76
+ * @returns {Array}
77
+ */
78
+ export function filterServicesByTask(services, task) {
79
+ return (services || []).filter((s) => servesTask(s, task));
80
+ }
81
+
82
+ /**
83
+ * The parameter schema a service declares (ordered array), or [].
84
+ * @param {{extras?: {parameters?: Array}}} service
85
+ * @returns {Array}
86
+ */
87
+ export function getParamSchema(service) {
88
+ const params = service?.extras?.parameters;
89
+ return Array.isArray(params) ? params : [];
90
+ }
91
+
92
+ /**
93
+ * A service's human summary: the rich `extras.summary`, else the short
94
+ * `description`, else ''.
95
+ * @param {{description?: string, extras?: {summary?: string}}} service
96
+ * @returns {string}
97
+ */
98
+ export function getServiceSummary(service) {
99
+ return service?.extras?.summary || service?.description || '';
100
+ }
101
+
102
+ /** Valid option values for an enum/multiselect param. */
103
+ function optionValues(param) {
104
+ return Array.isArray(param?.options) ? param.options.map((o) => o.value) : [];
105
+ }
106
+
107
+ /**
108
+ * A single parameter's default, honoring its declared `default` then falling
109
+ * back per type. For enum/multiselect the declared default is validated against
110
+ * `options` (an out-of-range declared default never escapes).
111
+ */
112
+ function defaultForParam(param) {
113
+ const opts = optionValues(param);
114
+ if (param.type === 'enum') {
115
+ if (param.default != null && opts.includes(param.default)) return param.default;
116
+ return opts[0] ?? '';
117
+ }
118
+ if (param.type === 'multiselect') {
119
+ const arr = Array.isArray(param.default) ? param.default : [];
120
+ return opts.length ? arr.filter((x) => opts.includes(x)) : arr;
121
+ }
122
+ if (param.default !== undefined && param.default !== null) return param.default;
123
+ switch (param.type) {
124
+ case 'number': return typeof param.min === 'number' ? param.min : 0;
125
+ case 'boolean': return false;
126
+ case 'string':
127
+ default: return '';
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Default values keyed by param key — the initial form state.
133
+ * @param {Array} schema getParamSchema(service)
134
+ * @returns {Object} { [key]: defaultValue }
135
+ */
136
+ export function buildDefaultValues(schema) {
137
+ const out = {};
138
+ for (const param of schema || []) {
139
+ if (!param || !param.key) continue;
140
+ out[param.key] = defaultForParam(param);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * Coerce/validate raw form values against the schema. Returns the cleaned
147
+ * values (keyed by param key, ready to merge into the request payload) plus any
148
+ * validation errors keyed by param key. Unknown keys in `raw` are dropped.
149
+ * @param {Array} schema getParamSchema(service)
150
+ * @param {Object} raw current form values
151
+ * @returns {{values: Object, errors: Object}}
152
+ */
153
+ export function coerceParamValues(schema, raw) {
154
+ const values = {};
155
+ const errors = {};
156
+ const src = raw || {};
157
+ for (const param of schema || []) {
158
+ if (!param || !param.key) continue;
159
+ const k = param.key;
160
+ let v = src[k];
161
+ if (v === undefined) v = defaultForParam(param);
162
+
163
+ switch (param.type) {
164
+ case 'number': {
165
+ // Blank / nullish counts as "missing" → the param's default (matches the
166
+ // Python client, where float('') raises and falls back to the default).
167
+ let n;
168
+ if (v === '' || v == null || (typeof v === 'string' && v.trim() === '')) {
169
+ n = defaultForParam(param);
170
+ } else {
171
+ n = typeof v === 'number' ? v : Number(v);
172
+ if (Number.isNaN(n)) n = defaultForParam(param);
173
+ }
174
+ if (typeof param.min === 'number') n = Math.max(param.min, n);
175
+ if (typeof param.max === 'number') n = Math.min(param.max, n);
176
+ v = n;
177
+ break;
178
+ }
179
+ case 'boolean':
180
+ v = v === true || v === 'true';
181
+ break;
182
+ case 'enum': {
183
+ const opts = optionValues(param);
184
+ if (opts.length && !opts.includes(v)) v = defaultForParam(param);
185
+ break;
186
+ }
187
+ case 'multiselect': {
188
+ const opts = optionValues(param);
189
+ const arr = Array.isArray(v) ? v : (v == null || v === '' ? [] : [v]);
190
+ v = opts.length ? arr.filter((x) => opts.includes(x)) : arr;
191
+ break;
192
+ }
193
+ case 'string':
194
+ default:
195
+ v = v == null ? '' : String(v);
196
+ break;
197
+ }
198
+
199
+ if (param.required) {
200
+ const empty = v === '' || v == null || (Array.isArray(v) && v.length === 0);
201
+ if (empty) errors[k] = `${param.label || k} is required`;
202
+ }
203
+ values[k] = v;
204
+ }
205
+ return { values, errors };
206
+ }
package/src/transforms.js CHANGED
@@ -25,7 +25,7 @@ export function transformKeyFromCamel(key) {
25
25
  // are never re-cased or namespace-stripped — a label like `case-marker` used as
26
26
  // a map key survives intact. Everything else is API envelope and gets the
27
27
  // usual case conversion.
28
- const OPAQUE_KEYS = new Set(['metadata', 'config']);
28
+ const OPAQUE_KEYS = new Set(['metadata', 'config', 'bindings']);
29
29
 
30
30
  /**
31
31
  * Recursively transform request object keys from camelCase to kebab-case.