@yuneta/gobj-ui 7.23.169 → 7.23.171
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/README.md +24 -0
- package/dist/gobj-ui.cjs.js +581 -421
- package/dist/gobj-ui.es.js +581 -421
- package/package.json +2 -2
- package/src/c_yui_treedb_schema.js +26 -5
- package/src/c_yui_treedb_topic_with_form.js +5 -39
- package/src/c_yui_treedb_topics.css +3 -0
- package/src/c_yui_treedb_topics.js +57 -35
- package/src/treedb_topic_keys.js +122 -0
- package/src/treedb_topic_keys.test.js +79 -0
- package/src/treedb_write_plan.js +47 -1
- package/src/treedb_write_plan.test.js +53 -1
package/dist/gobj-ui.es.js
CHANGED
|
@@ -23046,13 +23046,455 @@ function nodes_answer(data) {
|
|
|
23046
23046
|
};
|
|
23047
23047
|
}
|
|
23048
23048
|
//#endregion
|
|
23049
|
+
//#region src/schema_model.js
|
|
23050
|
+
/***********************************************************************
|
|
23051
|
+
* schema_model.js
|
|
23052
|
+
*
|
|
23053
|
+
* A SCHEMA out of the three meta-topics that store it.
|
|
23054
|
+
*
|
|
23055
|
+
* `treedb_system_schema` keeps every schema of a yuno as DATA, in
|
|
23056
|
+
* three flat topics — `treedbs`, `topics`, `cols` — linked by
|
|
23057
|
+
* fkeys. That is the right STORAGE and the wrong thing to put in
|
|
23058
|
+
* front of an operator: adding one column to one topic means
|
|
23059
|
+
* finding it in a table holding every column of every topic of
|
|
23060
|
+
* every treedb the yuno has.
|
|
23061
|
+
*
|
|
23062
|
+
* This module turns those three lists back into what they
|
|
23063
|
+
* describe: treedb -> topics -> columns, each in its declared
|
|
23064
|
+
* `order`. Pure, so the editor's model can be tested with no DOM
|
|
23065
|
+
* and no backend — the gclass fetches and draws, this decides
|
|
23066
|
+
* what the records MEAN.
|
|
23067
|
+
*
|
|
23068
|
+
* TWO THINGS IT EXISTS TO STATE ONCE:
|
|
23069
|
+
*
|
|
23070
|
+
* 1. A record is grouped by its FKEY, not by its id. The id is
|
|
23071
|
+
* qualified (`<treedb>.<topic>.<column>`) and reading it back
|
|
23072
|
+
* with a split on '.' works right up to the first treedb or
|
|
23073
|
+
* topic whose name carries one. The fkey is the link the store
|
|
23074
|
+
* itself uses; the id is only the fallback for a record whose
|
|
23075
|
+
* fkey never arrived.
|
|
23076
|
+
*
|
|
23077
|
+
* 2. A fkey reaches this code in FOUR shapes, because `nodes`
|
|
23078
|
+
* answers differently depending on the options it was called
|
|
23079
|
+
* with: the bare ref string, the expanded object, a list of
|
|
23080
|
+
* either, or a dict keyed by the ref. All four are the same
|
|
23081
|
+
* fact and parse_fkey_ref() flattens them to it.
|
|
23082
|
+
*
|
|
23083
|
+
* Copyright (c) 2026, ArtGins.
|
|
23084
|
+
* All Rights Reserved.
|
|
23085
|
+
***********************************************************************/
|
|
23086
|
+
var DEFAULT_ORDER = 9999;
|
|
23087
|
+
/***************************************************************
|
|
23088
|
+
* parse_fkey_ref(value) -> [{topic_name, id, hook_name}, ...]
|
|
23089
|
+
*
|
|
23090
|
+
* Every shape a fkey column can arrive in, flattened to the
|
|
23091
|
+
* refs it names. Anything unrecognizable contributes nothing —
|
|
23092
|
+
* a schema drawn without one link beats a screen that throws.
|
|
23093
|
+
***************************************************************/
|
|
23094
|
+
function parse_fkey_ref(value) {
|
|
23095
|
+
let refs = [];
|
|
23096
|
+
let push_one = (v) => {
|
|
23097
|
+
if (typeof v === "string") {
|
|
23098
|
+
let parts = v.split("^");
|
|
23099
|
+
if (parts.length !== 3) return;
|
|
23100
|
+
refs.push({
|
|
23101
|
+
topic_name: parts[0],
|
|
23102
|
+
id: parts[1],
|
|
23103
|
+
hook_name: parts[2]
|
|
23104
|
+
});
|
|
23105
|
+
return;
|
|
23106
|
+
}
|
|
23107
|
+
if (v && typeof v === "object") {
|
|
23108
|
+
let id = v.id;
|
|
23109
|
+
if (typeof id !== "string" || id.length === 0) return;
|
|
23110
|
+
refs.push({
|
|
23111
|
+
topic_name: v.topic_name || "",
|
|
23112
|
+
id,
|
|
23113
|
+
hook_name: v.hook_name || ""
|
|
23114
|
+
});
|
|
23115
|
+
}
|
|
23116
|
+
};
|
|
23117
|
+
if (value === null || value === void 0) return refs;
|
|
23118
|
+
if (typeof value === "string") {
|
|
23119
|
+
push_one(value);
|
|
23120
|
+
return refs;
|
|
23121
|
+
}
|
|
23122
|
+
if (Array.isArray(value)) {
|
|
23123
|
+
for (let v of value) push_one(v);
|
|
23124
|
+
return refs;
|
|
23125
|
+
}
|
|
23126
|
+
if (typeof value === "object") for (let [key, v] of Object.entries(value)) if (v && typeof v === "object" && typeof v.id === "string") push_one(v);
|
|
23127
|
+
else push_one(key);
|
|
23128
|
+
return refs;
|
|
23129
|
+
}
|
|
23130
|
+
/***************************************************************
|
|
23131
|
+
* The id of the FIRST parent a record names, or "" when it
|
|
23132
|
+
* names none. A record has one parent here — a column belongs to
|
|
23133
|
+
* one topic, a topic to one treedb — and a second ref would be a
|
|
23134
|
+
* store this editor cannot draw anyway.
|
|
23135
|
+
***************************************************************/
|
|
23136
|
+
function parent_id(record, fkey_col) {
|
|
23137
|
+
let refs = parse_fkey_ref(record ? record[fkey_col] : null);
|
|
23138
|
+
return refs.length > 0 ? refs[0].id : "";
|
|
23139
|
+
}
|
|
23140
|
+
/***************************************************************
|
|
23141
|
+
* The name a record is KNOWN by, which is not what it is keyed
|
|
23142
|
+
* by: `topics` and `cols` key by the qualified id and carry the
|
|
23143
|
+
* bare name in `value` (its pkey2). An older store that still
|
|
23144
|
+
* keyed by rowid has no `value` worth showing, so the id stands
|
|
23145
|
+
* in — the same fallback as node_label().
|
|
23146
|
+
***************************************************************/
|
|
23147
|
+
function record_name(record) {
|
|
23148
|
+
if (!record) return "";
|
|
23149
|
+
if (typeof record.value === "string" && record.value.length > 0) return record.value;
|
|
23150
|
+
return typeof record.id === "string" ? record.id : "";
|
|
23151
|
+
}
|
|
23152
|
+
/***************************************************************
|
|
23153
|
+
* The `order` of a record as a NUMBER, whatever the store put
|
|
23154
|
+
* there: an integer column read back from JSON may arrive as a
|
|
23155
|
+
* string, and comparing "10" with 9 sorts the schema wrong.
|
|
23156
|
+
***************************************************************/
|
|
23157
|
+
function record_order(record) {
|
|
23158
|
+
let order = record ? record.order : void 0;
|
|
23159
|
+
if (typeof order === "number" && isFinite(order)) return order;
|
|
23160
|
+
if (typeof order === "string" && order.trim().length > 0) {
|
|
23161
|
+
let n = Number(order);
|
|
23162
|
+
if (isFinite(n)) return n;
|
|
23163
|
+
}
|
|
23164
|
+
return DEFAULT_ORDER;
|
|
23165
|
+
}
|
|
23166
|
+
/***************************************************************
|
|
23167
|
+
* Sort in place by `order`, ties broken by name so a schema
|
|
23168
|
+
* whose records all default to 9999 still draws the same way
|
|
23169
|
+
* twice.
|
|
23170
|
+
***************************************************************/
|
|
23171
|
+
function sort_by_order(list) {
|
|
23172
|
+
list.sort((a, b) => {
|
|
23173
|
+
if (a.order !== b.order) return a.order - b.order;
|
|
23174
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
23175
|
+
});
|
|
23176
|
+
return list;
|
|
23177
|
+
}
|
|
23178
|
+
/***************************************************************
|
|
23179
|
+
* build_schema_model({treedbs, topics, cols}) -> model
|
|
23180
|
+
*
|
|
23181
|
+
* model = {
|
|
23182
|
+
* treedbs: [{
|
|
23183
|
+
* id, name, record,
|
|
23184
|
+
* schema_version, c_schema_version, system_schema_version,
|
|
23185
|
+
* topics: [{
|
|
23186
|
+
* id, name, order, record,
|
|
23187
|
+
* pkey, topic_version, system_topic, main_topic,
|
|
23188
|
+
* cols: [{id, name, order, record}]
|
|
23189
|
+
* }]
|
|
23190
|
+
* }],
|
|
23191
|
+
* orphan_topics: [...], topics whose treedb is not in the store
|
|
23192
|
+
* orphan_cols: [...] columns whose topic is not in the store
|
|
23193
|
+
* }
|
|
23194
|
+
*
|
|
23195
|
+
* An orphan is NOT dropped. A column whose topic was deleted is
|
|
23196
|
+
* exactly the kind of leftover an operator opens this editor to
|
|
23197
|
+
* find, and a model that hides it makes the editor lie.
|
|
23198
|
+
***************************************************************/
|
|
23199
|
+
function build_schema_model(records) {
|
|
23200
|
+
let r = records || {};
|
|
23201
|
+
let treedb_rows = Array.isArray(r.treedbs) ? r.treedbs : [];
|
|
23202
|
+
let topic_rows = Array.isArray(r.topics) ? r.topics : [];
|
|
23203
|
+
let col_rows = Array.isArray(r.cols) ? r.cols : [];
|
|
23204
|
+
let treedbs = [];
|
|
23205
|
+
let by_treedb_id = {};
|
|
23206
|
+
for (let record of treedb_rows) {
|
|
23207
|
+
if (!record || typeof record.id !== "string") continue;
|
|
23208
|
+
let entry = {
|
|
23209
|
+
id: record.id,
|
|
23210
|
+
name: record.id,
|
|
23211
|
+
record,
|
|
23212
|
+
schema_version: record.schema_version,
|
|
23213
|
+
c_schema_version: record.c_schema_version,
|
|
23214
|
+
system_schema_version: record.system_schema_version,
|
|
23215
|
+
topics: []
|
|
23216
|
+
};
|
|
23217
|
+
treedbs.push(entry);
|
|
23218
|
+
by_treedb_id[entry.id] = entry;
|
|
23219
|
+
}
|
|
23220
|
+
let orphan_topics = [];
|
|
23221
|
+
let by_topic_id = {};
|
|
23222
|
+
for (let record of topic_rows) {
|
|
23223
|
+
if (!record || typeof record.id !== "string") continue;
|
|
23224
|
+
let entry = {
|
|
23225
|
+
id: record.id,
|
|
23226
|
+
name: record_name(record),
|
|
23227
|
+
order: record_order(record),
|
|
23228
|
+
record,
|
|
23229
|
+
treedb_id: parent_id(record, "treedbs"),
|
|
23230
|
+
pkey: record.pkey || "",
|
|
23231
|
+
pkey2s: record.pkey2s,
|
|
23232
|
+
system_flag: record.system_flag || "",
|
|
23233
|
+
tkey: record.tkey || "",
|
|
23234
|
+
topic_version: record.topic_version,
|
|
23235
|
+
system_topic: !!record.system_topic,
|
|
23236
|
+
main_topic: !!record.main_topic,
|
|
23237
|
+
cols: []
|
|
23238
|
+
};
|
|
23239
|
+
by_topic_id[entry.id] = entry;
|
|
23240
|
+
let parent = by_treedb_id[entry.treedb_id];
|
|
23241
|
+
if (parent) parent.topics.push(entry);
|
|
23242
|
+
else orphan_topics.push(entry);
|
|
23243
|
+
}
|
|
23244
|
+
let orphan_cols = [];
|
|
23245
|
+
for (let record of col_rows) {
|
|
23246
|
+
if (!record || typeof record.id !== "string") continue;
|
|
23247
|
+
let entry = {
|
|
23248
|
+
id: record.id,
|
|
23249
|
+
name: record_name(record),
|
|
23250
|
+
order: record_order(record),
|
|
23251
|
+
record,
|
|
23252
|
+
topic_id: parent_id(record, "topics")
|
|
23253
|
+
};
|
|
23254
|
+
let parent = by_topic_id[entry.topic_id];
|
|
23255
|
+
if (parent) parent.cols.push(entry);
|
|
23256
|
+
else orphan_cols.push(entry);
|
|
23257
|
+
}
|
|
23258
|
+
for (let treedb of treedbs) {
|
|
23259
|
+
sort_by_order(treedb.topics);
|
|
23260
|
+
for (let topic of treedb.topics) sort_by_order(topic.cols);
|
|
23261
|
+
}
|
|
23262
|
+
for (let topic of orphan_topics) sort_by_order(topic.cols);
|
|
23263
|
+
treedbs.sort((a, b) => {
|
|
23264
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
23265
|
+
});
|
|
23266
|
+
sort_by_order(orphan_topics);
|
|
23267
|
+
sort_by_order(orphan_cols);
|
|
23268
|
+
return {
|
|
23269
|
+
treedbs,
|
|
23270
|
+
orphan_topics,
|
|
23271
|
+
orphan_cols
|
|
23272
|
+
};
|
|
23273
|
+
}
|
|
23274
|
+
/***************************************************************
|
|
23275
|
+
* Is there a value here at all?
|
|
23276
|
+
*
|
|
23277
|
+
* The store answers a `blob` column that was never set with an
|
|
23278
|
+
* EMPTY COLLECTION, not with nothing: `enum: {}`, `hook: {}`,
|
|
23279
|
+
* `default: {}`. Read as "a value", those turn every export into
|
|
23280
|
+
* a schema full of empty objects — and `'hook': {}` in a literal
|
|
23281
|
+
* is a hook with no mapping, which is a link the treedb builds
|
|
23282
|
+
* and nothing writes.
|
|
23283
|
+
***************************************************************/
|
|
23284
|
+
function is_empty_value(value) {
|
|
23285
|
+
if (value === null || value === void 0 || value === "") return true;
|
|
23286
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
23287
|
+
if (typeof value === "object") return Object.keys(value).length === 0;
|
|
23288
|
+
return false;
|
|
23289
|
+
}
|
|
23290
|
+
/***************************************************************
|
|
23291
|
+
* The fields of a column that are DECLARED as one thing and
|
|
23292
|
+
* STORED as another.
|
|
23293
|
+
*
|
|
23294
|
+
* `hook`, `enum`, `default`, `pkey2s` and `properties` are `blob`
|
|
23295
|
+
* columns of the `cols` topic (treedb_system_schema.c), so what
|
|
23296
|
+
* comes back may be the value or the JSON text of the value,
|
|
23297
|
+
* depending on how it was written — a projection writes the
|
|
23298
|
+
* value, a hand edit through a plain text field writes the text.
|
|
23299
|
+
* `flag` is an array that a single-flag edit can leave as a bare
|
|
23300
|
+
* string. Reading these without saying so is how a hook drawn
|
|
23301
|
+
* from `{"users":"department"}` becomes no hook at all.
|
|
23302
|
+
***************************************************************/
|
|
23303
|
+
function as_json(value) {
|
|
23304
|
+
if (typeof value !== "string") return value;
|
|
23305
|
+
let text = value.trim();
|
|
23306
|
+
if (text.length === 0) return null;
|
|
23307
|
+
try {
|
|
23308
|
+
return JSON.parse(text);
|
|
23309
|
+
} catch (e) {
|
|
23310
|
+
return value;
|
|
23311
|
+
}
|
|
23312
|
+
}
|
|
23313
|
+
function col_flags(record) {
|
|
23314
|
+
let flag = record ? record.flag : null;
|
|
23315
|
+
if (Array.isArray(flag)) return flag.filter((f) => typeof f === "string" && f.length > 0);
|
|
23316
|
+
if (typeof flag === "string" && flag.length > 0) {
|
|
23317
|
+
let parsed = as_json(flag);
|
|
23318
|
+
if (Array.isArray(parsed)) return parsed.filter((f) => typeof f === "string" && f.length > 0);
|
|
23319
|
+
return [flag];
|
|
23320
|
+
}
|
|
23321
|
+
return [];
|
|
23322
|
+
}
|
|
23323
|
+
function col_hook(record) {
|
|
23324
|
+
let hook = as_json(record ? record.hook : null);
|
|
23325
|
+
if (hook && typeof hook === "object" && !Array.isArray(hook)) return hook;
|
|
23326
|
+
return null;
|
|
23327
|
+
}
|
|
23328
|
+
function col_enum(record) {
|
|
23329
|
+
let e = as_json(record ? record.enum : null);
|
|
23330
|
+
if (Array.isArray(e)) return e;
|
|
23331
|
+
return [];
|
|
23332
|
+
}
|
|
23333
|
+
function topic_pkey2s(record) {
|
|
23334
|
+
let pkey2s = as_json(record ? record.pkey2s : null);
|
|
23335
|
+
if (Array.isArray(pkey2s)) return pkey2s.filter((k) => typeof k === "string" && k.length > 0);
|
|
23336
|
+
if (typeof pkey2s === "string" && pkey2s.length > 0) return [pkey2s];
|
|
23337
|
+
return [];
|
|
23338
|
+
}
|
|
23339
|
+
/***************************************************************
|
|
23340
|
+
* Lookups by id. The editor addresses a position by NAME (the
|
|
23341
|
+
* url carries `<treedb>/<topic>`), so both are offered.
|
|
23342
|
+
***************************************************************/
|
|
23343
|
+
function find_treedb(model, treedb_id) {
|
|
23344
|
+
if (!model || !Array.isArray(model.treedbs)) return null;
|
|
23345
|
+
for (let treedb of model.treedbs) if (treedb.id === treedb_id) return treedb;
|
|
23346
|
+
return null;
|
|
23347
|
+
}
|
|
23348
|
+
function find_topic(model, treedb_id, topic_name) {
|
|
23349
|
+
let treedb = find_treedb(model, treedb_id);
|
|
23350
|
+
if (!treedb) return null;
|
|
23351
|
+
for (let topic of treedb.topics) if (topic.name === topic_name || topic.id === topic_name) return topic;
|
|
23352
|
+
return null;
|
|
23353
|
+
}
|
|
23354
|
+
function find_col(model, treedb_id, topic_name, col_name) {
|
|
23355
|
+
let topic = find_topic(model, treedb_id, topic_name);
|
|
23356
|
+
if (!topic) return null;
|
|
23357
|
+
for (let col of topic.cols) if (col.name === col_name || col.id === col_name) return col;
|
|
23358
|
+
return null;
|
|
23359
|
+
}
|
|
23360
|
+
/***************************************************************
|
|
23361
|
+
* The fkey a NEW record must carry to belong to `parent_id`.
|
|
23362
|
+
* Written here because the editor is the only writer that has to
|
|
23363
|
+
* compose one, and getting the hook name wrong links the record
|
|
23364
|
+
* to nothing while the write still succeeds.
|
|
23365
|
+
***************************************************************/
|
|
23366
|
+
function fkey_ref(parent_topic, parent_id_, hook_name) {
|
|
23367
|
+
return `${parent_topic}^${parent_id_}^${hook_name}`;
|
|
23368
|
+
}
|
|
23369
|
+
/***************************************************************
|
|
23370
|
+
* Where a new sibling goes: after the last one. The store's own
|
|
23371
|
+
* default (9999) would put every hand-made column in the same
|
|
23372
|
+
* place and let their names decide the schema's order.
|
|
23373
|
+
***************************************************************/
|
|
23374
|
+
function next_order(siblings) {
|
|
23375
|
+
let max = 0;
|
|
23376
|
+
if (Array.isArray(siblings)) for (let s of siblings) {
|
|
23377
|
+
let order = s && typeof s.order === "number" ? s.order : DEFAULT_ORDER;
|
|
23378
|
+
if (order < 9999 && order > max) max = order;
|
|
23379
|
+
}
|
|
23380
|
+
return max + 1;
|
|
23381
|
+
}
|
|
23382
|
+
/***************************************************************
|
|
23383
|
+
* Renumber a list AFTER a move, as the writes it takes: only the
|
|
23384
|
+
* records whose order actually changed, so a drag of one row in a
|
|
23385
|
+
* 40-column topic is not 40 writes.
|
|
23386
|
+
*
|
|
23387
|
+
* moved_orders(list) -> [{id, order}, ...]
|
|
23388
|
+
***************************************************************/
|
|
23389
|
+
function moved_orders(list) {
|
|
23390
|
+
let writes = [];
|
|
23391
|
+
if (!Array.isArray(list)) return writes;
|
|
23392
|
+
for (let i = 0; i < list.length; i++) {
|
|
23393
|
+
let entry = list[i];
|
|
23394
|
+
let order = i + 1;
|
|
23395
|
+
if (!entry || typeof entry.id !== "string") continue;
|
|
23396
|
+
if (entry.order !== order) writes.push({
|
|
23397
|
+
id: entry.id,
|
|
23398
|
+
order
|
|
23399
|
+
});
|
|
23400
|
+
}
|
|
23401
|
+
return writes;
|
|
23402
|
+
}
|
|
23403
|
+
//#endregion
|
|
23404
|
+
//#region src/treedb_topic_keys.js
|
|
23405
|
+
/***********************************************************************
|
|
23406
|
+
* treedb_topic_keys.js
|
|
23407
|
+
*
|
|
23408
|
+
* The KEYS of a treedb topic, read from its desc: the pkey, the
|
|
23409
|
+
* secondary keys (`pkey2s`) and the time key (`tkey`), plus the
|
|
23410
|
+
* names behind its `system_flag` number.
|
|
23411
|
+
*
|
|
23412
|
+
* Every view that describes a topic -- the schema diagram, the
|
|
23413
|
+
* topic-info panel, the topic cards -- must say them, and say them
|
|
23414
|
+
* the same way. A topic with `pkey2s` is not a topic with one more
|
|
23415
|
+
* column: one id holds several INSTANCES, an update has to carry
|
|
23416
|
+
* the pkey2 or it lands on the primary, and `delete-instance`
|
|
23417
|
+
* exists. `tkey` names where the time of every record comes from.
|
|
23418
|
+
* The views said neither, so this is where they ask.
|
|
23419
|
+
*
|
|
23420
|
+
* Pure, so it is tested with no DOM.
|
|
23421
|
+
*
|
|
23422
|
+
* Copyright (c) 2026, ArtGins.
|
|
23423
|
+
* All Rights Reserved.
|
|
23424
|
+
***********************************************************************/
|
|
23425
|
+
var SF_NAMES = [
|
|
23426
|
+
"sf_string_key",
|
|
23427
|
+
"sf_rowid_key",
|
|
23428
|
+
"sf_int_key",
|
|
23429
|
+
"",
|
|
23430
|
+
"sf_zip_record",
|
|
23431
|
+
"sf_cipher_record",
|
|
23432
|
+
"",
|
|
23433
|
+
"",
|
|
23434
|
+
"sf_t_ms",
|
|
23435
|
+
"sf_tm_ms",
|
|
23436
|
+
"sf_deleted_instance",
|
|
23437
|
+
"sf_immutable_record",
|
|
23438
|
+
"sf_loading_from_disk",
|
|
23439
|
+
"",
|
|
23440
|
+
"",
|
|
23441
|
+
""
|
|
23442
|
+
];
|
|
23443
|
+
/************************************************************
|
|
23444
|
+
* The secondary keys of a topic, always a list. The desc
|
|
23445
|
+
* carries a list; a schema literal may declare a bare
|
|
23446
|
+
* string.
|
|
23447
|
+
************************************************************/
|
|
23448
|
+
function desc_pkey2s(desc) {
|
|
23449
|
+
return topic_pkey2s(desc);
|
|
23450
|
+
}
|
|
23451
|
+
/************************************************************
|
|
23452
|
+
* The time key of a topic, "" when it has none -- and then
|
|
23453
|
+
* the time of a record is the time it was appended.
|
|
23454
|
+
************************************************************/
|
|
23455
|
+
function desc_tkey(desc) {
|
|
23456
|
+
let tkey = desc ? desc.tkey : "";
|
|
23457
|
+
return typeof tkey === "string" ? tkey : "";
|
|
23458
|
+
}
|
|
23459
|
+
/************************************************************
|
|
23460
|
+
* Which keys of its topic a column IS, in the order the
|
|
23461
|
+
* views print them: "pkey", "pkey2", "tkey". Empty for a
|
|
23462
|
+
* column that is none.
|
|
23463
|
+
************************************************************/
|
|
23464
|
+
function col_key_roles(desc, col_id) {
|
|
23465
|
+
let roles = [];
|
|
23466
|
+
if (!desc || typeof col_id !== "string" || col_id.length === 0) return roles;
|
|
23467
|
+
if (col_id === (desc.pkey || "id")) roles.push("pkey");
|
|
23468
|
+
if (desc_pkey2s(desc).includes(col_id)) roles.push("pkey2");
|
|
23469
|
+
if (desc_tkey(desc) === col_id) roles.push("tkey");
|
|
23470
|
+
return roles;
|
|
23471
|
+
}
|
|
23472
|
+
/************************************************************
|
|
23473
|
+
* The names of a system_flag. The desc carries the NUMBER
|
|
23474
|
+
* (`1`), a schema literal carries the names
|
|
23475
|
+
* ("sf_string_key|sf_t_ms"); both come back as a list. A
|
|
23476
|
+
* set bit with no name is shown by its value, never
|
|
23477
|
+
* dropped.
|
|
23478
|
+
************************************************************/
|
|
23479
|
+
function system_flag_names(system_flag) {
|
|
23480
|
+
if (Array.isArray(system_flag)) return system_flag.filter((n) => typeof n === "string" && n.length > 0);
|
|
23481
|
+
if (typeof system_flag === "string") return system_flag.split(/[|,]/).map((n) => n.trim()).filter((n) => n.length > 0);
|
|
23482
|
+
if (typeof system_flag !== "number" || !Number.isInteger(system_flag) || system_flag <= 0) return [];
|
|
23483
|
+
let names = [];
|
|
23484
|
+
for (let bit = 0; bit < SF_NAMES.length; bit++) {
|
|
23485
|
+
let mask = 1 << bit;
|
|
23486
|
+
if (system_flag & mask) names.push(SF_NAMES[bit] || `0x${mask.toString(16).padStart(4, "0")}`);
|
|
23487
|
+
}
|
|
23488
|
+
return names;
|
|
23489
|
+
}
|
|
23490
|
+
//#endregion
|
|
23049
23491
|
//#region src/c_yui_treedb_topics.js
|
|
23050
23492
|
/***********************************************************************
|
|
23051
23493
|
* c_yui_treedb_topics.js
|
|
23052
23494
|
*
|
|
23053
23495
|
* Management of TreeDB's topics with Bulma tabs
|
|
23054
23496
|
*
|
|
23055
|
-
* Copyright (c) 2025, ArtGins.
|
|
23497
|
+
* Copyright (c) 2025-2026, ArtGins.
|
|
23056
23498
|
* All Rights Reserved.
|
|
23057
23499
|
***********************************************************************/
|
|
23058
23500
|
/***************************************************************
|
|
@@ -23566,6 +24008,29 @@ function topic_card_meta(desc) {
|
|
|
23566
24008
|
{ class: "TREEDB_TOPIC_CARD_META" },
|
|
23567
24009
|
head
|
|
23568
24010
|
]);
|
|
24011
|
+
let pkey2s = desc_pkey2s(desc);
|
|
24012
|
+
if (pkey2s.length) {
|
|
24013
|
+
let list = pkey2s.join(", ");
|
|
24014
|
+
meta.push([
|
|
24015
|
+
"div",
|
|
24016
|
+
{
|
|
24017
|
+
class: "TREEDB_TOPIC_CARD_REL TREEDB_TOPIC_CARD_PKEY2S",
|
|
24018
|
+
title: list
|
|
24019
|
+
},
|
|
24020
|
+
[[
|
|
24021
|
+
"span",
|
|
24022
|
+
{
|
|
24023
|
+
class: "TREEDB_TOPIC_CARD_REL_LABEL",
|
|
24024
|
+
i18n: "pkey2s"
|
|
24025
|
+
},
|
|
24026
|
+
"pkey2s"
|
|
24027
|
+
], [
|
|
24028
|
+
"span",
|
|
24029
|
+
{ class: "TREEDB_TOPIC_CARD_REL_LIST" },
|
|
24030
|
+
list
|
|
24031
|
+
]]
|
|
24032
|
+
]);
|
|
24033
|
+
}
|
|
23569
24034
|
let relation = (cls, key, arrow, names) => {
|
|
23570
24035
|
let list = names.join(", ");
|
|
23571
24036
|
return [
|
|
@@ -23829,33 +24294,26 @@ function show_topic_info(gobj, topic) {
|
|
|
23829
24294
|
}
|
|
23830
24295
|
/************************************************************
|
|
23831
24296
|
* Build the read-only topic-info panel from a topic desc: the
|
|
23832
|
-
* topic name, its
|
|
24297
|
+
* topic name, its KEYS, and a table of columns (name / type /
|
|
23833
24298
|
* key relationship). Everything is guarded — a malformed desc
|
|
23834
24299
|
* renders a shorter panel, never throws.
|
|
24300
|
+
*
|
|
24301
|
+
* The keys are what an operator has to know before touching a
|
|
24302
|
+
* record, so they are all said, and the empty ones too: a
|
|
24303
|
+
* topic with `pkey2s` keeps several instances under one id,
|
|
24304
|
+
* and an empty `tkey` means the time of a record is the time
|
|
24305
|
+
* it was appended -- a row missing is not the same as "none".
|
|
23835
24306
|
************************************************************/
|
|
23836
24307
|
function build_topic_info_panel(gobj, $info, topic, desc) {
|
|
23837
24308
|
while ($info.firstChild) $info.removeChild($info.firstChild);
|
|
23838
24309
|
let pkey = desc.pkey || "";
|
|
23839
|
-
let
|
|
23840
|
-
|
|
23841
|
-
if (is_object(v)) return Object.keys(v).join(", ");
|
|
23842
|
-
return v === void 0 || v === null ? "" : String(v);
|
|
23843
|
-
};
|
|
24310
|
+
let pkey2s = desc_pkey2s(desc);
|
|
24311
|
+
let tkey = desc_tkey(desc);
|
|
23844
24312
|
let $meta = [];
|
|
23845
|
-
let push_meta = (key,
|
|
23846
|
-
if (value === void 0 || value === null || value === "") return;
|
|
23847
|
-
let $val = highlight ? [
|
|
23848
|
-
"span",
|
|
23849
|
-
{ class: "tag is-info" },
|
|
23850
|
-
`${value}`
|
|
23851
|
-
] : [
|
|
23852
|
-
"code",
|
|
23853
|
-
{},
|
|
23854
|
-
`${value}`
|
|
23855
|
-
];
|
|
24313
|
+
let push_meta = (key, $val) => {
|
|
23856
24314
|
$meta.push([
|
|
23857
24315
|
"tr",
|
|
23858
|
-
{},
|
|
24316
|
+
{ class: `TREEDB_TOPIC_INFO_${key.toUpperCase()}` },
|
|
23859
24317
|
[[
|
|
23860
24318
|
"th",
|
|
23861
24319
|
{
|
|
@@ -23870,29 +24328,64 @@ function build_topic_info_panel(gobj, $info, topic, desc) {
|
|
|
23870
24328
|
]]
|
|
23871
24329
|
]);
|
|
23872
24330
|
};
|
|
23873
|
-
push_meta("version",
|
|
23874
|
-
|
|
23875
|
-
|
|
23876
|
-
|
|
24331
|
+
if (desc.topic_version !== void 0 && desc.topic_version !== null && desc.topic_version !== "") push_meta("version", [
|
|
24332
|
+
"span",
|
|
24333
|
+
{ class: "tag is-info" },
|
|
24334
|
+
`${desc.topic_version}`
|
|
24335
|
+
]);
|
|
24336
|
+
let sf_names = system_flag_names(desc.system_flag);
|
|
24337
|
+
if (sf_names.length > 0) push_meta("system", [
|
|
24338
|
+
"code",
|
|
24339
|
+
{ title: `${desc.system_flag}` },
|
|
24340
|
+
sf_names.join(" | ")
|
|
24341
|
+
]);
|
|
24342
|
+
if (pkey) push_meta("pkey", [
|
|
24343
|
+
"code",
|
|
24344
|
+
{},
|
|
24345
|
+
pkey
|
|
24346
|
+
]);
|
|
24347
|
+
if (pkey2s.length > 0) push_meta("pkey2s", [
|
|
24348
|
+
"span",
|
|
24349
|
+
{ class: "tags" },
|
|
24350
|
+
pkey2s.map((name) => [
|
|
24351
|
+
"span",
|
|
24352
|
+
{ class: "tag is-warning" },
|
|
24353
|
+
name
|
|
24354
|
+
])
|
|
24355
|
+
]);
|
|
24356
|
+
push_meta("tkey", tkey ? [
|
|
24357
|
+
"code",
|
|
24358
|
+
{},
|
|
24359
|
+
tkey
|
|
24360
|
+
] : [
|
|
24361
|
+
"span",
|
|
24362
|
+
{
|
|
24363
|
+
class: "has-text-grey",
|
|
24364
|
+
i18n: "append time"
|
|
24365
|
+
},
|
|
24366
|
+
"append time"
|
|
24367
|
+
]);
|
|
23877
24368
|
let $rows = [];
|
|
23878
24369
|
let cols = is_array(desc.cols) ? desc.cols : [];
|
|
23879
24370
|
for (let col of cols) {
|
|
23880
24371
|
if (!col || !col.id || col.id.charAt(0) === "_") continue;
|
|
23881
24372
|
let type = col.type || (is_array(col.flag) ? col.flag.join(", ") : col.flag || "");
|
|
23882
|
-
let
|
|
23883
|
-
|
|
23884
|
-
|
|
23885
|
-
else if (is_object(col.hook))
|
|
24373
|
+
let roles = col_key_roles(desc, col.id);
|
|
24374
|
+
let parts = roles.slice();
|
|
24375
|
+
if (is_object(col.fkey)) parts.push("→ " + Object.keys(col.fkey).join(", "));
|
|
24376
|
+
else if (is_object(col.hook)) parts.push("hook → " + Object.keys(col.hook).join(", "));
|
|
24377
|
+
let rel = parts.join(" · ");
|
|
24378
|
+
let is_key = roles.length > 0;
|
|
23886
24379
|
$rows.push([
|
|
23887
24380
|
"tr",
|
|
23888
|
-
{},
|
|
24381
|
+
is_key ? { class: "TREEDB_TOPIC_INFO_KEY_COL" } : {},
|
|
23889
24382
|
[
|
|
23890
24383
|
[
|
|
23891
24384
|
"td",
|
|
23892
24385
|
{},
|
|
23893
24386
|
[[
|
|
23894
24387
|
"code",
|
|
23895
|
-
{},
|
|
24388
|
+
is_key ? { class: "has-text-weight-bold" } : {},
|
|
23896
24389
|
`${col.id}`
|
|
23897
24390
|
]]
|
|
23898
24391
|
],
|
|
@@ -23903,7 +24396,7 @@ function build_topic_info_panel(gobj, $info, topic, desc) {
|
|
|
23903
24396
|
],
|
|
23904
24397
|
[
|
|
23905
24398
|
"td",
|
|
23906
|
-
{},
|
|
24399
|
+
is_key ? { class: "has-text-weight-semibold" } : {},
|
|
23907
24400
|
`${rel}`
|
|
23908
24401
|
]
|
|
23909
24402
|
]
|
|
@@ -25699,6 +26192,43 @@ function plan_treedb_writes(flags) {
|
|
|
25699
26192
|
form_toolbar: null
|
|
25700
26193
|
};
|
|
25701
26194
|
}
|
|
26195
|
+
/************************************************************
|
|
26196
|
+
* Does column `col` travel back to treedb on a write?
|
|
26197
|
+
*
|
|
26198
|
+
* The form SHOWS every field and sends back only what the topic
|
|
26199
|
+
* accepts: the writable cols, the fkeys (a link is edited by
|
|
26200
|
+
* linking) and the pkey, which is not written but is what
|
|
26201
|
+
* ADDRESSES the record.
|
|
26202
|
+
*
|
|
26203
|
+
* It matters because `treedb_update_node()` does not check
|
|
26204
|
+
* `writable` -- it writes any col it is handed -- so a read-only
|
|
26205
|
+
* field travelling back is written with whatever the form made of
|
|
26206
|
+
* it, and the fields that describe a record are exactly the ones
|
|
26207
|
+
* that do not survive a round trip through a widget: a `time` is an
|
|
26208
|
+
* integer rendered as a `datetime-local`, with no seconds, so every
|
|
26209
|
+
* save moved it back up to 59 s. Nothing looked wrong until the
|
|
26210
|
+
* stored timestamp had moved.
|
|
26211
|
+
*
|
|
26212
|
+
* `is_file` and not the type: a `file` column IS an fkey (it is
|
|
26213
|
+
* flagged ['fkey','file']) but answers `type: "file"`, and the write
|
|
26214
|
+
* goes out with `autolink`, which rebuilds the links from what the
|
|
26215
|
+
* record carries. Dropped, a read-only `file` column -- the one only
|
|
26216
|
+
* a load fills -- was UNLINKED by every save of any other field.
|
|
26217
|
+
*
|
|
26218
|
+
* The pkey2s go back for the same reason as the pkey: a secondary key
|
|
26219
|
+
* names the INSTANCE the update is for (`yunos.yuno_release` in the
|
|
26220
|
+
* agent), and it is `persistent, required`, not `writable`. Dropped,
|
|
26221
|
+
* the update reached the backend with no pkey2 and C_NODE resolved it
|
|
26222
|
+
* to the PRIMARY instance -- right by chance while the table lists
|
|
26223
|
+
* primaries, wrong from a form opened on a row of `instances`.
|
|
26224
|
+
************************************************************/
|
|
26225
|
+
function col_goes_back_to_treedb(desc, col) {
|
|
26226
|
+
let d = desc || {};
|
|
26227
|
+
let pkey = d.pkey || "id";
|
|
26228
|
+
const field_desc = treedb_get_field_desc(col);
|
|
26229
|
+
if (field_desc.is_writable || field_desc.type === "fkey" || field_desc.is_file || col.id === pkey) return true;
|
|
26230
|
+
return topic_pkey2s(d).includes(col.id);
|
|
26231
|
+
}
|
|
25702
26232
|
//#endregion
|
|
25703
26233
|
//#region src/delete_impact.js
|
|
25704
26234
|
/***********************************************************************
|
|
@@ -28046,35 +28576,6 @@ function get_schema_col(gobj, id) {
|
|
|
28046
28576
|
return null;
|
|
28047
28577
|
}
|
|
28048
28578
|
/************************************************************
|
|
28049
|
-
* Does column `col` travel back to treedb on a write?
|
|
28050
|
-
*
|
|
28051
|
-
* The form SHOWS every field and sends back only what the topic
|
|
28052
|
-
* accepts: the writable cols, the fkeys (a link is edited by
|
|
28053
|
-
* linking) and the pkey, which is not written but is what
|
|
28054
|
-
* ADDRESSES the record.
|
|
28055
|
-
*
|
|
28056
|
-
* It matters because `treedb_update_node()` does not check
|
|
28057
|
-
* `writable` -- it writes any col it is handed -- so a read-only
|
|
28058
|
-
* field travelling back is written with whatever the form made of
|
|
28059
|
-
* it, and the fields that describe a record are exactly the ones
|
|
28060
|
-
* that do not survive a round trip through a widget: a `time` is an
|
|
28061
|
-
* integer rendered as a `datetime-local`, with no seconds, so every
|
|
28062
|
-
* save moved it back up to 59 s. Nothing looked wrong until the
|
|
28063
|
-
* stored timestamp had moved.
|
|
28064
|
-
*
|
|
28065
|
-
* `is_file` and not the type: a `file` column IS an fkey (it is
|
|
28066
|
-
* flagged ['fkey','file']) but answers `type: "file"`, and the write
|
|
28067
|
-
* goes out with `autolink`, which rebuilds the links from what the
|
|
28068
|
-
* record carries. Dropped, a read-only `file` column -- the one only
|
|
28069
|
-
* a load fills -- was UNLINKED by every save of any other field.
|
|
28070
|
-
************************************************************/
|
|
28071
|
-
function col_goes_back_to_treedb(gobj, col) {
|
|
28072
|
-
let pkey = gobj_read_attr(gobj, "desc").pkey || "id";
|
|
28073
|
-
const field_desc = treedb_get_field_desc(col);
|
|
28074
|
-
if (field_desc.is_writable || field_desc.type === "fkey" || field_desc.is_file || col.id === pkey) return true;
|
|
28075
|
-
return false;
|
|
28076
|
-
}
|
|
28077
|
-
/************************************************************
|
|
28078
28579
|
* The record the FORM hands over, without the columns that do
|
|
28079
28580
|
* not go back to treedb (see col_goes_back_to_treedb()). The
|
|
28080
28581
|
* form has already encoded the values, so only the columns are
|
|
@@ -28084,7 +28585,7 @@ function strip_read_only_cols(gobj, record) {
|
|
|
28084
28585
|
let row = {};
|
|
28085
28586
|
for (let field_name of Object.keys(record || {})) {
|
|
28086
28587
|
let col = get_schema_col(gobj, field_name);
|
|
28087
|
-
if (col && !col_goes_back_to_treedb(gobj, col)) continue;
|
|
28588
|
+
if (col && !col_goes_back_to_treedb(gobj_read_attr(gobj, "desc"), col)) continue;
|
|
28088
28589
|
row[field_name] = record[field_name];
|
|
28089
28590
|
}
|
|
28090
28591
|
return row;
|
|
@@ -28100,7 +28601,7 @@ function transform__form_record_2_treedb_record(gobj, kw, operation) {
|
|
|
28100
28601
|
if (empty_string(field_name)) continue;
|
|
28101
28602
|
let col = get_schema_col(gobj, field_name);
|
|
28102
28603
|
if (!col) continue;
|
|
28103
|
-
if (!col_goes_back_to_treedb(gobj, col)) continue;
|
|
28604
|
+
if (!col_goes_back_to_treedb(gobj_read_attr(gobj, "desc"), col)) continue;
|
|
28104
28605
|
let value = kw[field_name];
|
|
28105
28606
|
row[field_name] = transform__form_value_2_treedb_value(gobj, col, value, operation);
|
|
28106
28607
|
}
|
|
@@ -68475,12 +68976,20 @@ function hook_mark(type) {
|
|
|
68475
68976
|
* (↖) 1 fkey (1 parent)
|
|
68476
68977
|
* [↖] n fkeys (n parents)
|
|
68477
68978
|
* {↖} N fkeys (N parents)
|
|
68979
|
+
* (2) a secondary key (pkey2)
|
|
68980
|
+
* (t) the time key (tkey)
|
|
68478
68981
|
* * required
|
|
68479
68982
|
* # the primary key
|
|
68480
68983
|
*
|
|
68481
|
-
*
|
|
68482
|
-
*
|
|
68483
|
-
*
|
|
68984
|
+
* The key marks lead the mark column, and a pkey2 is drawn in
|
|
68985
|
+
* bold like the pkey: a topic with secondary keys keeps several
|
|
68986
|
+
* instances under one id, which is the first thing to know
|
|
68987
|
+
* about it. `(t)` is new to the notation: the literals' legend
|
|
68988
|
+
* carries it too.
|
|
68989
|
+
*
|
|
68990
|
+
* The flags are read here rather than through
|
|
68991
|
+
* treedb_get_field_desc's single `type`, which keeps only the
|
|
68992
|
+
* last flag it saw.
|
|
68484
68993
|
************************************************************/
|
|
68485
68994
|
function schema_rows(desc) {
|
|
68486
68995
|
let rows = [];
|
|
@@ -68490,19 +68999,25 @@ function schema_rows(desc) {
|
|
|
68490
68999
|
let flags = Array.isArray(col.flag) ? col.flag : [];
|
|
68491
69000
|
let is_hook = flags.indexOf("hook") >= 0;
|
|
68492
69001
|
let is_fkey = flags.indexOf("fkey") >= 0;
|
|
69002
|
+
let roles = col_key_roles(desc, col.id);
|
|
69003
|
+
let key_marks = [];
|
|
69004
|
+
if (roles.includes("pkey2")) key_marks.push("(2)");
|
|
69005
|
+
if (roles.includes("tkey")) key_marks.push("(t)");
|
|
68493
69006
|
let mark = "";
|
|
68494
69007
|
if (is_hook) mark = hook_mark(col.type);
|
|
68495
69008
|
if (is_fkey) {
|
|
68496
69009
|
let fmark = hook_mark(col.type).replace("}", "↖}").replace("]", "↖]").replace(")", "↖)");
|
|
68497
69010
|
mark = mark ? mark + " " + fmark : fmark;
|
|
68498
69011
|
}
|
|
69012
|
+
if (key_marks.length) mark = mark ? key_marks.join(" ") + " " + mark : key_marks.join(" ");
|
|
68499
69013
|
rows.push({
|
|
68500
69014
|
name: col.id,
|
|
68501
69015
|
type: col.type || "",
|
|
68502
69016
|
mark,
|
|
68503
69017
|
is_hook,
|
|
68504
69018
|
is_fkey,
|
|
68505
|
-
is_pkey:
|
|
69019
|
+
is_pkey: roles.includes("pkey"),
|
|
69020
|
+
is_pkey2: roles.includes("pkey2"),
|
|
68506
69021
|
required: flags.indexOf("required") >= 0 || flags.indexOf("notnull") >= 0
|
|
68507
69022
|
});
|
|
68508
69023
|
}
|
|
@@ -68549,7 +69064,7 @@ function build_card_innerHTML(topic, rows, color, dark) {
|
|
|
68549
69064
|
let rows_html = "";
|
|
68550
69065
|
for (let row of rows) {
|
|
68551
69066
|
let lead = row.is_pkey ? "#" : row.required ? "*" : "";
|
|
68552
|
-
let name_weight = row.is_pkey || row.required ? "600" : "400";
|
|
69067
|
+
let name_weight = row.is_pkey || row.is_pkey2 ? "700" : row.required ? "600" : "400";
|
|
68553
69068
|
let name_color = row.is_hook || row.is_fkey ? title_color : text_color;
|
|
68554
69069
|
rows_html += ` <div class="TREEDB_SCHEMA_FIELD" style="
|
|
68555
69070
|
display: flex; align-items: center; gap: 6px;
|
|
@@ -69018,361 +69533,6 @@ function register_c_yui_treedb_schema() {
|
|
|
69018
69533
|
return create_gclass$2(GCLASS_NAME$2);
|
|
69019
69534
|
}
|
|
69020
69535
|
//#endregion
|
|
69021
|
-
//#region src/schema_model.js
|
|
69022
|
-
/***********************************************************************
|
|
69023
|
-
* schema_model.js
|
|
69024
|
-
*
|
|
69025
|
-
* A SCHEMA out of the three meta-topics that store it.
|
|
69026
|
-
*
|
|
69027
|
-
* `treedb_system_schema` keeps every schema of a yuno as DATA, in
|
|
69028
|
-
* three flat topics — `treedbs`, `topics`, `cols` — linked by
|
|
69029
|
-
* fkeys. That is the right STORAGE and the wrong thing to put in
|
|
69030
|
-
* front of an operator: adding one column to one topic means
|
|
69031
|
-
* finding it in a table holding every column of every topic of
|
|
69032
|
-
* every treedb the yuno has.
|
|
69033
|
-
*
|
|
69034
|
-
* This module turns those three lists back into what they
|
|
69035
|
-
* describe: treedb -> topics -> columns, each in its declared
|
|
69036
|
-
* `order`. Pure, so the editor's model can be tested with no DOM
|
|
69037
|
-
* and no backend — the gclass fetches and draws, this decides
|
|
69038
|
-
* what the records MEAN.
|
|
69039
|
-
*
|
|
69040
|
-
* TWO THINGS IT EXISTS TO STATE ONCE:
|
|
69041
|
-
*
|
|
69042
|
-
* 1. A record is grouped by its FKEY, not by its id. The id is
|
|
69043
|
-
* qualified (`<treedb>.<topic>.<column>`) and reading it back
|
|
69044
|
-
* with a split on '.' works right up to the first treedb or
|
|
69045
|
-
* topic whose name carries one. The fkey is the link the store
|
|
69046
|
-
* itself uses; the id is only the fallback for a record whose
|
|
69047
|
-
* fkey never arrived.
|
|
69048
|
-
*
|
|
69049
|
-
* 2. A fkey reaches this code in FOUR shapes, because `nodes`
|
|
69050
|
-
* answers differently depending on the options it was called
|
|
69051
|
-
* with: the bare ref string, the expanded object, a list of
|
|
69052
|
-
* either, or a dict keyed by the ref. All four are the same
|
|
69053
|
-
* fact and parse_fkey_ref() flattens them to it.
|
|
69054
|
-
*
|
|
69055
|
-
* Copyright (c) 2026, ArtGins.
|
|
69056
|
-
* All Rights Reserved.
|
|
69057
|
-
***********************************************************************/
|
|
69058
|
-
var DEFAULT_ORDER = 9999;
|
|
69059
|
-
/***************************************************************
|
|
69060
|
-
* parse_fkey_ref(value) -> [{topic_name, id, hook_name}, ...]
|
|
69061
|
-
*
|
|
69062
|
-
* Every shape a fkey column can arrive in, flattened to the
|
|
69063
|
-
* refs it names. Anything unrecognizable contributes nothing —
|
|
69064
|
-
* a schema drawn without one link beats a screen that throws.
|
|
69065
|
-
***************************************************************/
|
|
69066
|
-
function parse_fkey_ref(value) {
|
|
69067
|
-
let refs = [];
|
|
69068
|
-
let push_one = (v) => {
|
|
69069
|
-
if (typeof v === "string") {
|
|
69070
|
-
let parts = v.split("^");
|
|
69071
|
-
if (parts.length !== 3) return;
|
|
69072
|
-
refs.push({
|
|
69073
|
-
topic_name: parts[0],
|
|
69074
|
-
id: parts[1],
|
|
69075
|
-
hook_name: parts[2]
|
|
69076
|
-
});
|
|
69077
|
-
return;
|
|
69078
|
-
}
|
|
69079
|
-
if (v && typeof v === "object") {
|
|
69080
|
-
let id = v.id;
|
|
69081
|
-
if (typeof id !== "string" || id.length === 0) return;
|
|
69082
|
-
refs.push({
|
|
69083
|
-
topic_name: v.topic_name || "",
|
|
69084
|
-
id,
|
|
69085
|
-
hook_name: v.hook_name || ""
|
|
69086
|
-
});
|
|
69087
|
-
}
|
|
69088
|
-
};
|
|
69089
|
-
if (value === null || value === void 0) return refs;
|
|
69090
|
-
if (typeof value === "string") {
|
|
69091
|
-
push_one(value);
|
|
69092
|
-
return refs;
|
|
69093
|
-
}
|
|
69094
|
-
if (Array.isArray(value)) {
|
|
69095
|
-
for (let v of value) push_one(v);
|
|
69096
|
-
return refs;
|
|
69097
|
-
}
|
|
69098
|
-
if (typeof value === "object") for (let [key, v] of Object.entries(value)) if (v && typeof v === "object" && typeof v.id === "string") push_one(v);
|
|
69099
|
-
else push_one(key);
|
|
69100
|
-
return refs;
|
|
69101
|
-
}
|
|
69102
|
-
/***************************************************************
|
|
69103
|
-
* The id of the FIRST parent a record names, or "" when it
|
|
69104
|
-
* names none. A record has one parent here — a column belongs to
|
|
69105
|
-
* one topic, a topic to one treedb — and a second ref would be a
|
|
69106
|
-
* store this editor cannot draw anyway.
|
|
69107
|
-
***************************************************************/
|
|
69108
|
-
function parent_id(record, fkey_col) {
|
|
69109
|
-
let refs = parse_fkey_ref(record ? record[fkey_col] : null);
|
|
69110
|
-
return refs.length > 0 ? refs[0].id : "";
|
|
69111
|
-
}
|
|
69112
|
-
/***************************************************************
|
|
69113
|
-
* The name a record is KNOWN by, which is not what it is keyed
|
|
69114
|
-
* by: `topics` and `cols` key by the qualified id and carry the
|
|
69115
|
-
* bare name in `value` (its pkey2). An older store that still
|
|
69116
|
-
* keyed by rowid has no `value` worth showing, so the id stands
|
|
69117
|
-
* in — the same fallback as node_label().
|
|
69118
|
-
***************************************************************/
|
|
69119
|
-
function record_name(record) {
|
|
69120
|
-
if (!record) return "";
|
|
69121
|
-
if (typeof record.value === "string" && record.value.length > 0) return record.value;
|
|
69122
|
-
return typeof record.id === "string" ? record.id : "";
|
|
69123
|
-
}
|
|
69124
|
-
/***************************************************************
|
|
69125
|
-
* The `order` of a record as a NUMBER, whatever the store put
|
|
69126
|
-
* there: an integer column read back from JSON may arrive as a
|
|
69127
|
-
* string, and comparing "10" with 9 sorts the schema wrong.
|
|
69128
|
-
***************************************************************/
|
|
69129
|
-
function record_order(record) {
|
|
69130
|
-
let order = record ? record.order : void 0;
|
|
69131
|
-
if (typeof order === "number" && isFinite(order)) return order;
|
|
69132
|
-
if (typeof order === "string" && order.trim().length > 0) {
|
|
69133
|
-
let n = Number(order);
|
|
69134
|
-
if (isFinite(n)) return n;
|
|
69135
|
-
}
|
|
69136
|
-
return DEFAULT_ORDER;
|
|
69137
|
-
}
|
|
69138
|
-
/***************************************************************
|
|
69139
|
-
* Sort in place by `order`, ties broken by name so a schema
|
|
69140
|
-
* whose records all default to 9999 still draws the same way
|
|
69141
|
-
* twice.
|
|
69142
|
-
***************************************************************/
|
|
69143
|
-
function sort_by_order(list) {
|
|
69144
|
-
list.sort((a, b) => {
|
|
69145
|
-
if (a.order !== b.order) return a.order - b.order;
|
|
69146
|
-
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
69147
|
-
});
|
|
69148
|
-
return list;
|
|
69149
|
-
}
|
|
69150
|
-
/***************************************************************
|
|
69151
|
-
* build_schema_model({treedbs, topics, cols}) -> model
|
|
69152
|
-
*
|
|
69153
|
-
* model = {
|
|
69154
|
-
* treedbs: [{
|
|
69155
|
-
* id, name, record,
|
|
69156
|
-
* schema_version, c_schema_version, system_schema_version,
|
|
69157
|
-
* topics: [{
|
|
69158
|
-
* id, name, order, record,
|
|
69159
|
-
* pkey, topic_version, system_topic, main_topic,
|
|
69160
|
-
* cols: [{id, name, order, record}]
|
|
69161
|
-
* }]
|
|
69162
|
-
* }],
|
|
69163
|
-
* orphan_topics: [...], topics whose treedb is not in the store
|
|
69164
|
-
* orphan_cols: [...] columns whose topic is not in the store
|
|
69165
|
-
* }
|
|
69166
|
-
*
|
|
69167
|
-
* An orphan is NOT dropped. A column whose topic was deleted is
|
|
69168
|
-
* exactly the kind of leftover an operator opens this editor to
|
|
69169
|
-
* find, and a model that hides it makes the editor lie.
|
|
69170
|
-
***************************************************************/
|
|
69171
|
-
function build_schema_model(records) {
|
|
69172
|
-
let r = records || {};
|
|
69173
|
-
let treedb_rows = Array.isArray(r.treedbs) ? r.treedbs : [];
|
|
69174
|
-
let topic_rows = Array.isArray(r.topics) ? r.topics : [];
|
|
69175
|
-
let col_rows = Array.isArray(r.cols) ? r.cols : [];
|
|
69176
|
-
let treedbs = [];
|
|
69177
|
-
let by_treedb_id = {};
|
|
69178
|
-
for (let record of treedb_rows) {
|
|
69179
|
-
if (!record || typeof record.id !== "string") continue;
|
|
69180
|
-
let entry = {
|
|
69181
|
-
id: record.id,
|
|
69182
|
-
name: record.id,
|
|
69183
|
-
record,
|
|
69184
|
-
schema_version: record.schema_version,
|
|
69185
|
-
c_schema_version: record.c_schema_version,
|
|
69186
|
-
system_schema_version: record.system_schema_version,
|
|
69187
|
-
topics: []
|
|
69188
|
-
};
|
|
69189
|
-
treedbs.push(entry);
|
|
69190
|
-
by_treedb_id[entry.id] = entry;
|
|
69191
|
-
}
|
|
69192
|
-
let orphan_topics = [];
|
|
69193
|
-
let by_topic_id = {};
|
|
69194
|
-
for (let record of topic_rows) {
|
|
69195
|
-
if (!record || typeof record.id !== "string") continue;
|
|
69196
|
-
let entry = {
|
|
69197
|
-
id: record.id,
|
|
69198
|
-
name: record_name(record),
|
|
69199
|
-
order: record_order(record),
|
|
69200
|
-
record,
|
|
69201
|
-
treedb_id: parent_id(record, "treedbs"),
|
|
69202
|
-
pkey: record.pkey || "",
|
|
69203
|
-
pkey2s: record.pkey2s,
|
|
69204
|
-
system_flag: record.system_flag || "",
|
|
69205
|
-
tkey: record.tkey || "",
|
|
69206
|
-
topic_version: record.topic_version,
|
|
69207
|
-
system_topic: !!record.system_topic,
|
|
69208
|
-
main_topic: !!record.main_topic,
|
|
69209
|
-
cols: []
|
|
69210
|
-
};
|
|
69211
|
-
by_topic_id[entry.id] = entry;
|
|
69212
|
-
let parent = by_treedb_id[entry.treedb_id];
|
|
69213
|
-
if (parent) parent.topics.push(entry);
|
|
69214
|
-
else orphan_topics.push(entry);
|
|
69215
|
-
}
|
|
69216
|
-
let orphan_cols = [];
|
|
69217
|
-
for (let record of col_rows) {
|
|
69218
|
-
if (!record || typeof record.id !== "string") continue;
|
|
69219
|
-
let entry = {
|
|
69220
|
-
id: record.id,
|
|
69221
|
-
name: record_name(record),
|
|
69222
|
-
order: record_order(record),
|
|
69223
|
-
record,
|
|
69224
|
-
topic_id: parent_id(record, "topics")
|
|
69225
|
-
};
|
|
69226
|
-
let parent = by_topic_id[entry.topic_id];
|
|
69227
|
-
if (parent) parent.cols.push(entry);
|
|
69228
|
-
else orphan_cols.push(entry);
|
|
69229
|
-
}
|
|
69230
|
-
for (let treedb of treedbs) {
|
|
69231
|
-
sort_by_order(treedb.topics);
|
|
69232
|
-
for (let topic of treedb.topics) sort_by_order(topic.cols);
|
|
69233
|
-
}
|
|
69234
|
-
for (let topic of orphan_topics) sort_by_order(topic.cols);
|
|
69235
|
-
treedbs.sort((a, b) => {
|
|
69236
|
-
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
69237
|
-
});
|
|
69238
|
-
sort_by_order(orphan_topics);
|
|
69239
|
-
sort_by_order(orphan_cols);
|
|
69240
|
-
return {
|
|
69241
|
-
treedbs,
|
|
69242
|
-
orphan_topics,
|
|
69243
|
-
orphan_cols
|
|
69244
|
-
};
|
|
69245
|
-
}
|
|
69246
|
-
/***************************************************************
|
|
69247
|
-
* Is there a value here at all?
|
|
69248
|
-
*
|
|
69249
|
-
* The store answers a `blob` column that was never set with an
|
|
69250
|
-
* EMPTY COLLECTION, not with nothing: `enum: {}`, `hook: {}`,
|
|
69251
|
-
* `default: {}`. Read as "a value", those turn every export into
|
|
69252
|
-
* a schema full of empty objects — and `'hook': {}` in a literal
|
|
69253
|
-
* is a hook with no mapping, which is a link the treedb builds
|
|
69254
|
-
* and nothing writes.
|
|
69255
|
-
***************************************************************/
|
|
69256
|
-
function is_empty_value(value) {
|
|
69257
|
-
if (value === null || value === void 0 || value === "") return true;
|
|
69258
|
-
if (Array.isArray(value)) return value.length === 0;
|
|
69259
|
-
if (typeof value === "object") return Object.keys(value).length === 0;
|
|
69260
|
-
return false;
|
|
69261
|
-
}
|
|
69262
|
-
/***************************************************************
|
|
69263
|
-
* The fields of a column that are DECLARED as one thing and
|
|
69264
|
-
* STORED as another.
|
|
69265
|
-
*
|
|
69266
|
-
* `hook`, `enum`, `default`, `pkey2s` and `properties` are `blob`
|
|
69267
|
-
* columns of the `cols` topic (treedb_system_schema.c), so what
|
|
69268
|
-
* comes back may be the value or the JSON text of the value,
|
|
69269
|
-
* depending on how it was written — a projection writes the
|
|
69270
|
-
* value, a hand edit through a plain text field writes the text.
|
|
69271
|
-
* `flag` is an array that a single-flag edit can leave as a bare
|
|
69272
|
-
* string. Reading these without saying so is how a hook drawn
|
|
69273
|
-
* from `{"users":"department"}` becomes no hook at all.
|
|
69274
|
-
***************************************************************/
|
|
69275
|
-
function as_json(value) {
|
|
69276
|
-
if (typeof value !== "string") return value;
|
|
69277
|
-
let text = value.trim();
|
|
69278
|
-
if (text.length === 0) return null;
|
|
69279
|
-
try {
|
|
69280
|
-
return JSON.parse(text);
|
|
69281
|
-
} catch (e) {
|
|
69282
|
-
return value;
|
|
69283
|
-
}
|
|
69284
|
-
}
|
|
69285
|
-
function col_flags(record) {
|
|
69286
|
-
let flag = record ? record.flag : null;
|
|
69287
|
-
if (Array.isArray(flag)) return flag.filter((f) => typeof f === "string" && f.length > 0);
|
|
69288
|
-
if (typeof flag === "string" && flag.length > 0) {
|
|
69289
|
-
let parsed = as_json(flag);
|
|
69290
|
-
if (Array.isArray(parsed)) return parsed.filter((f) => typeof f === "string" && f.length > 0);
|
|
69291
|
-
return [flag];
|
|
69292
|
-
}
|
|
69293
|
-
return [];
|
|
69294
|
-
}
|
|
69295
|
-
function col_hook(record) {
|
|
69296
|
-
let hook = as_json(record ? record.hook : null);
|
|
69297
|
-
if (hook && typeof hook === "object" && !Array.isArray(hook)) return hook;
|
|
69298
|
-
return null;
|
|
69299
|
-
}
|
|
69300
|
-
function col_enum(record) {
|
|
69301
|
-
let e = as_json(record ? record.enum : null);
|
|
69302
|
-
if (Array.isArray(e)) return e;
|
|
69303
|
-
return [];
|
|
69304
|
-
}
|
|
69305
|
-
function topic_pkey2s(record) {
|
|
69306
|
-
let pkey2s = as_json(record ? record.pkey2s : null);
|
|
69307
|
-
if (Array.isArray(pkey2s)) return pkey2s.filter((k) => typeof k === "string" && k.length > 0);
|
|
69308
|
-
if (typeof pkey2s === "string" && pkey2s.length > 0) return [pkey2s];
|
|
69309
|
-
return [];
|
|
69310
|
-
}
|
|
69311
|
-
/***************************************************************
|
|
69312
|
-
* Lookups by id. The editor addresses a position by NAME (the
|
|
69313
|
-
* url carries `<treedb>/<topic>`), so both are offered.
|
|
69314
|
-
***************************************************************/
|
|
69315
|
-
function find_treedb(model, treedb_id) {
|
|
69316
|
-
if (!model || !Array.isArray(model.treedbs)) return null;
|
|
69317
|
-
for (let treedb of model.treedbs) if (treedb.id === treedb_id) return treedb;
|
|
69318
|
-
return null;
|
|
69319
|
-
}
|
|
69320
|
-
function find_topic(model, treedb_id, topic_name) {
|
|
69321
|
-
let treedb = find_treedb(model, treedb_id);
|
|
69322
|
-
if (!treedb) return null;
|
|
69323
|
-
for (let topic of treedb.topics) if (topic.name === topic_name || topic.id === topic_name) return topic;
|
|
69324
|
-
return null;
|
|
69325
|
-
}
|
|
69326
|
-
function find_col(model, treedb_id, topic_name, col_name) {
|
|
69327
|
-
let topic = find_topic(model, treedb_id, topic_name);
|
|
69328
|
-
if (!topic) return null;
|
|
69329
|
-
for (let col of topic.cols) if (col.name === col_name || col.id === col_name) return col;
|
|
69330
|
-
return null;
|
|
69331
|
-
}
|
|
69332
|
-
/***************************************************************
|
|
69333
|
-
* The fkey a NEW record must carry to belong to `parent_id`.
|
|
69334
|
-
* Written here because the editor is the only writer that has to
|
|
69335
|
-
* compose one, and getting the hook name wrong links the record
|
|
69336
|
-
* to nothing while the write still succeeds.
|
|
69337
|
-
***************************************************************/
|
|
69338
|
-
function fkey_ref(parent_topic, parent_id_, hook_name) {
|
|
69339
|
-
return `${parent_topic}^${parent_id_}^${hook_name}`;
|
|
69340
|
-
}
|
|
69341
|
-
/***************************************************************
|
|
69342
|
-
* Where a new sibling goes: after the last one. The store's own
|
|
69343
|
-
* default (9999) would put every hand-made column in the same
|
|
69344
|
-
* place and let their names decide the schema's order.
|
|
69345
|
-
***************************************************************/
|
|
69346
|
-
function next_order(siblings) {
|
|
69347
|
-
let max = 0;
|
|
69348
|
-
if (Array.isArray(siblings)) for (let s of siblings) {
|
|
69349
|
-
let order = s && typeof s.order === "number" ? s.order : DEFAULT_ORDER;
|
|
69350
|
-
if (order < 9999 && order > max) max = order;
|
|
69351
|
-
}
|
|
69352
|
-
return max + 1;
|
|
69353
|
-
}
|
|
69354
|
-
/***************************************************************
|
|
69355
|
-
* Renumber a list AFTER a move, as the writes it takes: only the
|
|
69356
|
-
* records whose order actually changed, so a drag of one row in a
|
|
69357
|
-
* 40-column topic is not 40 writes.
|
|
69358
|
-
*
|
|
69359
|
-
* moved_orders(list) -> [{id, order}, ...]
|
|
69360
|
-
***************************************************************/
|
|
69361
|
-
function moved_orders(list) {
|
|
69362
|
-
let writes = [];
|
|
69363
|
-
if (!Array.isArray(list)) return writes;
|
|
69364
|
-
for (let i = 0; i < list.length; i++) {
|
|
69365
|
-
let entry = list[i];
|
|
69366
|
-
let order = i + 1;
|
|
69367
|
-
if (!entry || typeof entry.id !== "string") continue;
|
|
69368
|
-
if (entry.order !== order) writes.push({
|
|
69369
|
-
id: entry.id,
|
|
69370
|
-
order
|
|
69371
|
-
});
|
|
69372
|
-
}
|
|
69373
|
-
return writes;
|
|
69374
|
-
}
|
|
69375
|
-
//#endregion
|
|
69376
69536
|
//#region src/schema_validate.js
|
|
69377
69537
|
/***********************************************************************
|
|
69378
69538
|
* schema_validate.js
|