@yuneta/gobj-ui 5.16.0 → 6.0.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.
@@ -1,11 +1,26 @@
1
1
  /***********************************************************************
2
2
  * c_yui_treedb_schema.js
3
3
  *
4
- * Schema-graph landing (prototype): the treedb drawn as a GRAPH OF
5
- * TOPICS one node per topic, one edge per hook/fkey relationship
6
- * from the schema `descs` alone (no data, no backend calls). A
7
- * node click opens that topic's table (a real hash navigation via
8
- * the host-supplied `node_route`). An alternate landing to the
4
+ * Schema-graph landing: the treedb drawn the way its `.c` literal
5
+ * draws it in ASCII (treedb_schema_*.c, treedb_system_schema.c)
6
+ * one CARD per topic listing its fields in schema order, one edge
7
+ * per hook, from the row that declares the hook to the fkey row of
8
+ * the child it names. Built from the schema `descs` alone: no data,
9
+ * no backend calls.
10
+ *
11
+ * WHY THE CARD AND NOT A DOT. A topic is its fields; a schema is
12
+ * read to find out what a topic holds and what links to what. A
13
+ * graph of labelled dots answers neither, and the node graph next
14
+ * door (C_G6_NODES_TREE) answers a different question entirely —
15
+ * it draws the RECORDS, so on a treedb whose records are schemas
16
+ * it draws one box per column, hundreds of them, each labelled by
17
+ * a pkey that may be a rowid. This view draws the schema itself.
18
+ *
19
+ * The marks are the notation of the `.c` literals, so the drawing
20
+ * and the source read the same — see schema_rows().
21
+ *
22
+ * A node click opens that topic's table (a real hash navigation
23
+ * via the host-supplied `node_route`). An alternate landing to the
9
24
  * topic cards, in the spirit of "every treedb is a graph".
10
25
  *
11
26
  * Copyright (c) 2026, ArtGins.
@@ -29,10 +44,13 @@ import {
29
44
  gobj_read_bool_attr,
30
45
  gobj_read_str_attr,
31
46
  is_object,
47
+ escapeHtml,
32
48
  } from "@yuneta/gobj-js";
33
49
 
34
50
  import {Graph, NodeEvent} from "@antv/g6";
35
51
 
52
+ import {getStrokeColor} from "./lib_graph.js";
53
+
36
54
  import {yui_is_dark, yui_watch_theme} from "./yui_theme.js";
37
55
 
38
56
  import {t} from "i18next";
@@ -42,6 +60,31 @@ import {t} from "i18next";
42
60
  ***************************************************************/
43
61
  const GCLASS_NAME = "C_YUI_TREEDB_SCHEMA";
44
62
 
63
+ /************************************************************
64
+ * Geometry of a card, in one place: the row height is what
65
+ * turns a field's index into the vertical position of its
66
+ * port, so an edge leaves the very row that declares the
67
+ * hook and lands on the row that declares the fkey.
68
+ ************************************************************/
69
+ const CARD_HEADER_H = 30;
70
+ const CARD_ROW_H = 19;
71
+ const CARD_PAD_V = 7;
72
+ const CARD_MIN_W = 210;
73
+ const CARD_MAX_W = 340;
74
+
75
+ /* Same palette as the node graph (C_G6_NODES_TREE), so a topic
76
+ * keeps its colour when the operator moves between the two. */
77
+ const topic_colors = [
78
+ 'rgb(237, 201, 73)',
79
+ 'rgb(118, 183, 178)',
80
+ 'rgb(255, 157, 167)',
81
+ 'rgb(175, 122, 161)',
82
+ 'rgb(89, 161, 79)',
83
+ 'rgb(186, 176, 171)',
84
+ 'rgb(66, 146, 198)',
85
+ ];
86
+ const SYSTEM_TOPIC_COLOR = 'rgb(148, 163, 184)';
87
+
45
88
  /***************************************************************
46
89
  * Data
47
90
  ***************************************************************/
@@ -138,61 +181,318 @@ function build_ui(gobj)
138
181
  gobj_write_attr(gobj, "$container", $container);
139
182
  }
140
183
 
