sg-widgets-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1015 @@
1
+ //#region src/field-types.ts
2
+ /**
3
+ * Field data types and their filter vocabularies.
4
+ *
5
+ * Every list here is what a live Flow Production Tracking site answered when a
6
+ * probe sent it an unknown operator: the 400 names the legal set. Source:
7
+ * sg-groundtruth corpus, `findings/field_types/*.md` and `findings/017_filter_operators.md`.
8
+ */
9
+ const DATA_TYPES = [
10
+ "text",
11
+ "number",
12
+ "float",
13
+ "percent",
14
+ "duration",
15
+ "timecode",
16
+ "currency",
17
+ "checkbox",
18
+ "date",
19
+ "date_time",
20
+ "list",
21
+ "status_list",
22
+ "entity",
23
+ "multi_entity",
24
+ "entity_type",
25
+ "color",
26
+ "image",
27
+ "url",
28
+ "jsonb",
29
+ "serializable",
30
+ "uuid",
31
+ "calculated",
32
+ "summary",
33
+ "password",
34
+ "pivot_column",
35
+ "footage",
36
+ "tag_list"
37
+ ];
38
+ const OPERATORS = [
39
+ "is",
40
+ "is_not",
41
+ "in",
42
+ "not_in",
43
+ "contains",
44
+ "not_contains",
45
+ "starts_with",
46
+ "ends_with",
47
+ "greater_than",
48
+ "less_than",
49
+ "between",
50
+ "in_last",
51
+ "not_in_last",
52
+ "in_next",
53
+ "not_in_next",
54
+ "in_calendar_day",
55
+ "in_calendar_week",
56
+ "in_calendar_month",
57
+ "in_calendar_year",
58
+ "name_is",
59
+ "name_contains",
60
+ "name_not_contains",
61
+ "type_is",
62
+ "type_is_not"
63
+ ];
64
+ const TIME_UNITS = [
65
+ "HOUR",
66
+ "DAY",
67
+ "WEEK",
68
+ "MONTH",
69
+ "YEAR"
70
+ ];
71
+ const EQUALITY = [
72
+ "is",
73
+ "is_not",
74
+ "in",
75
+ "not_in"
76
+ ];
77
+ const TEXT = [
78
+ "contains",
79
+ "not_contains",
80
+ "is",
81
+ "is_not",
82
+ "starts_with",
83
+ "ends_with",
84
+ "in",
85
+ "not_in"
86
+ ];
87
+ const NUMERIC = [
88
+ "is",
89
+ "is_not",
90
+ "greater_than",
91
+ "less_than",
92
+ "between",
93
+ "in",
94
+ "not_in"
95
+ ];
96
+ const TEMPORAL = [
97
+ "is",
98
+ "is_not",
99
+ "greater_than",
100
+ "less_than",
101
+ "in_last",
102
+ "not_in_last",
103
+ "in_next",
104
+ "not_in_next",
105
+ "in_calendar_week",
106
+ "in_calendar_month",
107
+ "in_calendar_day",
108
+ "in_calendar_year",
109
+ "between",
110
+ "in",
111
+ "not_in"
112
+ ];
113
+ const LINK = [
114
+ "is",
115
+ "is_not",
116
+ "name_contains",
117
+ "name_not_contains",
118
+ "name_is",
119
+ "type_is",
120
+ "type_is_not",
121
+ "in",
122
+ "not_in"
123
+ ];
124
+ const NONE = [];
125
+ /**
126
+ * Operators each data type accepts in a `_search` filter. An empty list means the
127
+ * type "cannot be used in a filter" and the API 400s on every operator.
128
+ */
129
+ const OPERATORS_BY_TYPE = {
130
+ text: TEXT,
131
+ number: NUMERIC,
132
+ float: NUMERIC,
133
+ percent: NUMERIC,
134
+ duration: NUMERIC,
135
+ timecode: NUMERIC,
136
+ currency: NUMERIC,
137
+ footage: NUMERIC,
138
+ checkbox: ["is", "is_not"],
139
+ date: TEMPORAL,
140
+ date_time: TEMPORAL,
141
+ list: EQUALITY,
142
+ status_list: EQUALITY,
143
+ entity_type: EQUALITY,
144
+ color: EQUALITY,
145
+ uuid: EQUALITY,
146
+ entity: LINK,
147
+ multi_entity: LINK,
148
+ tag_list: LINK,
149
+ image: ["is", "is_not"],
150
+ jsonb: [
151
+ "is",
152
+ "is_not",
153
+ "contains",
154
+ "not_contains"
155
+ ],
156
+ url: NONE,
157
+ serializable: NONE,
158
+ calculated: NONE,
159
+ summary: NONE,
160
+ password: NONE,
161
+ pivot_column: NONE
162
+ };
163
+ const VALUE_SHAPE = {
164
+ is: "scalar",
165
+ is_not: "scalar",
166
+ in: "list",
167
+ not_in: "list",
168
+ contains: "scalar",
169
+ not_contains: "scalar",
170
+ starts_with: "scalar",
171
+ ends_with: "scalar",
172
+ greater_than: "scalar",
173
+ less_than: "scalar",
174
+ between: "range",
175
+ in_last: "relative",
176
+ not_in_last: "relative",
177
+ in_next: "relative",
178
+ not_in_next: "relative",
179
+ in_calendar_day: "calendar",
180
+ in_calendar_week: "calendar",
181
+ in_calendar_month: "calendar",
182
+ in_calendar_year: "calendar",
183
+ name_is: "string",
184
+ name_contains: "string",
185
+ name_not_contains: "string",
186
+ type_is: "string",
187
+ type_is_not: "string"
188
+ };
189
+ /**
190
+ * Operators whose result set includes rows where the field is null.
191
+ * `is_not X` is not the complement of `is X` on Flow PT: every negating operator
192
+ * matches unset rows, while comparisons exclude them.
193
+ */
194
+ const NEGATING_OPERATORS = /* @__PURE__ */ new Set([
195
+ "is_not",
196
+ "not_in",
197
+ "not_contains",
198
+ "not_in_last",
199
+ "not_in_next",
200
+ "name_not_contains",
201
+ "type_is_not"
202
+ ]);
203
+ function isDataType(value) {
204
+ return DATA_TYPES.includes(value);
205
+ }
206
+ function isOperator(value) {
207
+ return OPERATORS.includes(value);
208
+ }
209
+ /** Operators legal for a data type. Unknown types get the equality set, the smallest one the API prints. */
210
+ function operatorsFor(dataType) {
211
+ return isDataType(dataType) ? OPERATORS_BY_TYPE[dataType] : EQUALITY;
212
+ }
213
+ function isFilterable(dataType) {
214
+ return operatorsFor(dataType).length > 0;
215
+ }
216
+ function supportsOperator(dataType, operator) {
217
+ return operatorsFor(dataType).includes(operator);
218
+ }
219
+ /** Types whose scalar value is a `{type, id}` entity hash. */
220
+ function isLinkType(dataType) {
221
+ return dataType === "entity" || dataType === "multi_entity" || dataType === "tag_list";
222
+ }
223
+ /** Types whose scalar value is a number (integer unless noted). */
224
+ function isNumericType(dataType) {
225
+ return [
226
+ "number",
227
+ "float",
228
+ "percent",
229
+ "duration",
230
+ "timecode",
231
+ "currency",
232
+ "footage"
233
+ ].includes(dataType);
234
+ }
235
+ function isTemporalType(dataType) {
236
+ return dataType === "date" || dataType === "date_time";
237
+ }
238
+ //#endregion
239
+ //#region src/filter.ts
240
+ /**
241
+ * The filter array `entity_types` wants, `[]` for no filter
242
+ * (post_entity_text_search). The array form is `and` only and cannot express a
243
+ * nested group, so one is refused here rather than sent and guessed at.
244
+ */
245
+ function toFilterArray(filter) {
246
+ if (!filter) return [];
247
+ if (Array.isArray(filter)) return filter;
248
+ if (filter.logical_operator !== "and") throw new Error("A '_text_search' filter is an array of conditions, which is 'and' only.");
249
+ return filter.conditions.map((c) => {
250
+ if (!Array.isArray(c)) throw new Error("A '_text_search' filter is an array of conditions and cannot nest a group.");
251
+ return c;
252
+ });
253
+ }
254
+ function group(logicalOperator, conditions = []) {
255
+ return {
256
+ kind: "group",
257
+ logicalOperator,
258
+ conditions
259
+ };
260
+ }
261
+ function condition(path, operator, value) {
262
+ return {
263
+ kind: "condition",
264
+ path,
265
+ operator,
266
+ value
267
+ };
268
+ }
269
+ function emptyFilter() {
270
+ return group("and");
271
+ }
272
+ /**
273
+ * A condition is blank when it has no path, or when its operator needs a value
274
+ * and none was given. Blank conditions are dropped on serialisation rather than
275
+ * sent as a degenerate filter.
276
+ */
277
+ function isBlankCondition(c) {
278
+ if (!c.path) return true;
279
+ const shape = VALUE_SHAPE[c.operator];
280
+ const v = c.value;
281
+ switch (shape) {
282
+ case "scalar":
283
+ case "string": return v === void 0 || v === "";
284
+ case "list": return !Array.isArray(v) || v.length === 0;
285
+ case "range": return !Array.isArray(v) || v.length !== 2 || v[0] == null || v[1] == null;
286
+ case "relative": return !Array.isArray(v) || v.length !== 2 || typeof v[0] !== "number" || v[0] <= 0 || !v[1];
287
+ case "calendar": return typeof v !== "number";
288
+ case "none": return false;
289
+ }
290
+ }
291
+ /** True when serialisation would produce no conditions at all. */
292
+ function isEmptyFilter(node) {
293
+ if (node.kind === "condition") return isBlankCondition(node);
294
+ return node.conditions.every(isEmptyFilter);
295
+ }
296
+ function stripName(v) {
297
+ if (v !== null && typeof v === "object") return {
298
+ type: v.type,
299
+ id: v.id
300
+ };
301
+ return v;
302
+ }
303
+ function serializeValue(c) {
304
+ const shape = VALUE_SHAPE[c.operator];
305
+ const v = c.value;
306
+ if (shape === "list" || shape === "range") return v.map(stripName);
307
+ if (shape === "relative" || shape === "calendar" || shape === "none") return v;
308
+ return stripName(v);
309
+ }
310
+ /**
311
+ * Serialise a tree to the `api3_hash` `filters` value. Blank conditions and
312
+ * groups that end up empty are dropped. Returns `null` when nothing remains,
313
+ * so callers can omit `filters` rather than send `[]` (which matches every
314
+ * row on the site).
315
+ */
316
+ function toApi3Hash(node) {
317
+ const out = serializeNode(node);
318
+ if (out === null) return null;
319
+ if (Array.isArray(out)) return {
320
+ logical_operator: "and",
321
+ conditions: [out]
322
+ };
323
+ return out;
324
+ }
325
+ function serializeNode(node) {
326
+ if (node.kind === "condition") {
327
+ if (isBlankCondition(node)) return null;
328
+ return [
329
+ node.path,
330
+ node.operator,
331
+ serializeValue(node)
332
+ ];
333
+ }
334
+ const conditions = node.conditions.map(serializeNode).filter((c) => c !== null);
335
+ if (conditions.length === 0) return null;
336
+ return {
337
+ logical_operator: node.logicalOperator,
338
+ conditions
339
+ };
340
+ }
341
+ /** Parse a wire group (or a flat `api3_array` list) back into the editor tree. */
342
+ function fromWire(wire) {
343
+ if (Array.isArray(wire)) return group("and", wire.map(fromWireCondition));
344
+ return group(wire.logical_operator, wire.conditions.map((c) => Array.isArray(c) ? fromWireCondition(c) : fromWire(c)));
345
+ }
346
+ function fromWireCondition([path, operator, value]) {
347
+ return condition(path, operator, value);
348
+ }
349
+ /** Depth-first walk, useful for collecting referenced paths or validating against a schema. */
350
+ function* walk(node) {
351
+ yield node;
352
+ if (node.kind === "group") for (const child of node.conditions) yield* walk(child);
353
+ }
354
+ function referencedPaths(node) {
355
+ const paths = /* @__PURE__ */ new Set();
356
+ for (const n of walk(node)) if (n.kind === "condition" && n.path) paths.add(n.path);
357
+ return [...paths];
358
+ }
359
+ //#endregion
360
+ //#region src/entity-path.ts
361
+ /** How a type is spelled in a url. Internal: nothing outside this package addresses the API by path. */
362
+ function pluralPath(entityType) {
363
+ const snake = entityType.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
364
+ if (snake.endsWith("s")) return `${snake}es`;
365
+ if (snake.endsWith("y") && !/[aeiou]y$/.test(snake)) return `${snake.slice(0, -1)}ies`;
366
+ return `${snake}s`;
367
+ }
368
+ //#endregion
369
+ //#region src/schema.ts
370
+ /**
371
+ * Fields the schema types one way and the API answers another, keyed
372
+ * `<Type>.<field>`. `Note.read_by_current_user` holds the codes `unread` and
373
+ * `read`, never a boolean, so every control derived from it is a list and not a
374
+ * checkbox (067_notes_in_the_stream, entity_types/Note).
375
+ *
376
+ * An entry carries the whole field, because a site may leave it out of the schema
377
+ * and still answer it on the row: `GET /schema/Note/fields` answers 33 fields and
378
+ * no `read_by_current_user` among them, while `GET /schema/Note/fields/read_by_current_user`
379
+ * answers 200 with `data: null`, which is what an undeclared field reads as and a
380
+ * name that is nothing at all answers 404 (068_note_read_state). `normalizeFields`
381
+ * adds what the schema left out. A site that declares the field keeps its own
382
+ * display name and editability and is corrected on the data type, the values and
383
+ * the operators alone.
384
+ *
385
+ * `operators` is the set the API evaluates, which is not always the set it
386
+ * advertises. `read_by_current_user` names `is`, `is_not`, `in` and `not_in` in its
387
+ * own `Valid relations`, and evaluates only the first two: `in`, `not_in` and an
388
+ * `is` value outside the vocabulary all answer 200 with the caller's unread rows
389
+ * whatever the list holds, in `_search` and in `_summarize`, under both body shapes
390
+ * (068_note_read_state).
391
+ *
392
+ * The value is per person: a person's `PUT` answers 200 and the re-read follows it,
393
+ * so the field is editable. A script has no read state, and its own write answers
394
+ * 200 and stores nothing (068_note_read_state).
395
+ */
396
+ const FIELD_SCHEMA_OVERRIDES = { "Note.read_by_current_user": {
397
+ displayName: "Read by Current User",
398
+ dataType: "list",
399
+ validValues: ["unread", "read"],
400
+ operators: ["is", "is_not"],
401
+ editable: true,
402
+ mandatory: false,
403
+ unique: false
404
+ } };
405
+ /** The part of an override that corrects a declaring site. */
406
+ const PATCHED_KEYS = [
407
+ "dataType",
408
+ "validValues",
409
+ "operators"
410
+ ];
411
+ /** What a declared field's schema has to be corrected to, when it is one of those. */
412
+ function fieldSchemaOverride(entityType, name) {
413
+ const whole = FIELD_SCHEMA_OVERRIDES[`${entityType}.${name}`];
414
+ if (!whole) return void 0;
415
+ const patch = {};
416
+ for (const key of PATCHED_KEYS) if (whole[key] !== void 0) Object.assign(patch, { [key]: whole[key] });
417
+ return patch;
418
+ }
419
+ /** The whole field, for a site whose schema does not declare it; undefined when it is not one of those. */
420
+ function undeclaredField(entityType, name) {
421
+ const whole = FIELD_SCHEMA_OVERRIDES[`${entityType}.${name}`];
422
+ return whole ? {
423
+ name,
424
+ entityType,
425
+ ...whole
426
+ } : void 0;
427
+ }
428
+ function prop(props, key) {
429
+ const p = props?.[key];
430
+ return p ? p.value : void 0;
431
+ }
432
+ function normalizeField(name, raw) {
433
+ const props = raw.properties;
434
+ const field = {
435
+ name,
436
+ displayName: raw.name.value,
437
+ entityType: raw.entity_type.value,
438
+ dataType: raw.data_type.value,
439
+ editable: raw.editable.value,
440
+ mandatory: raw.mandatory.value,
441
+ unique: raw.unique.value
442
+ };
443
+ const validTypes = prop(props, "valid_types");
444
+ if (validTypes) field.validTypes = validTypes;
445
+ const validValues = prop(props, "valid_values");
446
+ if (validValues) field.validValues = validValues;
447
+ const displayValues = prop(props, "display_values");
448
+ if (displayValues) field.displayValues = displayValues;
449
+ const hiddenValues = prop(props, "hidden_values");
450
+ if (hiddenValues) field.hiddenValues = hiddenValues;
451
+ const defaultValue = prop(props, "default_value");
452
+ if (defaultValue !== void 0) field.defaultValue = defaultValue;
453
+ const description = prop(props, "description");
454
+ if (description) field.description = description;
455
+ return {
456
+ ...field,
457
+ ...fieldSchemaOverride(field.entityType, name)
458
+ };
459
+ }
460
+ /**
461
+ * Every field of a type, with the fields an override declares and the schema omitted.
462
+ *
463
+ * `entityType` is only needed for a response with no fields at all; otherwise the
464
+ * fields name their own type.
465
+ */
466
+ function normalizeFields(response, entityType) {
467
+ const out = {};
468
+ for (const [name, raw] of Object.entries(response.data)) out[name] = normalizeField(name, raw);
469
+ const type = entityType ?? Object.values(out)[0]?.entityType;
470
+ if (!type) return out;
471
+ for (const key of Object.keys(FIELD_SCHEMA_OVERRIDES)) {
472
+ const at = key.indexOf(".");
473
+ const name = key.slice(at + 1);
474
+ if (key.slice(0, at) !== type || out[name]) continue;
475
+ const whole = undeclaredField(type, name);
476
+ if (whole) out[name] = whole;
477
+ }
478
+ return out;
479
+ }
480
+ /**
481
+ * The field name a type's own status lives under. Project's is `sg_status`, a plain
482
+ * `list` with the four values a bid goes through and no Status row behind it
483
+ * (probe 018, entity_types/Project).
484
+ */
485
+ function statusFieldNameFor(entityType) {
486
+ return entityType === "Project" ? "sg_status" : "sg_status_list";
487
+ }
488
+ /**
489
+ * The status field of an entity type. Every type but Project uses `sg_status_list`
490
+ * (data type `status_list`); Project's is `sg_status`, a plain `list`. Prefer the
491
+ * schema when you have it; the name guess is the fallback.
492
+ *
493
+ * The conventional name wins over any other `status_list` field on the type. A site
494
+ * is free to add its own — a client status, a delivery status — and a schema read
495
+ * answers them in no order the caller controls, so picking the first one found makes
496
+ * a type's status whichever field the response happened to list first.
497
+ */
498
+ function statusFieldFor(entityType, fields) {
499
+ const name = statusFieldNameFor(entityType);
500
+ if (fields) {
501
+ const own = fields[name];
502
+ if (own) return own;
503
+ const status = Object.values(fields).find((f) => f.dataType === "status_list");
504
+ if (status) return status;
505
+ }
506
+ return name;
507
+ }
508
+ /**
509
+ * The fields Flow PT conventionally uses as a row's human label, in order.
510
+ * `cached_display_name` is the server's own copy, the same string an entity dict
511
+ * carries as its `name`, and wins when present (060_entity_dict_name).
512
+ */
513
+ const DISPLAY_NAME_FIELDS = [
514
+ "cached_display_name",
515
+ "code",
516
+ "name",
517
+ "title",
518
+ "content",
519
+ "subject"
520
+ ];
521
+ function displayNameOf(attributes, fallback = "") {
522
+ if (!attributes) return fallback;
523
+ for (const key of DISPLAY_NAME_FIELDS) {
524
+ const v = attributes[key];
525
+ if (typeof v === "string" && v.length > 0) return v;
526
+ }
527
+ return fallback;
528
+ }
529
+ //#endregion
530
+ //#region src/client.ts
531
+ /**
532
+ * Client adapter.
533
+ *
534
+ * Widgets talk to a `SgClient` and nothing else. Browser code rarely calls the
535
+ * Flow PT REST API directly (credentials, CORS), so most apps implement this
536
+ * against their own proxy. `RestClient` is the reference implementation against
537
+ * the REST API itself, for tests, tools and apps that can hold a token.
538
+ */
539
+ /** The row a node stands for, or null when it stands for a type or nothing. */
540
+ function hierarchyEntity(ref) {
541
+ if (!ref || ref.kind !== "entity" || ref.value === null || typeof ref.value !== "object") return null;
542
+ return ref.value;
543
+ }
544
+ /**
545
+ * The cut the event log takes, as the `and` group `_search` wants. Nothing here
546
+ * touches `meta`: it takes no filter (025_event_log).
547
+ */
548
+ function eventLogFilters(options = {}) {
549
+ const conditions = [];
550
+ if (options.projectId !== void 0) conditions.push([
551
+ "project",
552
+ "is",
553
+ {
554
+ type: "Project",
555
+ id: options.projectId
556
+ }
557
+ ]);
558
+ if (options.entity) conditions.push([
559
+ "entity",
560
+ "is",
561
+ {
562
+ type: options.entity.type,
563
+ id: options.entity.id
564
+ }
565
+ ]);
566
+ if (options.eventType !== void 0) conditions.push(Array.isArray(options.eventType) ? [
567
+ "event_type",
568
+ "in",
569
+ options.eventType
570
+ ] : [
571
+ "event_type",
572
+ "is",
573
+ options.eventType
574
+ ]);
575
+ if (options.attributeName !== void 0) conditions.push([
576
+ "attribute_name",
577
+ "is",
578
+ options.attributeName
579
+ ]);
580
+ if (options.since !== void 0) conditions.push([
581
+ "created_at",
582
+ "greater_than",
583
+ options.since
584
+ ]);
585
+ if (options.until !== void 0) conditions.push([
586
+ "created_at",
587
+ "less_than",
588
+ options.until
589
+ ]);
590
+ return {
591
+ logical_operator: "and",
592
+ conditions
593
+ };
594
+ }
595
+ /**
596
+ * What an event is read with. `audit_trail` is never returned even when it is
597
+ * named, so it is not asked for (025_event_log).
598
+ */
599
+ const EVENT_LOG_FIELDS = [
600
+ "event_type",
601
+ "attribute_name",
602
+ "description",
603
+ "created_at",
604
+ "meta",
605
+ "entity",
606
+ "project",
607
+ "user"
608
+ ];
609
+ /**
610
+ * Normalise one node and the level below it.
611
+ *
612
+ * The sample response gives a child a `label`, a `ref` and `has_children` but not
613
+ * always a `path`, so a child without one is addressed by appending its ref to the
614
+ * parent's path (post_hierarchy_expand).
615
+ */
616
+ function normalizeHierarchyNode(raw, path) {
617
+ const ref = {
618
+ kind: String(raw.ref?.kind ?? "empty"),
619
+ value: raw.ref?.value ?? null
620
+ };
621
+ const own = raw.path ?? path;
622
+ return {
623
+ label: String(raw.label ?? ""),
624
+ ref,
625
+ path: own,
626
+ parentPath: raw.parent_path ?? null,
627
+ hasChildren: raw.has_children ?? false,
628
+ children: uniqueByPath((raw.children ?? []).map((child) => normalizeHierarchyNode(child, childPath(own, child))))
629
+ };
630
+ }
631
+ /**
632
+ * Children keyed by path, first occurrence kept. `_expand` repeats the "no <field>"
633
+ * bucket (`.../Sequence/__none__`) once after every group on a grouped level, and the
634
+ * repeats are the same node (post_hierarchy_expand, 064_hierarchy_expand_buckets).
635
+ */
636
+ function uniqueByPath(nodes) {
637
+ const seen = /* @__PURE__ */ new Set();
638
+ return nodes.filter((node) => seen.has(node.path) ? false : (seen.add(node.path), true));
639
+ }
640
+ function childPath(parentPath, child) {
641
+ if (typeof child.path === "string") return child.path;
642
+ const value = child.ref?.value;
643
+ if (child.ref?.kind === "entity_type" && typeof value === "string") return `${parentPath}/${value}`;
644
+ if (child.ref?.kind === "entity" && value !== null && typeof value === "object") return `${parentPath}/id/${value.id}`;
645
+ return parentPath;
646
+ }
647
+ var SgApiError = class extends Error {
648
+ status;
649
+ body;
650
+ constructor(status, body, message) {
651
+ super(message ?? `Flow PT API error ${status}`);
652
+ this.status = status;
653
+ this.body = body;
654
+ this.name = "SgApiError";
655
+ }
656
+ };
657
+ const API3_HASH = "application/vnd+shotgun.api3_hash+json";
658
+ function toHashGroup(filter) {
659
+ if (!filter) return {
660
+ logical_operator: "and",
661
+ conditions: []
662
+ };
663
+ if (Array.isArray(filter)) return {
664
+ logical_operator: "and",
665
+ conditions: filter
666
+ };
667
+ return filter;
668
+ }
669
+ var RestClient = class {
670
+ options;
671
+ base;
672
+ siteRoot;
673
+ fetchFn;
674
+ constructor(options) {
675
+ this.options = options;
676
+ this.siteRoot = options.siteUrl.replace(/\/+$/, "");
677
+ this.base = this.siteRoot + "/api/v1";
678
+ this.fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
679
+ }
680
+ async request(method, path, body, params, contentType = API3_HASH) {
681
+ const url = new URL(this.base + path);
682
+ for (const [k, v] of Object.entries(params ?? {})) if (v !== void 0) url.searchParams.set(k, String(v));
683
+ const headers = {
684
+ Accept: "application/json",
685
+ Authorization: `Bearer ${await this.options.token()}`
686
+ };
687
+ if (body !== void 0) headers["Content-Type"] = contentType;
688
+ const init = {
689
+ method,
690
+ headers
691
+ };
692
+ if (body !== void 0) init.body = JSON.stringify(body);
693
+ const res = await this.fetchFn(url, init);
694
+ const text = await res.text();
695
+ const json = text ? JSON.parse(text) : null;
696
+ if (!res.ok) {
697
+ const title = json?.errors?.[0]?.title;
698
+ throw new SgApiError(res.status, json, title);
699
+ }
700
+ return json;
701
+ }
702
+ async entityTypes() {
703
+ const res = await this.request("GET", "/schema");
704
+ return Object.entries(res.data).map(([name, v]) => ({
705
+ name,
706
+ displayName: v.name.value
707
+ }));
708
+ }
709
+ async fields(entityType, projectId) {
710
+ return normalizeFields(await this.request("GET", `/schema/${entityType}/fields`, void 0, { project_id: projectId }), entityType);
711
+ }
712
+ async fieldWithProject(entityType, field, projectId) {
713
+ const res = await this.request("GET", `/schema/${entityType}/fields/${field}`, void 0, { project_id: projectId });
714
+ if (res.data) return normalizeField(field, res.data);
715
+ const known = undeclaredField(entityType, field);
716
+ if (known) return known;
717
+ throw new SgApiError(200, res, `Field '${entityType}.${field}' is not in the schema.`);
718
+ }
719
+ async search(entityType, options) {
720
+ const size = options.page?.size ?? 50;
721
+ const body = {
722
+ filters: options.filters ?? {
723
+ logical_operator: "and",
724
+ conditions: []
725
+ },
726
+ page: {
727
+ size,
728
+ number: options.page?.number ?? 1
729
+ }
730
+ };
731
+ if (options.fields) body["fields"] = options.fields.join(",");
732
+ if (options.sort) body["sort"] = options.sort;
733
+ const res = await this.request("POST", `/entity/${pluralPath(entityType)}/_search`, body);
734
+ return {
735
+ data: res.data,
736
+ hasMore: res.data.length === size
737
+ };
738
+ }
739
+ async textSearch(text, entityTypes, page) {
740
+ const types = {};
741
+ for (const [t, f] of Object.entries(entityTypes)) types[t] = toHashGroup(f);
742
+ const body = {
743
+ text,
744
+ entity_types: types,
745
+ page: {
746
+ size: Math.min(page?.size ?? 25, 25),
747
+ number: page?.number ?? 1
748
+ }
749
+ };
750
+ return (await this.request("POST", "/entity/_text_search", body)).data.map(toTextSearchRow);
751
+ }
752
+ async update(entityType, id, patch) {
753
+ return (await this.request("PUT", `/entity/${pluralPath(entityType)}/${id}`, patch, void 0, "application/json")).data;
754
+ }
755
+ async create(entityType, body) {
756
+ return (await this.request("POST", `/entity/${pluralPath(entityType)}`, body, void 0, "application/json")).data;
757
+ }
758
+ async upload(entityType, id, file) {
759
+ const field = file.field === void 0 ? "" : `/${file.field}`;
760
+ const ticket = await this.request("GET", `/entity/${pluralPath(entityType)}/${id}${field}/_upload`, void 0, { filename: file.filename });
761
+ const stored = await this.fetchFn(ticket.links.upload, {
762
+ method: "PUT",
763
+ body: file.data
764
+ });
765
+ if (!stored.ok) throw new SgApiError(stored.status, await stored.text(), "The presigned upload refused the bytes");
766
+ const completed = await this.fetchFn(new URL(ticket.links.complete_upload, this.siteRoot).toString(), {
767
+ method: "POST",
768
+ headers: {
769
+ Accept: "application/json",
770
+ Authorization: `Bearer ${await this.options.token()}`,
771
+ "Content-Type": "application/json"
772
+ },
773
+ body: JSON.stringify({
774
+ upload_info: ticket.data,
775
+ upload_data: {}
776
+ })
777
+ });
778
+ if (!completed.ok) {
779
+ const text = await completed.text();
780
+ let parsed = text;
781
+ try {
782
+ parsed = text ? JSON.parse(text) : null;
783
+ } catch {
784
+ parsed = text;
785
+ }
786
+ const title = parsed?.errors?.[0]?.title;
787
+ throw new SgApiError(completed.status, parsed, title);
788
+ }
789
+ return {
790
+ uploadType: String(ticket.data["upload_type"] ?? ""),
791
+ uploadInfo: ticket.data,
792
+ etag: stored.headers.get("ETag")?.replace(/"/g, "") ?? null
793
+ };
794
+ }
795
+ async summarize(entityType, options = {}) {
796
+ const body = {
797
+ filters: options.filters ?? {
798
+ logical_operator: "and",
799
+ conditions: []
800
+ },
801
+ summary_fields: options.summaryFields ?? [{
802
+ field: "id",
803
+ type: "count"
804
+ }]
805
+ };
806
+ if (options.grouping) body["grouping"] = options.grouping.map((g) => ({
807
+ field: g.field,
808
+ type: g.type ?? "exact",
809
+ direction: g.direction ?? "asc"
810
+ }));
811
+ return normalizeSummarize(await this.request("POST", `/entity/${pluralPath(entityType)}/_summarize`, body));
812
+ }
813
+ async threadContents(noteId, entityFields) {
814
+ const params = {};
815
+ for (const [type, names] of Object.entries(entityFields ?? {})) params[`entity_fields[${type}]`] = names.join(",");
816
+ return (await this.request("GET", `/entity/notes/${noteId}/thread_contents`, void 0, params)).data.map(toThreadRow);
817
+ }
818
+ async eventLog(options = {}) {
819
+ const size = options.page?.size ?? 50;
820
+ const body = {
821
+ filters: eventLogFilters(options),
822
+ fields: EVENT_LOG_FIELDS.join(","),
823
+ sort: "-id",
824
+ page: {
825
+ size,
826
+ number: options.page?.number ?? 1
827
+ }
828
+ };
829
+ const res = await this.request("POST", "/entity/event_log_entries/_search", body);
830
+ return {
831
+ data: res.data.map(normalizeEventLogEntry),
832
+ hasMore: res.data.length === size
833
+ };
834
+ }
835
+ async following(userId, options = {}) {
836
+ return (await this.request("GET", `/entity/human_users/${userId}/following`, void 0, {
837
+ entity: options.entity,
838
+ project_id: options.projectId
839
+ })).data.map((row) => ({
840
+ type: String(row.type),
841
+ id: Number(row.id)
842
+ }));
843
+ }
844
+ async hierarchyExpand(path) {
845
+ return normalizeHierarchyNode((await this.request("POST", "/hierarchy/_expand", { path }, void 0, "application/json")).data, path);
846
+ }
847
+ async hierarchySearch(rootPath, entity) {
848
+ const body = {
849
+ root_path: rootPath,
850
+ search_criteria: { entity: {
851
+ type: entity.type,
852
+ id: entity.id
853
+ } }
854
+ };
855
+ return (await this.request("POST", "/hierarchy/_search", body, void 0, "application/json")).data.map(toHierarchyPath);
856
+ }
857
+ async statuses() {
858
+ const res = await this.request("GET", "/entity/statuses", void 0, {
859
+ fields: "code,name,bg_color,icon",
860
+ "page[size]": 500
861
+ });
862
+ const iconIds = /* @__PURE__ */ new Set();
863
+ for (const row of res.data) {
864
+ const icon = row.relationships["icon"]?.data;
865
+ if (icon) iconIds.add(icon.id);
866
+ }
867
+ const icons = /* @__PURE__ */ new Map();
868
+ if (iconIds.size > 0) {
869
+ const iconRes = await this.request("POST", "/entity/icons/_search", {
870
+ filters: {
871
+ logical_operator: "and",
872
+ conditions: [[
873
+ "id",
874
+ "in",
875
+ [...iconIds]
876
+ ]]
877
+ },
878
+ fields: "display_type,image_map_key,url,html,image_data",
879
+ page: {
880
+ size: 500,
881
+ number: 1
882
+ }
883
+ });
884
+ for (const i of iconRes.data) icons.set(i.id, i);
885
+ }
886
+ return res.data.map((row) => {
887
+ const iconRef = row.relationships["icon"]?.data;
888
+ const icon = iconRef ? icons.get(iconRef.id) : void 0;
889
+ return {
890
+ id: row.id,
891
+ code: String(row.attributes["code"] ?? ""),
892
+ name: String(row.attributes["name"] ?? ""),
893
+ bgColor: row.attributes["bg_color"] ?? null,
894
+ icon: icon ? toStatusIcon(icon.attributes) : null
895
+ };
896
+ });
897
+ }
898
+ };
899
+ /** A grouped call wraps the answer in `data`; an ungrouped one does not (020_summarize). */
900
+ function normalizeSummarize(res) {
901
+ const raw = res.data ?? res;
902
+ return {
903
+ summaries: raw.summaries ?? {},
904
+ groups: (raw.groups ?? []).map((g) => ({
905
+ groupName: String(g.group_name ?? ""),
906
+ groupValue: g.group_value ?? null,
907
+ summaries: g.summaries ?? {}
908
+ }))
909
+ };
910
+ }
911
+ function toTextSearchRow(row) {
912
+ const links = row.attributes?.links;
913
+ const pair = Array.isArray(links) ? [String(links[0] ?? ""), String(links[1] ?? "")] : ["", ""];
914
+ return {
915
+ type: String(row.type),
916
+ id: Number(row.id),
917
+ name: String(row.attributes?.name ?? ""),
918
+ links: pair,
919
+ status: row.attributes?.status ?? null
920
+ };
921
+ }
922
+ function toThreadAuthor(value) {
923
+ if (value === null || typeof value !== "object") return null;
924
+ const hash = value;
925
+ if (typeof hash.type !== "string" || typeof hash.id !== "number") return null;
926
+ const author = {
927
+ type: hash.type,
928
+ id: hash.id
929
+ };
930
+ if (typeof hash.name === "string") author.name = hash.name;
931
+ if (hash.image !== void 0) author.image = hash.image ?? null;
932
+ return author;
933
+ }
934
+ /**
935
+ * The author key follows the row type: `created_by` on a Note and an Attachment,
936
+ * `user` on a Reply (get_entity_notes_id_thread_contents). A Note widened with
937
+ * `user` still names its author under `created_by`.
938
+ */
939
+ function toThreadRow(row) {
940
+ const author = toThreadAuthor(row.type === "Reply" ? row["user"] : row["created_by"]);
941
+ return {
942
+ type: String(row.type),
943
+ id: Number(row.id),
944
+ createdAt: row["created_at"] ?? null,
945
+ content: row["content"] ?? null,
946
+ author,
947
+ fields: row
948
+ };
949
+ }
950
+ function entityRefOf(value) {
951
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
952
+ const ref = value;
953
+ return typeof ref.name === "string" ? {
954
+ type: ref.type,
955
+ id: ref.id,
956
+ name: ref.name
957
+ } : {
958
+ type: ref.type,
959
+ id: ref.id
960
+ };
961
+ }
962
+ /** One `_search` row of `EventLogEntry`, with `meta` decoded as the API returns it. */
963
+ function normalizeEventLogEntry(row) {
964
+ const a = row.attributes;
965
+ const meta = a["meta"] ?? null;
966
+ return {
967
+ id: row.id,
968
+ eventType: a["event_type"] ?? null,
969
+ attributeName: a["attribute_name"] ?? null,
970
+ description: a["description"] ?? null,
971
+ createdAt: a["created_at"] ?? null,
972
+ entity: entityRefOf(row.relationships["entity"]?.data),
973
+ project: entityRefOf(row.relationships["project"]?.data),
974
+ user: entityRefOf(row.relationships["user"]?.data),
975
+ meta,
976
+ oldValue: meta?.["old_value"] ?? null,
977
+ newValue: meta?.["new_value"] ?? null
978
+ };
979
+ }
980
+ function toHierarchyPath(row) {
981
+ return {
982
+ label: String(row.label ?? ""),
983
+ pathLabel: String(row.path_label ?? ""),
984
+ incrementalPath: row.incremental_path ?? [],
985
+ ref: {
986
+ type: row.ref.type,
987
+ id: row.ref.id
988
+ },
989
+ projectId: row.project_id ?? null
990
+ };
991
+ }
992
+ function toStatusIcon(a) {
993
+ switch (a["display_type"]) {
994
+ case "image_map": return {
995
+ displayType: "image_map",
996
+ imageMapKey: String(a["image_map_key"] ?? "")
997
+ };
998
+ case "image": {
999
+ const url = String(a["url"] ?? "").replace(/\s+/g, "");
1000
+ const data = String(a["image_data"] ?? "").replace(/\s+/g, "");
1001
+ const dataUrl = url || (data ? `data:image/png;base64,${data}` : "");
1002
+ return dataUrl ? {
1003
+ displayType: "image",
1004
+ dataUrl
1005
+ } : null;
1006
+ }
1007
+ case "html": return {
1008
+ displayType: "html",
1009
+ html: String(a["html"] ?? "")
1010
+ };
1011
+ default: return null;
1012
+ }
1013
+ }
1014
+ //#endregion
1015
+ export { OPERATORS as A, operatorsFor as B, isEmptyFilter as C, walk as D, toFilterArray as E, isFilterable as F, isLinkType as I, isNumericType as L, TIME_UNITS as M, VALUE_SHAPE as N, DATA_TYPES as O, isDataType as P, isOperator as R, isBlankCondition as S, toApi3Hash as T, supportsOperator as V, pluralPath as _, hierarchyEntity as a, fromWire as b, DISPLAY_NAME_FIELDS as c, fieldSchemaOverride as d, normalizeField as f, undeclaredField as g, statusFieldNameFor as h, eventLogFilters as i, OPERATORS_BY_TYPE as j, NEGATING_OPERATORS as k, FIELD_SCHEMA_OVERRIDES as l, statusFieldFor as m, RestClient as n, normalizeEventLogEntry as o, normalizeFields as p, SgApiError as r, normalizeHierarchyNode as s, EVENT_LOG_FIELDS as t, displayNameOf as u, condition as v, referencedPaths as w, group as x, emptyFilter as y, isTemporalType as z };