@yuneta/gobj-ui 6.0.0 → 6.1.1

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,538 @@
1
+ /***********************************************************************
2
+ * schema_model.js
3
+ *
4
+ * A SCHEMA out of the three meta-topics that store it.
5
+ *
6
+ * `treedb_system_schema` keeps every schema of a yuno as DATA, in
7
+ * three flat topics — `treedbs`, `topics`, `cols` — linked by
8
+ * fkeys. That is the right STORAGE and the wrong thing to put in
9
+ * front of an operator: adding one column to one topic means
10
+ * finding it in a table holding every column of every topic of
11
+ * every treedb the yuno has.
12
+ *
13
+ * This module turns those three lists back into what they
14
+ * describe: treedb -> topics -> columns, each in its declared
15
+ * `order`. Pure, so the editor's model can be tested with no DOM
16
+ * and no backend — the gclass fetches and draws, this decides
17
+ * what the records MEAN.
18
+ *
19
+ * TWO THINGS IT EXISTS TO STATE ONCE:
20
+ *
21
+ * 1. A record is grouped by its FKEY, not by its id. The id is
22
+ * qualified (`<treedb>.<topic>.<column>`) and reading it back
23
+ * with a split on '.' works right up to the first treedb or
24
+ * topic whose name carries one. The fkey is the link the store
25
+ * itself uses; the id is only the fallback for a record whose
26
+ * fkey never arrived.
27
+ *
28
+ * 2. A fkey reaches this code in FOUR shapes, because `nodes`
29
+ * answers differently depending on the options it was called
30
+ * with: the bare ref string, the expanded object, a list of
31
+ * either, or a dict keyed by the ref. All four are the same
32
+ * fact and parse_fkey_ref() flattens them to it.
33
+ *
34
+ * Copyright (c) 2026, ArtGins.
35
+ * All Rights Reserved.
36
+ ***********************************************************************/
37
+
38
+ /* What a node with nothing to say about its place gets: the projector
39
+ * stamps `order` from the position the C schema declares, and a record
40
+ * created by hand says nothing, so it goes last (treedb_system_schema.c). */
41
+ const DEFAULT_ORDER = 9999;
42
+
43
+ /* The separator of a fkey reference, `<topic>^<id>^<hook>`. It is why an
44
+ * id may never carry one (treedb_system_schema.c). */
45
+ const REF_SEP = "^";
46
+
47
+
48
+ /***************************************************************
49
+ * parse_fkey_ref(value) -> [{topic_name, id, hook_name}, ...]
50
+ *
51
+ * Every shape a fkey column can arrive in, flattened to the
52
+ * refs it names. Anything unrecognizable contributes nothing —
53
+ * a schema drawn without one link beats a screen that throws.
54
+ ***************************************************************/
55
+ function parse_fkey_ref(value)
56
+ {
57
+ let refs = [];
58
+
59
+ let push_one = (v) => {
60
+ if(typeof v === "string") {
61
+ let parts = v.split(REF_SEP);
62
+ if(parts.length !== 3) {
63
+ return;
64
+ }
65
+ refs.push({topic_name: parts[0], id: parts[1], hook_name: parts[2]});
66
+ return;
67
+ }
68
+ if(v && typeof v === "object") {
69
+ let id = v.id;
70
+ if(typeof id !== "string" || id.length === 0) {
71
+ return;
72
+ }
73
+ refs.push({
74
+ topic_name: v.topic_name || "",
75
+ id: id,
76
+ hook_name: v.hook_name || ""
77
+ });
78
+ }
79
+ };
80
+
81
+ if(value === null || value === undefined) {
82
+ return refs;
83
+ }
84
+ if(typeof value === "string") {
85
+ push_one(value);
86
+ return refs;
87
+ }
88
+ if(Array.isArray(value)) {
89
+ for(let v of value) {
90
+ push_one(v);
91
+ }
92
+ return refs;
93
+ }
94
+ if(typeof value === "object") {
95
+ /* A dict fkey is keyed BY the ref and carries `true` (or the
96
+ * expanded node) as the value: the key is the fact. */
97
+ for(let [key, v] of Object.entries(value)) {
98
+ if(v && typeof v === "object" && typeof v.id === "string") {
99
+ push_one(v);
100
+ } else {
101
+ push_one(key);
102
+ }
103
+ }
104
+ }
105
+
106
+ return refs;
107
+ }
108
+
109
+ /***************************************************************
110
+ * The id of the FIRST parent a record names, or "" when it
111
+ * names none. A record has one parent here — a column belongs to
112
+ * one topic, a topic to one treedb — and a second ref would be a
113
+ * store this editor cannot draw anyway.
114
+ ***************************************************************/
115
+ function parent_id(record, fkey_col)
116
+ {
117
+ let refs = parse_fkey_ref(record ? record[fkey_col] : null);
118
+
119
+ return refs.length > 0 ? refs[0].id : "";
120
+ }
121
+
122
+ /***************************************************************
123
+ * The name a record is KNOWN by, which is not what it is keyed
124
+ * by: `topics` and `cols` key by the qualified id and carry the
125
+ * bare name in `value` (its pkey2). An older store that still
126
+ * keyed by rowid has no `value` worth showing, so the id stands
127
+ * in — the same fallback as node_label().
128
+ ***************************************************************/
129
+ function record_name(record)
130
+ {
131
+ if(!record) {
132
+ return "";
133
+ }
134
+ if(typeof record.value === "string" && record.value.length > 0) {
135
+ return record.value;
136
+ }
137
+ return typeof record.id === "string" ? record.id : "";
138
+ }
139
+
140
+ /***************************************************************
141
+ * The `order` of a record as a NUMBER, whatever the store put
142
+ * there: an integer column read back from JSON may arrive as a
143
+ * string, and comparing "10" with 9 sorts the schema wrong.
144
+ ***************************************************************/
145
+ function record_order(record)
146
+ {
147
+ let order = record ? record.order : undefined;
148
+
149
+ if(typeof order === "number" && isFinite(order)) {
150
+ return order;
151
+ }
152
+ if(typeof order === "string" && order.trim().length > 0) {
153
+ let n = Number(order);
154
+ if(isFinite(n)) {
155
+ return n;
156
+ }
157
+ }
158
+ return DEFAULT_ORDER;
159
+ }
160
+
161
+ /***************************************************************
162
+ * Sort in place by `order`, ties broken by name so a schema
163
+ * whose records all default to 9999 still draws the same way
164
+ * twice.
165
+ ***************************************************************/
166
+ function sort_by_order(list)
167
+ {
168
+ list.sort((a, b) => {
169
+ if(a.order !== b.order) {
170
+ return a.order - b.order;
171
+ }
172
+ return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0);
173
+ });
174
+ return list;
175
+ }
176
+
177
+ /***************************************************************
178
+ * build_schema_model({treedbs, topics, cols}) -> model
179
+ *
180
+ * model = {
181
+ * treedbs: [{
182
+ * id, name, record,
183
+ * schema_version, c_schema_version, system_schema_version,
184
+ * topics: [{
185
+ * id, name, order, record,
186
+ * pkey, topic_version, system_topic,
187
+ * cols: [{id, name, order, record}]
188
+ * }]
189
+ * }],
190
+ * orphan_topics: [...], topics whose treedb is not in the store
191
+ * orphan_cols: [...] columns whose topic is not in the store
192
+ * }
193
+ *
194
+ * An orphan is NOT dropped. A column whose topic was deleted is
195
+ * exactly the kind of leftover an operator opens this editor to
196
+ * find, and a model that hides it makes the editor lie.
197
+ ***************************************************************/
198
+ function build_schema_model(records)
199
+ {
200
+ let r = records || {};
201
+ let treedb_rows = Array.isArray(r.treedbs) ? r.treedbs : [];
202
+ let topic_rows = Array.isArray(r.topics) ? r.topics : [];
203
+ let col_rows = Array.isArray(r.cols) ? r.cols : [];
204
+
205
+ let treedbs = [];
206
+ let by_treedb_id = {};
207
+ for(let record of treedb_rows) {
208
+ if(!record || typeof record.id !== "string") {
209
+ continue;
210
+ }
211
+ let entry = {
212
+ id: record.id,
213
+ name: record.id,
214
+ record: record,
215
+ schema_version: record.schema_version,
216
+ c_schema_version: record.c_schema_version,
217
+ system_schema_version: record.system_schema_version,
218
+ topics: []
219
+ };
220
+ treedbs.push(entry);
221
+ by_treedb_id[entry.id] = entry;
222
+ }
223
+
224
+ let orphan_topics = [];
225
+ let by_topic_id = {};
226
+ for(let record of topic_rows) {
227
+ if(!record || typeof record.id !== "string") {
228
+ continue;
229
+ }
230
+ let entry = {
231
+ id: record.id,
232
+ name: record_name(record),
233
+ order: record_order(record),
234
+ record: record,
235
+ treedb_id: parent_id(record, "treedbs"),
236
+ pkey: record.pkey || "",
237
+ pkey2s: record.pkey2s,
238
+ system_flag: record.system_flag || "",
239
+ tkey: record.tkey || "",
240
+ topic_version: record.topic_version,
241
+ system_topic: !!record.system_topic,
242
+ cols: []
243
+ };
244
+ by_topic_id[entry.id] = entry;
245
+
246
+ let parent = by_treedb_id[entry.treedb_id];
247
+ if(parent) {
248
+ parent.topics.push(entry);
249
+ } else {
250
+ orphan_topics.push(entry);
251
+ }
252
+ }
253
+
254
+ let orphan_cols = [];
255
+ for(let record of col_rows) {
256
+ if(!record || typeof record.id !== "string") {
257
+ continue;
258
+ }
259
+ let entry = {
260
+ id: record.id,
261
+ name: record_name(record),
262
+ order: record_order(record),
263
+ record: record,
264
+ topic_id: parent_id(record, "topics")
265
+ };
266
+
267
+ let parent = by_topic_id[entry.topic_id];
268
+ if(parent) {
269
+ parent.cols.push(entry);
270
+ } else {
271
+ orphan_cols.push(entry);
272
+ }
273
+ }
274
+
275
+ for(let treedb of treedbs) {
276
+ sort_by_order(treedb.topics);
277
+ for(let topic of treedb.topics) {
278
+ sort_by_order(topic.cols);
279
+ }
280
+ }
281
+ for(let topic of orphan_topics) {
282
+ sort_by_order(topic.cols);
283
+ }
284
+ treedbs.sort((a, b) => {
285
+ return a.id < b.id ? -1 : (a.id > b.id ? 1 : 0);
286
+ });
287
+ sort_by_order(orphan_topics);
288
+ sort_by_order(orphan_cols);
289
+
290
+ return {
291
+ treedbs: treedbs,
292
+ orphan_topics: orphan_topics,
293
+ orphan_cols: orphan_cols
294
+ };
295
+ }
296
+
297
+ /***************************************************************
298
+ * Is there a value here at all?
299
+ *
300
+ * The store answers a `blob` column that was never set with an
301
+ * EMPTY COLLECTION, not with nothing: `enum: {}`, `hook: {}`,
302
+ * `default: {}`. Read as "a value", those turn every export into
303
+ * a schema full of empty objects — and `'hook': {}` in a literal
304
+ * is a hook with no mapping, which is a link the treedb builds
305
+ * and nothing writes.
306
+ ***************************************************************/
307
+ function is_empty_value(value)
308
+ {
309
+ if(value === null || value === undefined || value === "") {
310
+ return true;
311
+ }
312
+ if(Array.isArray(value)) {
313
+ return value.length === 0;
314
+ }
315
+ if(typeof value === "object") {
316
+ return Object.keys(value).length === 0;
317
+ }
318
+ return false;
319
+ }
320
+
321
+
322
+ /***************************************************************
323
+ * The fields of a column that are DECLARED as one thing and
324
+ * STORED as another.
325
+ *
326
+ * `hook`, `enum`, `default`, `pkey2s` and `properties` are `blob`
327
+ * columns of the `cols` topic (treedb_system_schema.c), so what
328
+ * comes back may be the value or the JSON text of the value,
329
+ * depending on how it was written — a projection writes the
330
+ * value, a hand edit through a plain text field writes the text.
331
+ * `flag` is an array that a single-flag edit can leave as a bare
332
+ * string. Reading these without saying so is how a hook drawn
333
+ * from `{"users":"department"}` becomes no hook at all.
334
+ ***************************************************************/
335
+ function as_json(value)
336
+ {
337
+ if(typeof value !== "string") {
338
+ return value;
339
+ }
340
+ let text = value.trim();
341
+ if(text.length === 0) {
342
+ return null;
343
+ }
344
+ try {
345
+ return JSON.parse(text);
346
+ } catch(e) {
347
+ return value; /* plain text that is not JSON: the caller decides */
348
+ }
349
+ }
350
+
351
+ /* The flags of a column, always as a list. */
352
+ function col_flags(record)
353
+ {
354
+ let flag = record ? record.flag : null;
355
+
356
+ if(Array.isArray(flag)) {
357
+ return flag.filter(f => typeof f === "string" && f.length > 0);
358
+ }
359
+ if(typeof flag === "string" && flag.length > 0) {
360
+ let parsed = as_json(flag);
361
+ if(Array.isArray(parsed)) {
362
+ return parsed.filter(f => typeof f === "string" && f.length > 0);
363
+ }
364
+ return [flag];
365
+ }
366
+ return [];
367
+ }
368
+
369
+ function col_has_flag(record, name)
370
+ {
371
+ return col_flags(record).indexOf(name) >= 0;
372
+ }
373
+
374
+ /* The hook mapping of a column, `{child_topic: fkey_col}`, or null. */
375
+ function col_hook(record)
376
+ {
377
+ let hook = as_json(record ? record.hook : null);
378
+
379
+ if(hook && typeof hook === "object" && !Array.isArray(hook)) {
380
+ return hook;
381
+ }
382
+ return null;
383
+ }
384
+
385
+ /* The enum list of a column, always as a list. */
386
+ function col_enum(record)
387
+ {
388
+ let e = as_json(record ? record.enum : null);
389
+
390
+ if(Array.isArray(e)) {
391
+ return e;
392
+ }
393
+ return [];
394
+ }
395
+
396
+ /* The secondary keys a topic declares: a list here, a bare string
397
+ * in the schema literal (see node_label.js). */
398
+ function topic_pkey2s(record)
399
+ {
400
+ let pkey2s = as_json(record ? record.pkey2s : null);
401
+
402
+ if(Array.isArray(pkey2s)) {
403
+ return pkey2s.filter(k => typeof k === "string" && k.length > 0);
404
+ }
405
+ if(typeof pkey2s === "string" && pkey2s.length > 0) {
406
+ return [pkey2s];
407
+ }
408
+ return [];
409
+ }
410
+
411
+
412
+ /***************************************************************
413
+ * Lookups by id. The editor addresses a position by NAME (the
414
+ * url carries `<treedb>/<topic>`), so both are offered.
415
+ ***************************************************************/
416
+ function find_treedb(model, treedb_id)
417
+ {
418
+ if(!model || !Array.isArray(model.treedbs)) {
419
+ return null;
420
+ }
421
+ for(let treedb of model.treedbs) {
422
+ if(treedb.id === treedb_id) {
423
+ return treedb;
424
+ }
425
+ }
426
+ return null;
427
+ }
428
+
429
+ function find_topic(model, treedb_id, topic_name)
430
+ {
431
+ let treedb = find_treedb(model, treedb_id);
432
+
433
+ if(!treedb) {
434
+ return null;
435
+ }
436
+ for(let topic of treedb.topics) {
437
+ if(topic.name === topic_name || topic.id === topic_name) {
438
+ return topic;
439
+ }
440
+ }
441
+ return null;
442
+ }
443
+
444
+ function find_col(model, treedb_id, topic_name, col_name)
445
+ {
446
+ let topic = find_topic(model, treedb_id, topic_name);
447
+
448
+ if(!topic) {
449
+ return null;
450
+ }
451
+ for(let col of topic.cols) {
452
+ if(col.name === col_name || col.id === col_name) {
453
+ return col;
454
+ }
455
+ }
456
+ return null;
457
+ }
458
+
459
+ /***************************************************************
460
+ * The fkey a NEW record must carry to belong to `parent_id`.
461
+ * Written here because the editor is the only writer that has to
462
+ * compose one, and getting the hook name wrong links the record
463
+ * to nothing while the write still succeeds.
464
+ ***************************************************************/
465
+ function fkey_ref(parent_topic, parent_id_, hook_name)
466
+ {
467
+ return `${parent_topic}${REF_SEP}${parent_id_}${REF_SEP}${hook_name}`;
468
+ }
469
+
470
+ /***************************************************************
471
+ * Where a new sibling goes: after the last one. The store's own
472
+ * default (9999) would put every hand-made column in the same
473
+ * place and let their names decide the schema's order.
474
+ ***************************************************************/
475
+ function next_order(siblings)
476
+ {
477
+ let max = 0;
478
+
479
+ if(Array.isArray(siblings)) {
480
+ for(let s of siblings) {
481
+ let order = (s && typeof s.order === "number") ? s.order : DEFAULT_ORDER;
482
+ if(order < DEFAULT_ORDER && order > max) {
483
+ max = order;
484
+ }
485
+ }
486
+ }
487
+ return max + 1;
488
+ }
489
+
490
+ /***************************************************************
491
+ * Renumber a list AFTER a move, as the writes it takes: only the
492
+ * records whose order actually changed, so a drag of one row in a
493
+ * 40-column topic is not 40 writes.
494
+ *
495
+ * moved_orders(list) -> [{id, order}, ...]
496
+ ***************************************************************/
497
+ function moved_orders(list)
498
+ {
499
+ let writes = [];
500
+
501
+ if(!Array.isArray(list)) {
502
+ return writes;
503
+ }
504
+ for(let i = 0; i < list.length; i++) {
505
+ let entry = list[i];
506
+ let order = i + 1;
507
+ if(!entry || typeof entry.id !== "string") {
508
+ continue;
509
+ }
510
+ if(entry.order !== order) {
511
+ writes.push({id: entry.id, order: order});
512
+ }
513
+ }
514
+ return writes;
515
+ }
516
+
517
+
518
+ export {
519
+ DEFAULT_ORDER,
520
+ REF_SEP,
521
+ parse_fkey_ref,
522
+ as_json,
523
+ is_empty_value,
524
+ col_flags,
525
+ col_has_flag,
526
+ col_hook,
527
+ col_enum,
528
+ topic_pkey2s,
529
+ record_name,
530
+ record_order,
531
+ build_schema_model,
532
+ find_treedb,
533
+ find_topic,
534
+ find_col,
535
+ fkey_ref,
536
+ next_order,
537
+ moved_orders,
538
+ };