184
+ /************************************************************
185
+ * The shape of a hook/fkey field, from the type it declares.
186
+ * `dict` and `object` are the SAME thing to treedb (both build
187
+ * a json object keyed by child id), and so are `list` and
188
+ * `array` — see the hook/fkey switches in tr_treedb.c. A
189
+ * `string` holds one reference, so it draws as one.
190
+ ************************************************************/
191
+ function hook_mark(type)
192
+ {
193
+ if(type === "dict" || type === "object") {
194
+ return "{}";
195
+ }
196
+ if(type === "list" || type === "array") {
197
+ return "[]";
198
+ }
199
+ return "()";
200
+ }
201
+
202
+ /************************************************************
203
+ * The fields of one topic, as the schema declares them.
204
+ *
205
+ * The marks are the notation of the schema `.c` literals
206
+ * (treedb_schema_*.c, treedb_system_schema.c), so the
207
+ * drawing and the source read the same:
208
+ * {} dict hook (N unique children)
209
+ * [] list hook (n not-unique children)
210
+ * () 1 child
211
+ * (↖) 1 fkey (1 parent)
212
+ * [↖] n fkeys (n parents)
213
+ * {↖} N fkeys (N parents)
214
+ * * required
215
+ * # the primary key
216
+ *
217
+ * A column can be BOTH hook and fkey, so the flags are read
218
+ * here rather than through treedb_get_field_desc's single
219
+ * `type`, which keeps only the last flag it saw.
220
+ ************************************************************/
221
+ function schema_rows(desc)
222
+ {
223
+ let rows = [];
224
+ if(!is_object(desc) || !Array.isArray(desc.cols)) {
225
+ return rows;
226
+ }
227
+ for(let col of desc.cols) {
228
+ if(!is_object(col) || !col.id) {
229
+ continue;
230
+ }
231
+ let flags = Array.isArray(col.flag)? col.flag : [];
232
+ let is_hook = flags.indexOf("hook") >= 0;
233
+ let is_fkey = flags.indexOf("fkey") >= 0;
234
+ let mark = "";
235
+ if(is_hook) {
236
+ mark = hook_mark(col.type);
237
+ }
238
+ if(is_fkey) {
239
+ let fmark = hook_mark(col.type).replace("}", "↖}")
240
+ .replace("]", "↖]")
241
+ .replace(")", "↖)");
242
+ mark = mark? (mark + " " + fmark) : fmark;
243
+ }
244
+ rows.push({
245
+ name: col.id,
246
+ type: col.type || "",
247
+ mark: mark,
248
+ is_hook: is_hook,
249
+ is_fkey: is_fkey,
250
+ is_pkey: (col.id === desc.pkey),
251
+ required: (flags.indexOf("required") >= 0 || flags.indexOf("notnull") >= 0),
252
+ });
253
+ }
254
+ return rows;
255
+ }
256
+
257
+ /************************************************************
258
+ * Size of the card that holds those rows. The width follows
259
+ * the longest line so a long field name is not clipped into
260
+ * an ellipsis — the point of the drawing is to read them.
261
+ ************************************************************/
262
+ function card_size(rows)
263
+ {
264
+ let longest = 0;
265
+ for(let row of rows) {
266
+ let len = row.name.length + (row.mark? row.mark.length + 2 : 0) + 2;
267
+ if(len > longest) {
268
+ longest = len;
269
+ }
270
+ }
271
+ let w = Math.round(26 + longest * 6.6);
272
+ if(w < CARD_MIN_W) {
273
+ w = CARD_MIN_W;
274
+ }
275
+ if(w > CARD_MAX_W) {
276
+ w = CARD_MAX_W;
277
+ }
278
+ let h = CARD_HEADER_H + CARD_PAD_V * 2 + rows.length * CARD_ROW_H;
279
+ return [w, h];
280
+ }
281
+
282
+ /************************************************************
283
+ * Vertical placement of a field's port, as a fraction of the
284
+ * card height: the centre of its own row.
285
+ ************************************************************/
286
+ function row_placement(index, height)
287
+ {
288
+ let y = CARD_HEADER_H + CARD_PAD_V + index * CARD_ROW_H + CARD_ROW_H / 2;
289
+ return y / height;
290
+ }
291
+
292
+ /************************************************************
293
+ * The card itself. Header = topic name on its topic colour;
294
+ * body = one line per field, in schema order.
295
+ ************************************************************/
296
+ function build_card_innerHTML(topic, rows, color, dark)
297
+ {
298
+ let surface = dark? "#1b2230" : "#ffffff";
299
+ let bg = dark
300
+ ? `color-mix(in srgb, ${color} 18%, #232b38)`
301
+ : `color-mix(in srgb, ${color} 6%, ${surface})`;
302
+ let border = dark
303
+ ? `color-mix(in srgb, ${color} 85%, #ffffff)`
304
+ : color;
305
+ let header_bg = dark
306
+ ? `color-mix(in srgb, ${color} 45%, #2c3542)`
307
+ : `color-mix(in srgb, ${color} 28%, ${surface})`;
308
+ let title_color = dark? "#e8eaed" : "#0f172a";
309
+ let text_color = dark? "#d3d8de" : "#334155";
310
+ let mute_color = dark? "#98a2b0" : "#7c8694";
311
+ let rule_color = dark
312
+ ? "rgba(255,255,255,0.06)"
313
+ : "rgba(15,23,42,0.05)";
314
+ let shadow = dark
315
+ ? "0 1px 3px rgba(0,0,0,0.45), 0 1px 2px rgba(0,0,0,0.30)"
316
+ : "0 1px 3px rgba(15,23,42,0.12), 0 1px 2px rgba(15,23,42,0.06)";
317
+
318
+ let rows_html = "";
319
+ for(let row of rows) {
320
+ let lead = row.is_pkey? "#" : (row.required? "*" : "");
321
+ let name_weight = (row.is_pkey || row.required)? "600" : "400";
322
+ let name_color = (row.is_hook || row.is_fkey)? title_color : text_color;
323
+ rows_html +=
324
+ ` <div class="TREEDB_SCHEMA_FIELD" style="
325
+ display: flex; align-items: center; gap: 6px;
326
+ height: ${CARD_ROW_H}px; line-height: ${CARD_ROW_H}px;
327
+ padding: 0 10px; border-top: 1px solid ${rule_color};
328
+ ">
329
+ <span style="
330
+ width: 8px; flex: 0 0 8px; text-align: center;
331
+ color: ${mute_color}; font-size: 11px;
332
+ ">${lead}</span>
333
+ <span style="
334
+ flex: 1 1 auto; min-width: 0; overflow: hidden;
335
+ text-overflow: ellipsis; white-space: nowrap;
336
+ font-size: 12px; font-weight: ${name_weight}; color: ${name_color};
337
+ ">${escapeHtml(row.name)}</span>
338
+ <span style="
339
+ flex: 0 0 auto; font-size: 11px; color: ${mute_color};
340
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
341
+ ">${escapeHtml(row.mark)}</span>
342
+ </div>
343
+ `;
344
+ }
345
+
346
+ return `
347
+ <div class="TREEDB_SCHEMA_CARD" title="${escapeHtml(topic)}" style="
348
+ box-sizing: border-box;
349
+ width: 100%;
350
+ height: 100%;
351
+ background: ${bg};
352
+ border: 1.5px solid ${border};
353
+ border-radius: 8px;
354
+ box-shadow: ${shadow};
355
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
356
+ display: flex;
357
+ flex-direction: column;
358
+ overflow: hidden;
359
+ cursor: pointer;
360
+ ">
361
+ <div class="TREEDB_SCHEMA_CARD_HEADER" style="
362
+ height: ${CARD_HEADER_H}px; line-height: ${CARD_HEADER_H}px;
363
+ flex: 0 0 ${CARD_HEADER_H}px;
364
+ padding: 0 10px; background: ${header_bg}; color: ${title_color};
365
+ font-size: 13px; font-weight: 700;
366
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
367
+ ">${escapeHtml(topic)}</div>
368
+ <div class="TREEDB_SCHEMA_CARD_BODY" style="
369
+ flex: 1 1 auto; padding: ${CARD_PAD_V}px 0; overflow: hidden;
370
+ ">
371
+ ${rows_html} </div>
372
+ </div>
373
+ `;
374
+ }
375
+
141
376
  /************************************************************
142
377
  * Derive {nodes, edges} from the schema.
143
- * Node = a topic. Edge = a hook (parent -> child) or an
144
- * fkey (child -> parent, reversed to parent -> child), deduped.
378
+ *
379
+ * Node = a topic, drawn as the card the `.c` literal draws in
380
+ * ASCII: its name and its fields. Edge = a hook, from the row
381
+ * that declares it to the fkey row of the child it names —
382
+ * `'hook': {'users': 'departments'}` says both ends, so the
383
+ * arrow can land where the `.c` drawing lands it. An fkey
384
+ * whose parent declares no hook still gets its edge, or a
385
+ * half-declared schema would draw as disconnected.
386
+ *
145
387
  * Left-to-right dagre follows the parent -> child data flow.
146
388
  ************************************************************/
147
389
  function schema_to_graph(gobj)
148
390
  {
149
391
  let descs = gobj_read_attr(gobj, "descs");
150
392
  let system = gobj_read_bool_attr(gobj, "system");
393
+ let dark = yui_is_dark();
151
394
  let nodes = [];
152
- let topic_set = {};
395
+ let cards = {}; /* topic -> {rows, size, color} */
153
396
 
154
397
  if(!is_object(descs)) {
155
398
  return {nodes: nodes, edges: []};
156
399
  }
157
400
 
401
+ let idx = 0;
158
402
  for(let topic of Object.keys(descs)) {
159
- if(!system && topic.substring(0, 2) === "__") {
403
+ let is_system = (topic.substring(0, 2) === "__");
404
+ if(!system && is_system) {
160
405
  continue;
161
406
  }
162
- topic_set[topic] = true;
163
- nodes.push({id: topic, data: {topic_name: topic}});
407
+ let rows = schema_rows(descs[topic]);
408
+ let color;
409
+ if(is_system) {
410
+ color = SYSTEM_TOPIC_COLOR;
411
+ } else {
412
+ color = topic_colors[idx % topic_colors.length];
413
+ idx++;
414
+ }
415
+ cards[topic] = {rows: rows, size: card_size(rows), color: color};
416
+ }
417
+
418
+ /*
419
+ * Ports: a hook leaves on the right, an fkey arrives on the
420
+ * left, each at the height of its own row.
421
+ */
422
+ for(let topic of Object.keys(cards)) {
423
+ let card = cards[topic];
424
+ let [w, h] = card.size;
425
+ let ports = [];
426
+ for(let i = 0; i < card.rows.length; i++) {
427
+ let row = card.rows[i];
428
+ if(!row.is_hook && !row.is_fkey) {
429
+ continue;
430
+ }
431
+ ports.push({
432
+ key: row.name,
433
+ placement: [row.is_hook? 1 : 0, row_placement(i, h)],
434
+ fill: card.color,
435
+ stroke: getStrokeColor(card.color),
436
+ });
437
+ }
438
+ nodes.push({
439
+ id: topic,
440
+ type: "html",
441
+ style: {
442
+ size: [w, h],
443
+ dx: -w / 2,
444
+ dy: -h / 2,
445
+ innerHTML: build_card_innerHTML(topic, card.rows, card.color, dark),
446
+ port: ports.length > 0,
447
+ ports: ports,
448
+ portR: 3,
449
+ portLineWidth: 1,
450
+ },
451
+ /* What a theme switch needs to repaint this card without
452
+ * rebuilding the graph (which would drop zoom/pan). */
453
+ data: {topic_name: topic, rows: card.rows, color: card.color},
454
+ });
164
455
  }
165
456
 
166
457
  let edge_seen = {};
167
458
  let edges = [];
168
- let add_edge = (source, target) => {
169
- if(!topic_set[source] || !topic_set[target] || source === target) {
459
+ let add_edge = (source, source_port, target, target_port) => {
460
+ if(!cards[source] || !cards[target]) {
170
461
  return;
171
462
  }
172
- let key = source + "" + target;
463
+ let key = `${source}|${source_port}|${target}|${target_port}`;
173
464
  if(edge_seen[key]) {
174
465
  return;
175
466
  }
176
467
  edge_seen[key] = true;
177
- edges.push({id: key, source: source, target: target});
468
+ edges.push({
469
+ id: key,
470
+ type: "cubic",
471
+ source: source,
472
+ target: target,
473
+ style: {sourcePort: source_port, targetPort: target_port},
474
+ });
178
475
  };
179
476
 
180
- for(let topic of Object.keys(descs)) {
477
+ for(let topic of Object.keys(cards)) {
181
478
  let desc = descs[topic];
182
479
  if(!is_object(desc) || !Array.isArray(desc.cols)) {
183
480
  continue;
184
481
  }
185
482
  for(let col of desc.cols) {
186
- if(!col) {
483
+ if(!is_object(col) || !col.id) {
187
484
  continue;
188
485
  }
189
486
  if(is_object(col.hook)) {
487
+ /* {child_topic: fkey_field_of_the_child} */
190
488
  for(let child of Object.keys(col.hook)) {
191
- add_edge(topic, child); /* parent -> child */
489
+ add_edge(topic, col.id, child, col.hook[child]);
192
490
  }
193
- } else if(is_object(col.fkey)) {
491
+ }
492
+ if(is_object(col.fkey)) {
493
+ /* {parent_topic: hook_field_of_the_parent} */
194
494
  for(let parent of Object.keys(col.fkey)) {
195
- add_edge(parent, topic); /* parent -> child */
495
+ add_edge(parent, col.fkey[parent], topic, col.id);
196
496
  }
197
497
  }
198
498
  }
@@ -202,29 +502,16 @@ function schema_to_graph(gobj)
202
502
  }
203
503
 
204
504
  /************************************************************
205
- * The theme-dependent styles, in ONE place: they are applied
206
- * as the graph is built and re-applied on a theme switch
505
+ * The theme-dependent edge style, in ONE place: applied as
506
+ * the graph is built and re-applied on a theme switch
207
507
  * (ac_theme), which restyles the LIVE graph — rebuilding it
208
508
  * would drop the user's zoom/pan and any dragged node.
209
509
  ************************************************************/
210
- function node_style(dark)
211
- {
212
- return {
213
- size: 40,
214
- fill: "#5B8FF9",
215
- labelText: (d) => d.id,
216
- labelPlacement: "bottom",
217
- labelFill: dark ? "#e6e6e6" : "#333333",
218
- labelBackground: true,
219
- labelBackgroundFill: dark ? "rgba(0,0,0,0.5)" : "rgba(255,255,255,0.7)",
220
- cursor: "pointer",
221
- };
222
- }
223
-
224
510
  function edge_style(dark)
225
511
  {
226
512
  return {
227
- stroke: dark ? "#666666" : "#bbbbbb",
513
+ stroke: dark ? "#7a8593" : "#9aa4b2",
514
+ lineWidth: 1.2,
228
515
  endArrow: true,
229
516
  };
230
517
  }
@@ -258,17 +545,14 @@ function build_graph(gobj)
258
545
  container: $container,
259
546
  autoResize: true,
260
547
  data: data,
261
- node: {
262
- style: node_style(dark),
263
- },
264
548
  edge: {
265
549
  style: edge_style(dark),
266
550
  },
267
551
  layout: {
268
552
  type: "antv-dagre",
269
553
  rankdir: "LR",
270
- nodesep: 24,
271
- ranksep: 60,
554
+ nodesep: 28,
555
+ ranksep: 90,
272
556
  },
273
557
  behaviors: ["zoom-canvas", "drag-canvas", "drag-element"],
274
558
  });
@@ -386,8 +670,29 @@ function ac_theme(gobj, event, kw, src)
386
670
  let dark = (kw && kw.theme) ? (kw.theme === "dark") : yui_is_dark();
387
671
  try {
388
672
  graph.setTheme(dark ? "dark" : "light");
389
- graph.setNode({style: node_style(dark)});
390
673
  graph.setEdge({style: edge_style(dark)});
674
+
675
+ /* A card is HTML, so its colours live in its markup: repaint
676
+ * each one from the rows kept in its node data. */
677
+ let updates = [];
678
+ for(let node of graph.getNodeData()) {
679
+ let data = node.data;
680
+ if(!is_object(data) || !Array.isArray(data.rows)) {
681
+ continue;
682
+ }
683
+ updates.push({
684
+ id: node.id,
685
+ style: {
686
+ innerHTML: build_card_innerHTML(
687
+ data.topic_name, data.rows, data.color, dark
688
+ )
689
+ }
690
+ });
691
+ }
692
+ if(updates.length > 0) {
693
+ graph.updateNodeData(updates);
694
+ }
695
+
391
696
  graph.draw().catch((e) => {
392
697
  log_error(`${gobj_short_name(gobj)}: schema graph redraw failed: ${e}`);
393
698
  });
@@ -1082,7 +1082,8 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field)
1082
1082
  case "email": // string subtype — plain text in the cell
1083
1083
  case "tel": // string subtype — plain text in the cell
1084
1084
  case "url": // string subtype — plain text in the cell
1085
- case "rowid": // the record's own key — shown as it comes
1085
+ case "rowid": // the record's own key — shown as it comes
1086
+ case "qualified": // idem, the name with its ancestors in front
1086
1087
  break;
1087
1088
  case "integer":
1088
1089
  break;
@@ -1758,6 +1759,9 @@ function transform__form_value_2_treedb_value(gobj, col, value, operation)
1758
1759
 
1759
1760
  switch(field_desc.type) {
1760
1761
  case "rowid":
1762
+ case "qualified":
1763
+ /* The store hands the key out: a rowid from the topic size, a
1764
+ * qualified id from the parent and the name. */
1761
1765
  if(operation==="create") {
1762
1766
  value = "";
1763
1767
  }
@@ -0,0 +1,79 @@
1
+ /***********************************************************************
2
+ * treedb_node_label.js
3
+ *
4
+ * Pure, testable logic behind the label of a node in the treedb
5
+ * graph: WHAT A RECORD IS CALLED, which is not always what it is
6
+ * keyed by. Kept out of the gclass so it can be unit-tested with
7
+ * no DOM and no G6.
8
+ *
9
+ * Copyright (c) 2026, ArtGins.
10
+ * All Rights Reserved.
11
+ ***********************************************************************/
12
+
13
+ /************************************************************
14
+ * What a node is CALLED, which is not always what it is
15
+ * keyed by.
16
+ *
17
+ * A topic whose id column is flagged `rowid`, `uuid` or
18
+ * `qualified` keys its records by something that is not the
19
+ * name — a counter, a random string, or the name with every
20
+ * ancestor in front of it — and the name a human knows the
21
+ * record by lives in the secondary key the topic declares
22
+ * (`pkey2s`, carried in the desc since SDK > 7.13.0). The
23
+ * `topics` and `cols` topics of treedb_system_schema are the
24
+ * case that forced this: named in `value`, so every card in
25
+ * the graph read "181", "225", "193" while they were keyed by
26
+ * rowid, and reads the whole path now that they are
27
+ * qualified.
28
+ *
29
+ * The pkey stays reachable: it is the card's tooltip.
30
+ *
31
+ * Falls back to the id whenever the schema does not say
32
+ * otherwise — an older node whose desc carries no `pkey2s`
33
+ * included.
34
+ ************************************************************/
35
+ export function node_label(desc, record)
36
+ {
37
+ /* treedb stores every record under `id` (the pkey name is fixed in
38
+ * tranger2_create_topic), so the fallback reads `id` while the FLAGS
39
+ * are read from whatever column the desc names as the pkey. */
40
+ let id = record.id;
41
+
42
+ let id_col = null;
43
+ if(Array.isArray(desc.cols)) {
44
+ for(let col of desc.cols) {
45
+ if(col && col.id === (desc.pkey || "id")) {
46
+ id_col = col;
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ if(!id_col || !Array.isArray(id_col.flag)) {
52
+ return id;
53
+ }
54
+ if(id_col.flag.indexOf("rowid") < 0 &&
55
+ id_col.flag.indexOf("uuid") < 0 &&
56
+ id_col.flag.indexOf("qualified") < 0
57
+ ) {
58
+ return id;
59
+ }
60
+
61
+ /* `pkey2s` is a list in the desc, but the schema literal may
62
+ * declare it as a bare string — take either. */
63
+ let pkey2s = desc.pkey2s;
64
+ if(typeof pkey2s === "string") {
65
+ pkey2s = pkey2s? [pkey2s] : [];
66
+ }
67
+ if(!Array.isArray(pkey2s)) {
68
+ return id;
69
+ }
70
+ for(let name of pkey2s) {
71
+ let value = record[name];
72
+ if(typeof value === "string" && value.length > 0) {
73
+ return value;
74
+ }
75
+ }
76
+
77
+ return id;
78
+ }
79
+