@yuneta/gobj-ui 7.23.196 → 7.25.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.
- package/README.md +31 -1
- package/dist/gobj-ui.cjs.js +255 -6
- package/dist/gobj-ui.es.js +255 -6
- package/package.json +1 -1
- package/src/schema_to_c.js +49 -9
- package/src/schema_to_c.test.js +21 -3
- package/src/schema_to_diagram.js +301 -0
- package/src/schema_to_diagram.test.js +107 -0
package/README.md
CHANGED
|
@@ -2084,7 +2084,37 @@ The logic is pure and tested apart from the view: `schema_model.js` (the three
|
|
|
2084
2084
|
lists regrouped — grouping follows the **fkey**, not a split of the qualified
|
|
2085
2085
|
id on `.`, which works right up to the first name that carries one),
|
|
2086
2086
|
`schema_validate.js`, `schema_descs.js`, `schema_to_c.js`, `schema_import.js`,
|
|
2087
|
-
`schema_flags.js`, `schema_write_options.js`. Since 6.1.0.
|
|
2087
|
+
`schema_flags.js`, `schema_write_options.js`, `schema_to_diagram.js`. Since 6.1.0.
|
|
2088
|
+
|
|
2089
|
+
**The export is the whole `treedb_schema_<db>.c`.** `schema_to_c()` writes the
|
|
2090
|
+
graph of the schema as a comment — one box per topic, `*` required, `=`
|
|
2091
|
+
inherited, `(2)` pkey2, `(t)` tkey, the hook markers `{}` `[]` `()` and the
|
|
2092
|
+
fkey markers `(↖)` `[↖]` `{↖}`, and a line from each hook's row to the row of
|
|
2093
|
+
the fkey it fills — and then the literal, and that is all such a file holds:
|
|
2094
|
+
an edit made in the editor goes back into the source by replacing the file
|
|
2095
|
+
whole. The graph is `schema_to_diagram()`, derived and never drawn by hand;
|
|
2096
|
+
`json_to_c()` writes the same file from the literal's own JSON (the yunetas
|
|
2097
|
+
script `scripts/schema_diagram.mjs` uses it to keep the files in the repo
|
|
2098
|
+
equal to what an export would give). `with_diagram: false` gives the literal
|
|
2099
|
+
alone. Since 7.23.197.
|
|
2100
|
+
|
|
2101
|
+
```c
|
|
2102
|
+
/*
|
|
2103
|
+
Generated from the literal below by schema_to_diagram() (gobj-ui).
|
|
2104
|
+
...
|
|
2105
|
+
users
|
|
2106
|
+
┌───────────────────────────┐
|
|
2107
|
+
│* id │
|
|
2108
|
+
│ name (2) │
|
|
2109
|
+
│ departments [↖] │ ──────┘
|
|
2110
|
+
└───────────────────────────┘
|
|
2111
|
+
*/
|
|
2112
|
+
|
|
2113
|
+
static char treedb_schema_sample[]= "\
|
|
2114
|
+
{ \n\
|
|
2115
|
+
'id': 'treedb_sample', \n\
|
|
2116
|
+
...
|
|
2117
|
+
```
|
|
2088
2118
|
|
|
2089
2119
|
**A drag can be undone.** Reordering a column is a WRITE — `order` is a field —
|
|
2090
2120
|
so the drop lands in the store the moment you let go. The toolbar grows an
|
package/dist/gobj-ui.cjs.js
CHANGED
|
@@ -70457,6 +70457,230 @@ function topic_descs(treedb) {
|
|
|
70457
70457
|
}
|
|
70458
70458
|
return descs;
|
|
70459
70459
|
}
|
|
70460
|
+
//#endregion
|
|
70461
|
+
//#region src/schema_to_diagram.js
|
|
70462
|
+
/***********************************************************************
|
|
70463
|
+
* schema_to_diagram.js
|
|
70464
|
+
*
|
|
70465
|
+
* The graph of a treedb schema, drawn as text.
|
|
70466
|
+
*
|
|
70467
|
+
* It is the comment that heads every `treedb_schema_<db>.c`: one
|
|
70468
|
+
* box per topic, in the order the schema declares them, a row per
|
|
70469
|
+
* column, and every hook -> fkey link drawn as a line from the
|
|
70470
|
+
* hook's row (`◀`) to the fkey's row. It is DERIVED from the
|
|
70471
|
+
* schema, never drawn by hand, so the file that holds the literal
|
|
70472
|
+
* can be replaced whole with an export and its picture is never
|
|
70473
|
+
* behind it.
|
|
70474
|
+
*
|
|
70475
|
+
* Each link runs down its own LANE to the right of the boxes, the
|
|
70476
|
+
* shorter links nearer to them, so no search is needed to lay it
|
|
70477
|
+
* out: the only ambiguity left is where a line crosses a lane that
|
|
70478
|
+
* is in use, and that is drawn as a junction.
|
|
70479
|
+
*
|
|
70480
|
+
* The input is the schema as its literal holds it (what
|
|
70481
|
+
* schema_to_json() answers, or the parsed literal itself):
|
|
70482
|
+
* {id, schema_version, topics: [{id, pkey2s, tkey, main_topic,
|
|
70483
|
+
* cols: {name: {type, flag, hook}}}]}
|
|
70484
|
+
*
|
|
70485
|
+
* Copyright (c) 2026, ArtGins.
|
|
70486
|
+
* All Rights Reserved.
|
|
70487
|
+
***********************************************************************/
|
|
70488
|
+
var INDENT = 8;
|
|
70489
|
+
var MIN_INNER = 27;
|
|
70490
|
+
var LANE_GAP = 4;
|
|
70491
|
+
var LEGEND = [
|
|
70492
|
+
"{} dict hook (N unique children)",
|
|
70493
|
+
"[] list hook (n not-unique children)",
|
|
70494
|
+
"() string hook (1 unique child)",
|
|
70495
|
+
"(↖) 1 fkey (1 parent)",
|
|
70496
|
+
"[↖] n fkeys (n parents)",
|
|
70497
|
+
"{↖} N fkeys (N parents)",
|
|
70498
|
+
"",
|
|
70499
|
+
"(2) pkey2 - secondary key",
|
|
70500
|
+
"(t) tkey - time key",
|
|
70501
|
+
"* field required",
|
|
70502
|
+
"= field inherited"
|
|
70503
|
+
];
|
|
70504
|
+
var L = 1;
|
|
70505
|
+
var R = 2;
|
|
70506
|
+
var U = 4;
|
|
70507
|
+
var D = 8;
|
|
70508
|
+
var GLYPH = {
|
|
70509
|
+
[3]: "─",
|
|
70510
|
+
[L]: "─",
|
|
70511
|
+
[R]: "─",
|
|
70512
|
+
[12]: "│",
|
|
70513
|
+
[U]: "│",
|
|
70514
|
+
[D]: "│",
|
|
70515
|
+
[10]: "┌",
|
|
70516
|
+
[9]: "┐",
|
|
70517
|
+
[6]: "└",
|
|
70518
|
+
[5]: "┘",
|
|
70519
|
+
[11]: "┬",
|
|
70520
|
+
[7]: "┴",
|
|
70521
|
+
[14]: "├",
|
|
70522
|
+
[13]: "┤",
|
|
70523
|
+
[15]: "┼"
|
|
70524
|
+
};
|
|
70525
|
+
/***************************************************************
|
|
70526
|
+
* A value that may be one string or a list of them, as a list.
|
|
70527
|
+
***************************************************************/
|
|
70528
|
+
function as_list(value) {
|
|
70529
|
+
if (typeof value === "string") return value ? [value] : [];
|
|
70530
|
+
if (Array.isArray(value)) {
|
|
70531
|
+
let list = [];
|
|
70532
|
+
for (let v of value) list.push(...as_list(v));
|
|
70533
|
+
return list;
|
|
70534
|
+
}
|
|
70535
|
+
if (value && typeof value === "object") {
|
|
70536
|
+
let list = [];
|
|
70537
|
+
for (let v of Object.values(value)) list.push(...as_list(v));
|
|
70538
|
+
return list;
|
|
70539
|
+
}
|
|
70540
|
+
return [];
|
|
70541
|
+
}
|
|
70542
|
+
/***************************************************************
|
|
70543
|
+
* The columns of a topic as [name, desc] in declared order: a
|
|
70544
|
+
* dict in the literals, a list of {id} in some stores.
|
|
70545
|
+
***************************************************************/
|
|
70546
|
+
function topic_cols(topic) {
|
|
70547
|
+
let cols = topic.cols || {};
|
|
70548
|
+
if (Array.isArray(cols)) return cols.map((c) => [c.id, c]);
|
|
70549
|
+
return Object.entries(cols);
|
|
70550
|
+
}
|
|
70551
|
+
/***************************************************************
|
|
70552
|
+
* What a link column looks like at its end of the line.
|
|
70553
|
+
***************************************************************/
|
|
70554
|
+
function hook_marker(type) {
|
|
70555
|
+
if (type === "dict" || type === "object") return "{}";
|
|
70556
|
+
if (type === "array" || type === "list") return "[]";
|
|
70557
|
+
return "()";
|
|
70558
|
+
}
|
|
70559
|
+
function fkey_marker(type) {
|
|
70560
|
+
if (type === "dict" || type === "object") return "{↖}";
|
|
70561
|
+
if (type === "array" || type === "list") return "[↖]";
|
|
70562
|
+
return "(↖)";
|
|
70563
|
+
}
|
|
70564
|
+
/***************************************************************
|
|
70565
|
+
* One row of a box: the text inside it, and whether it is a
|
|
70566
|
+
* link column (written against the right edge, where its line
|
|
70567
|
+
* leaves the box).
|
|
70568
|
+
***************************************************************/
|
|
70569
|
+
function col_row(topic, name, desc, pkey2s) {
|
|
70570
|
+
let flags = as_list(desc.flag);
|
|
70571
|
+
if (flags.indexOf("hook") >= 0) return {
|
|
70572
|
+
text: `${name} ${hook_marker(desc.type)}`,
|
|
70573
|
+
link: true
|
|
70574
|
+
};
|
|
70575
|
+
if (flags.indexOf("fkey") >= 0) return {
|
|
70576
|
+
text: `${name} ${fkey_marker(desc.type)}`,
|
|
70577
|
+
link: true
|
|
70578
|
+
};
|
|
70579
|
+
let text = `${flags.indexOf("required") >= 0 ? "*" : flags.indexOf("inherit") >= 0 ? "=" : " "} ${name}`;
|
|
70580
|
+
if (pkey2s.indexOf(name) >= 0) text += " (2)";
|
|
70581
|
+
if (topic.tkey && topic.tkey === name) text += " (t)";
|
|
70582
|
+
return {
|
|
70583
|
+
text,
|
|
70584
|
+
link: false
|
|
70585
|
+
};
|
|
70586
|
+
}
|
|
70587
|
+
/***************************************************************
|
|
70588
|
+
* schema_to_diagram(schema) -> the text, one string with a
|
|
70589
|
+
* newline per line and no trailing spaces.
|
|
70590
|
+
***************************************************************/
|
|
70591
|
+
function schema_to_diagram(schema) {
|
|
70592
|
+
if (!schema || !Array.isArray(schema.topics)) return "";
|
|
70593
|
+
let boxes = [];
|
|
70594
|
+
let inner = MIN_INNER;
|
|
70595
|
+
for (let topic of schema.topics) {
|
|
70596
|
+
let pkey2s = as_list(topic.pkey2s);
|
|
70597
|
+
let rows = topic_cols(topic).map(([name, desc]) => Object.assign({ name }, col_row(topic, name, desc || {}, pkey2s)));
|
|
70598
|
+
for (let row of rows) inner = Math.max(inner, row.text.length + 2);
|
|
70599
|
+
let title = topic.id + (topic.main_topic ? " (main_topic)" : "");
|
|
70600
|
+
inner = Math.max(inner, title.length);
|
|
70601
|
+
boxes.push({
|
|
70602
|
+
topic,
|
|
70603
|
+
title,
|
|
70604
|
+
rows
|
|
70605
|
+
});
|
|
70606
|
+
}
|
|
70607
|
+
let lines = [];
|
|
70608
|
+
let where = {};
|
|
70609
|
+
let edge = INDENT + inner + 1;
|
|
70610
|
+
lines.push(`${schema.id || ""} (schema_version ${schema.schema_version || "?"})`);
|
|
70611
|
+
lines.push("");
|
|
70612
|
+
for (let l of LEGEND) lines.push(l);
|
|
70613
|
+
lines.push("");
|
|
70614
|
+
lines.push("");
|
|
70615
|
+
for (let box of boxes) {
|
|
70616
|
+
let pad = Math.max(0, Math.floor((inner + 2 - box.title.length) / 2));
|
|
70617
|
+
lines.push(" ".repeat(INDENT + pad) + box.title);
|
|
70618
|
+
lines.push(" ".repeat(INDENT) + "┌" + "─".repeat(inner) + "┐");
|
|
70619
|
+
for (let row of box.rows) {
|
|
70620
|
+
let text = row.link ? row.text.padStart(inner - 1) + " " : row.text.padEnd(inner);
|
|
70621
|
+
where[`${box.topic.id}\`${row.name}`] = lines.length;
|
|
70622
|
+
lines.push(" ".repeat(INDENT) + "│" + text + "│");
|
|
70623
|
+
}
|
|
70624
|
+
lines.push(" ".repeat(INDENT) + "└" + "─".repeat(inner) + "┘");
|
|
70625
|
+
lines.push("");
|
|
70626
|
+
}
|
|
70627
|
+
let links = [];
|
|
70628
|
+
for (let topic of schema.topics) for (let [name, desc] of topic_cols(topic)) {
|
|
70629
|
+
desc = desc || {};
|
|
70630
|
+
if (as_list(desc.flag).indexOf("hook") < 0) continue;
|
|
70631
|
+
let hook = desc.hook;
|
|
70632
|
+
if (!hook || typeof hook !== "object" || Array.isArray(hook)) continue;
|
|
70633
|
+
for (let [child_topic, fkey_col] of Object.entries(hook)) {
|
|
70634
|
+
let from = where[`${topic.id}\`${name}`];
|
|
70635
|
+
let to = where[`${child_topic}\`${fkey_col}`];
|
|
70636
|
+
if (from === void 0 || to === void 0) continue;
|
|
70637
|
+
links.push({
|
|
70638
|
+
from,
|
|
70639
|
+
to,
|
|
70640
|
+
top: Math.min(from, to),
|
|
70641
|
+
bottom: Math.max(from, to)
|
|
70642
|
+
});
|
|
70643
|
+
}
|
|
70644
|
+
}
|
|
70645
|
+
links.sort((a, b) => a.bottom - a.top - (b.bottom - b.top) || a.top - b.top);
|
|
70646
|
+
let lanes = [];
|
|
70647
|
+
for (let link of links) {
|
|
70648
|
+
let k = 0;
|
|
70649
|
+
for (;; k++) if ((lanes[k] || []).every((o) => link.bottom < o.top || o.bottom < link.top)) break;
|
|
70650
|
+
lanes[k] = (lanes[k] || []).concat([link]);
|
|
70651
|
+
link.x = edge + LANE_GAP + LANE_GAP * k;
|
|
70652
|
+
}
|
|
70653
|
+
let bits = {};
|
|
70654
|
+
let fixed = {};
|
|
70655
|
+
let join = (y, x, b) => {
|
|
70656
|
+
let key = `${y},${x}`;
|
|
70657
|
+
bits[key] = (bits[key] || 0) | b;
|
|
70658
|
+
};
|
|
70659
|
+
let hline = (y, x1, x2) => {
|
|
70660
|
+
for (let x = x1; x <= x2; x++) join(y, x, (x > x1 ? L : 0) | (x < x2 ? R : 0));
|
|
70661
|
+
};
|
|
70662
|
+
let vline = (x, y1, y2) => {
|
|
70663
|
+
for (let y = y1; y <= y2; y++) join(y, x, (y > y1 ? U : 0) | (y < y2 ? D : 0));
|
|
70664
|
+
};
|
|
70665
|
+
for (let link of links) {
|
|
70666
|
+
hline(link.from, edge + 2, link.x);
|
|
70667
|
+
hline(link.to, edge + 2, link.x);
|
|
70668
|
+
vline(link.x, link.top, link.bottom);
|
|
70669
|
+
fixed[`${link.from},${edge + 2}`] = "◀";
|
|
70670
|
+
}
|
|
70671
|
+
let out = lines.map((line, y) => {
|
|
70672
|
+
let chars = Array.from(line);
|
|
70673
|
+
for (let key of Object.keys(bits)) {
|
|
70674
|
+
let [ky, kx] = key.split(",").map(Number);
|
|
70675
|
+
if (ky !== y) continue;
|
|
70676
|
+
while (chars.length <= kx) chars.push(" ");
|
|
70677
|
+
chars[kx] = fixed[key] || GLYPH[bits[key]] || " ";
|
|
70678
|
+
}
|
|
70679
|
+
return chars.join("").replace(/\s+$/, "");
|
|
70680
|
+
});
|
|
70681
|
+
while (out.length && out[out.length - 1] === "") out.pop();
|
|
70682
|
+
return out.join("\n");
|
|
70683
|
+
}
|
|
70460
70684
|
var TOPIC_KEY_ORDER = [
|
|
70461
70685
|
"id",
|
|
70462
70686
|
"pkey",
|
|
@@ -70661,14 +70885,22 @@ function c_pair(lines, key, value, indent, trailing) {
|
|
|
70661
70885
|
/***************************************************************
|
|
70662
70886
|
* schema_to_c(treedb, options) -> the C source text
|
|
70663
70887
|
*
|
|
70664
|
-
* options {var_name, pad}
|
|
70665
|
-
* var_name
|
|
70666
|
-
*
|
|
70667
|
-
* pad
|
|
70888
|
+
* options {var_name, pad, with_diagram}
|
|
70889
|
+
* var_name the array's name; defaults to
|
|
70890
|
+
* `treedb_schema_<treedb id without its prefix>`
|
|
70891
|
+
* pad column of the `\n\` continuation
|
|
70892
|
+
* with_diagram the graph as a comment above the array
|
|
70893
|
+
* (default true)
|
|
70668
70894
|
***************************************************************/
|
|
70669
70895
|
function schema_to_c(treedb, options) {
|
|
70896
|
+
return json_to_c(schema_to_json(treedb), options);
|
|
70897
|
+
}
|
|
70898
|
+
/***************************************************************
|
|
70899
|
+
* json_to_c(json, options) -> the C source text, from the schema
|
|
70900
|
+
* as its literal holds it. Same options as schema_to_c().
|
|
70901
|
+
***************************************************************/
|
|
70902
|
+
function json_to_c(json, options) {
|
|
70670
70903
|
let opts = options || {};
|
|
70671
|
-
let json = schema_to_json(treedb);
|
|
70672
70904
|
if (!json) return "";
|
|
70673
70905
|
let name = opts.var_name;
|
|
70674
70906
|
if (!name) {
|
|
@@ -70687,7 +70919,24 @@ function schema_to_c(treedb, options) {
|
|
|
70687
70919
|
let body = lines.map((line) => {
|
|
70688
70920
|
return `${line}${line.length < pad ? " ".repeat(pad - line.length) : " "}\\n\\`;
|
|
70689
70921
|
});
|
|
70690
|
-
|
|
70922
|
+
let literal = `static char ${name}[]= "\\\n${body.join("\n")}\n";\n`;
|
|
70923
|
+
if (opts.with_diagram === false) return literal;
|
|
70924
|
+
return c_diagram_comment(schema_to_diagram(json)) + "\n" + literal;
|
|
70925
|
+
}
|
|
70926
|
+
/***************************************************************
|
|
70927
|
+
* The diagram as the comment that heads the file.
|
|
70928
|
+
***************************************************************/
|
|
70929
|
+
function c_diagram_comment(diagram) {
|
|
70930
|
+
let lines = [
|
|
70931
|
+
"/*",
|
|
70932
|
+
" Generated from the literal below by schema_to_diagram() (gobj-ui).",
|
|
70933
|
+
" This file holds this comment and the literal, nothing else: replace",
|
|
70934
|
+
" it whole with an export of the schema editor.",
|
|
70935
|
+
""
|
|
70936
|
+
];
|
|
70937
|
+
for (let line of diagram.split("\n")) lines.push(line ? " " + line : "");
|
|
70938
|
+
lines.push("*/");
|
|
70939
|
+
return lines.join("\n") + "\n";
|
|
70691
70940
|
}
|
|
70692
70941
|
//#endregion
|
|
70693
70942
|
//#region src/schema_import.js
|
package/dist/gobj-ui.es.js
CHANGED
|
@@ -70452,6 +70452,230 @@ function topic_descs(treedb) {
|
|
|
70452
70452
|
}
|
|
70453
70453
|
return descs;
|
|
70454
70454
|
}
|
|
70455
|
+
//#endregion
|
|
70456
|
+
//#region src/schema_to_diagram.js
|
|
70457
|
+
/***********************************************************************
|
|
70458
|
+
* schema_to_diagram.js
|
|
70459
|
+
*
|
|
70460
|
+
* The graph of a treedb schema, drawn as text.
|
|
70461
|
+
*
|
|
70462
|
+
* It is the comment that heads every `treedb_schema_<db>.c`: one
|
|
70463
|
+
* box per topic, in the order the schema declares them, a row per
|
|
70464
|
+
* column, and every hook -> fkey link drawn as a line from the
|
|
70465
|
+
* hook's row (`◀`) to the fkey's row. It is DERIVED from the
|
|
70466
|
+
* schema, never drawn by hand, so the file that holds the literal
|
|
70467
|
+
* can be replaced whole with an export and its picture is never
|
|
70468
|
+
* behind it.
|
|
70469
|
+
*
|
|
70470
|
+
* Each link runs down its own LANE to the right of the boxes, the
|
|
70471
|
+
* shorter links nearer to them, so no search is needed to lay it
|
|
70472
|
+
* out: the only ambiguity left is where a line crosses a lane that
|
|
70473
|
+
* is in use, and that is drawn as a junction.
|
|
70474
|
+
*
|
|
70475
|
+
* The input is the schema as its literal holds it (what
|
|
70476
|
+
* schema_to_json() answers, or the parsed literal itself):
|
|
70477
|
+
* {id, schema_version, topics: [{id, pkey2s, tkey, main_topic,
|
|
70478
|
+
* cols: {name: {type, flag, hook}}}]}
|
|
70479
|
+
*
|
|
70480
|
+
* Copyright (c) 2026, ArtGins.
|
|
70481
|
+
* All Rights Reserved.
|
|
70482
|
+
***********************************************************************/
|
|
70483
|
+
var INDENT = 8;
|
|
70484
|
+
var MIN_INNER = 27;
|
|
70485
|
+
var LANE_GAP = 4;
|
|
70486
|
+
var LEGEND = [
|
|
70487
|
+
"{} dict hook (N unique children)",
|
|
70488
|
+
"[] list hook (n not-unique children)",
|
|
70489
|
+
"() string hook (1 unique child)",
|
|
70490
|
+
"(↖) 1 fkey (1 parent)",
|
|
70491
|
+
"[↖] n fkeys (n parents)",
|
|
70492
|
+
"{↖} N fkeys (N parents)",
|
|
70493
|
+
"",
|
|
70494
|
+
"(2) pkey2 - secondary key",
|
|
70495
|
+
"(t) tkey - time key",
|
|
70496
|
+
"* field required",
|
|
70497
|
+
"= field inherited"
|
|
70498
|
+
];
|
|
70499
|
+
var L = 1;
|
|
70500
|
+
var R = 2;
|
|
70501
|
+
var U = 4;
|
|
70502
|
+
var D = 8;
|
|
70503
|
+
var GLYPH = {
|
|
70504
|
+
[3]: "─",
|
|
70505
|
+
[L]: "─",
|
|
70506
|
+
[R]: "─",
|
|
70507
|
+
[12]: "│",
|
|
70508
|
+
[U]: "│",
|
|
70509
|
+
[D]: "│",
|
|
70510
|
+
[10]: "┌",
|
|
70511
|
+
[9]: "┐",
|
|
70512
|
+
[6]: "└",
|
|
70513
|
+
[5]: "┘",
|
|
70514
|
+
[11]: "┬",
|
|
70515
|
+
[7]: "┴",
|
|
70516
|
+
[14]: "├",
|
|
70517
|
+
[13]: "┤",
|
|
70518
|
+
[15]: "┼"
|
|
70519
|
+
};
|
|
70520
|
+
/***************************************************************
|
|
70521
|
+
* A value that may be one string or a list of them, as a list.
|
|
70522
|
+
***************************************************************/
|
|
70523
|
+
function as_list(value) {
|
|
70524
|
+
if (typeof value === "string") return value ? [value] : [];
|
|
70525
|
+
if (Array.isArray(value)) {
|
|
70526
|
+
let list = [];
|
|
70527
|
+
for (let v of value) list.push(...as_list(v));
|
|
70528
|
+
return list;
|
|
70529
|
+
}
|
|
70530
|
+
if (value && typeof value === "object") {
|
|
70531
|
+
let list = [];
|
|
70532
|
+
for (let v of Object.values(value)) list.push(...as_list(v));
|
|
70533
|
+
return list;
|
|
70534
|
+
}
|
|
70535
|
+
return [];
|
|
70536
|
+
}
|
|
70537
|
+
/***************************************************************
|
|
70538
|
+
* The columns of a topic as [name, desc] in declared order: a
|
|
70539
|
+
* dict in the literals, a list of {id} in some stores.
|
|
70540
|
+
***************************************************************/
|
|
70541
|
+
function topic_cols(topic) {
|
|
70542
|
+
let cols = topic.cols || {};
|
|
70543
|
+
if (Array.isArray(cols)) return cols.map((c) => [c.id, c]);
|
|
70544
|
+
return Object.entries(cols);
|
|
70545
|
+
}
|
|
70546
|
+
/***************************************************************
|
|
70547
|
+
* What a link column looks like at its end of the line.
|
|
70548
|
+
***************************************************************/
|
|
70549
|
+
function hook_marker(type) {
|
|
70550
|
+
if (type === "dict" || type === "object") return "{}";
|
|
70551
|
+
if (type === "array" || type === "list") return "[]";
|
|
70552
|
+
return "()";
|
|
70553
|
+
}
|
|
70554
|
+
function fkey_marker(type) {
|
|
70555
|
+
if (type === "dict" || type === "object") return "{↖}";
|
|
70556
|
+
if (type === "array" || type === "list") return "[↖]";
|
|
70557
|
+
return "(↖)";
|
|
70558
|
+
}
|
|
70559
|
+
/***************************************************************
|
|
70560
|
+
* One row of a box: the text inside it, and whether it is a
|
|
70561
|
+
* link column (written against the right edge, where its line
|
|
70562
|
+
* leaves the box).
|
|
70563
|
+
***************************************************************/
|
|
70564
|
+
function col_row(topic, name, desc, pkey2s) {
|
|
70565
|
+
let flags = as_list(desc.flag);
|
|
70566
|
+
if (flags.indexOf("hook") >= 0) return {
|
|
70567
|
+
text: `${name} ${hook_marker(desc.type)}`,
|
|
70568
|
+
link: true
|
|
70569
|
+
};
|
|
70570
|
+
if (flags.indexOf("fkey") >= 0) return {
|
|
70571
|
+
text: `${name} ${fkey_marker(desc.type)}`,
|
|
70572
|
+
link: true
|
|
70573
|
+
};
|
|
70574
|
+
let text = `${flags.indexOf("required") >= 0 ? "*" : flags.indexOf("inherit") >= 0 ? "=" : " "} ${name}`;
|
|
70575
|
+
if (pkey2s.indexOf(name) >= 0) text += " (2)";
|
|
70576
|
+
if (topic.tkey && topic.tkey === name) text += " (t)";
|
|
70577
|
+
return {
|
|
70578
|
+
text,
|
|
70579
|
+
link: false
|
|
70580
|
+
};
|
|
70581
|
+
}
|
|
70582
|
+
/***************************************************************
|
|
70583
|
+
* schema_to_diagram(schema) -> the text, one string with a
|
|
70584
|
+
* newline per line and no trailing spaces.
|
|
70585
|
+
***************************************************************/
|
|
70586
|
+
function schema_to_diagram(schema) {
|
|
70587
|
+
if (!schema || !Array.isArray(schema.topics)) return "";
|
|
70588
|
+
let boxes = [];
|
|
70589
|
+
let inner = MIN_INNER;
|
|
70590
|
+
for (let topic of schema.topics) {
|
|
70591
|
+
let pkey2s = as_list(topic.pkey2s);
|
|
70592
|
+
let rows = topic_cols(topic).map(([name, desc]) => Object.assign({ name }, col_row(topic, name, desc || {}, pkey2s)));
|
|
70593
|
+
for (let row of rows) inner = Math.max(inner, row.text.length + 2);
|
|
70594
|
+
let title = topic.id + (topic.main_topic ? " (main_topic)" : "");
|
|
70595
|
+
inner = Math.max(inner, title.length);
|
|
70596
|
+
boxes.push({
|
|
70597
|
+
topic,
|
|
70598
|
+
title,
|
|
70599
|
+
rows
|
|
70600
|
+
});
|
|
70601
|
+
}
|
|
70602
|
+
let lines = [];
|
|
70603
|
+
let where = {};
|
|
70604
|
+
let edge = INDENT + inner + 1;
|
|
70605
|
+
lines.push(`${schema.id || ""} (schema_version ${schema.schema_version || "?"})`);
|
|
70606
|
+
lines.push("");
|
|
70607
|
+
for (let l of LEGEND) lines.push(l);
|
|
70608
|
+
lines.push("");
|
|
70609
|
+
lines.push("");
|
|
70610
|
+
for (let box of boxes) {
|
|
70611
|
+
let pad = Math.max(0, Math.floor((inner + 2 - box.title.length) / 2));
|
|
70612
|
+
lines.push(" ".repeat(INDENT + pad) + box.title);
|
|
70613
|
+
lines.push(" ".repeat(INDENT) + "┌" + "─".repeat(inner) + "┐");
|
|
70614
|
+
for (let row of box.rows) {
|
|
70615
|
+
let text = row.link ? row.text.padStart(inner - 1) + " " : row.text.padEnd(inner);
|
|
70616
|
+
where[`${box.topic.id}\`${row.name}`] = lines.length;
|
|
70617
|
+
lines.push(" ".repeat(INDENT) + "│" + text + "│");
|
|
70618
|
+
}
|
|
70619
|
+
lines.push(" ".repeat(INDENT) + "└" + "─".repeat(inner) + "┘");
|
|
70620
|
+
lines.push("");
|
|
70621
|
+
}
|
|
70622
|
+
let links = [];
|
|
70623
|
+
for (let topic of schema.topics) for (let [name, desc] of topic_cols(topic)) {
|
|
70624
|
+
desc = desc || {};
|
|
70625
|
+
if (as_list(desc.flag).indexOf("hook") < 0) continue;
|
|
70626
|
+
let hook = desc.hook;
|
|
70627
|
+
if (!hook || typeof hook !== "object" || Array.isArray(hook)) continue;
|
|
70628
|
+
for (let [child_topic, fkey_col] of Object.entries(hook)) {
|
|
70629
|
+
let from = where[`${topic.id}\`${name}`];
|
|
70630
|
+
let to = where[`${child_topic}\`${fkey_col}`];
|
|
70631
|
+
if (from === void 0 || to === void 0) continue;
|
|
70632
|
+
links.push({
|
|
70633
|
+
from,
|
|
70634
|
+
to,
|
|
70635
|
+
top: Math.min(from, to),
|
|
70636
|
+
bottom: Math.max(from, to)
|
|
70637
|
+
});
|
|
70638
|
+
}
|
|
70639
|
+
}
|
|
70640
|
+
links.sort((a, b) => a.bottom - a.top - (b.bottom - b.top) || a.top - b.top);
|
|
70641
|
+
let lanes = [];
|
|
70642
|
+
for (let link of links) {
|
|
70643
|
+
let k = 0;
|
|
70644
|
+
for (;; k++) if ((lanes[k] || []).every((o) => link.bottom < o.top || o.bottom < link.top)) break;
|
|
70645
|
+
lanes[k] = (lanes[k] || []).concat([link]);
|
|
70646
|
+
link.x = edge + LANE_GAP + LANE_GAP * k;
|
|
70647
|
+
}
|
|
70648
|
+
let bits = {};
|
|
70649
|
+
let fixed = {};
|
|
70650
|
+
let join = (y, x, b) => {
|
|
70651
|
+
let key = `${y},${x}`;
|
|
70652
|
+
bits[key] = (bits[key] || 0) | b;
|
|
70653
|
+
};
|
|
70654
|
+
let hline = (y, x1, x2) => {
|
|
70655
|
+
for (let x = x1; x <= x2; x++) join(y, x, (x > x1 ? L : 0) | (x < x2 ? R : 0));
|
|
70656
|
+
};
|
|
70657
|
+
let vline = (x, y1, y2) => {
|
|
70658
|
+
for (let y = y1; y <= y2; y++) join(y, x, (y > y1 ? U : 0) | (y < y2 ? D : 0));
|
|
70659
|
+
};
|
|
70660
|
+
for (let link of links) {
|
|
70661
|
+
hline(link.from, edge + 2, link.x);
|
|
70662
|
+
hline(link.to, edge + 2, link.x);
|
|
70663
|
+
vline(link.x, link.top, link.bottom);
|
|
70664
|
+
fixed[`${link.from},${edge + 2}`] = "◀";
|
|
70665
|
+
}
|
|
70666
|
+
let out = lines.map((line, y) => {
|
|
70667
|
+
let chars = Array.from(line);
|
|
70668
|
+
for (let key of Object.keys(bits)) {
|
|
70669
|
+
let [ky, kx] = key.split(",").map(Number);
|
|
70670
|
+
if (ky !== y) continue;
|
|
70671
|
+
while (chars.length <= kx) chars.push(" ");
|
|
70672
|
+
chars[kx] = fixed[key] || GLYPH[bits[key]] || " ";
|
|
70673
|
+
}
|
|
70674
|
+
return chars.join("").replace(/\s+$/, "");
|
|
70675
|
+
});
|
|
70676
|
+
while (out.length && out[out.length - 1] === "") out.pop();
|
|
70677
|
+
return out.join("\n");
|
|
70678
|
+
}
|
|
70455
70679
|
var TOPIC_KEY_ORDER = [
|
|
70456
70680
|
"id",
|
|
70457
70681
|
"pkey",
|
|
@@ -70656,14 +70880,22 @@ function c_pair(lines, key, value, indent, trailing) {
|
|
|
70656
70880
|
/***************************************************************
|
|
70657
70881
|
* schema_to_c(treedb, options) -> the C source text
|
|
70658
70882
|
*
|
|
70659
|
-
* options {var_name, pad}
|
|
70660
|
-
* var_name
|
|
70661
|
-
*
|
|
70662
|
-
* pad
|
|
70883
|
+
* options {var_name, pad, with_diagram}
|
|
70884
|
+
* var_name the array's name; defaults to
|
|
70885
|
+
* `treedb_schema_<treedb id without its prefix>`
|
|
70886
|
+
* pad column of the `\n\` continuation
|
|
70887
|
+
* with_diagram the graph as a comment above the array
|
|
70888
|
+
* (default true)
|
|
70663
70889
|
***************************************************************/
|
|
70664
70890
|
function schema_to_c(treedb, options) {
|
|
70891
|
+
return json_to_c(schema_to_json(treedb), options);
|
|
70892
|
+
}
|
|
70893
|
+
/***************************************************************
|
|
70894
|
+
* json_to_c(json, options) -> the C source text, from the schema
|
|
70895
|
+
* as its literal holds it. Same options as schema_to_c().
|
|
70896
|
+
***************************************************************/
|
|
70897
|
+
function json_to_c(json, options) {
|
|
70665
70898
|
let opts = options || {};
|
|
70666
|
-
let json = schema_to_json(treedb);
|
|
70667
70899
|
if (!json) return "";
|
|
70668
70900
|
let name = opts.var_name;
|
|
70669
70901
|
if (!name) {
|
|
@@ -70682,7 +70914,24 @@ function schema_to_c(treedb, options) {
|
|
|
70682
70914
|
let body = lines.map((line) => {
|
|
70683
70915
|
return `${line}${line.length < pad ? " ".repeat(pad - line.length) : " "}\\n\\`;
|
|
70684
70916
|
});
|
|
70685
|
-
|
|
70917
|
+
let literal = `static char ${name}[]= "\\\n${body.join("\n")}\n";\n`;
|
|
70918
|
+
if (opts.with_diagram === false) return literal;
|
|
70919
|
+
return c_diagram_comment(schema_to_diagram(json)) + "\n" + literal;
|
|
70920
|
+
}
|
|
70921
|
+
/***************************************************************
|
|
70922
|
+
* The diagram as the comment that heads the file.
|
|
70923
|
+
***************************************************************/
|
|
70924
|
+
function c_diagram_comment(diagram) {
|
|
70925
|
+
let lines = [
|
|
70926
|
+
"/*",
|
|
70927
|
+
" Generated from the literal below by schema_to_diagram() (gobj-ui).",
|
|
70928
|
+
" This file holds this comment and the literal, nothing else: replace",
|
|
70929
|
+
" it whole with an export of the schema editor.",
|
|
70930
|
+
""
|
|
70931
|
+
];
|
|
70932
|
+
for (let line of diagram.split("\n")) lines.push(line ? " " + line : "");
|
|
70933
|
+
lines.push("*/");
|
|
70934
|
+
return lines.join("\n") + "\n";
|
|
70686
70935
|
}
|
|
70687
70936
|
//#endregion
|
|
70688
70937
|
//#region src/schema_import.js
|
package/package.json
CHANGED
package/src/schema_to_c.js
CHANGED
|
@@ -19,13 +19,19 @@
|
|
|
19
19
|
* Two outputs, because they answer different questions:
|
|
20
20
|
* schema_to_json() — the schema as a value: to diff, to keep, to
|
|
21
21
|
* feed back in.
|
|
22
|
-
* schema_to_c() — the same value as the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* schema_to_c() — the same value as the WHOLE file
|
|
23
|
+
* `treedb_schema_<db>.c`: the graph of the
|
|
24
|
+
* schema as a comment (schema_to_diagram()),
|
|
25
|
+
* then the literal — single quotes, `\n\`
|
|
26
|
+
* continuations, padded. The file holds that
|
|
27
|
+
* and nothing else, so an export replaces it
|
|
28
|
+
* whole. json_to_c() does the same from the
|
|
29
|
+
* literal's own JSON.
|
|
25
30
|
*
|
|
26
31
|
* Copyright (c) 2026, ArtGins.
|
|
27
32
|
* All Rights Reserved.
|
|
28
33
|
***********************************************************************/
|
|
34
|
+
import { schema_to_diagram } from "./schema_to_diagram.js";
|
|
29
35
|
import {
|
|
30
36
|
col_flags,
|
|
31
37
|
col_hook,
|
|
@@ -326,15 +332,25 @@ function c_pair(lines, key, value, indent, trailing)
|
|
|
326
332
|
/***************************************************************
|
|
327
333
|
* schema_to_c(treedb, options) -> the C source text
|
|
328
334
|
*
|
|
329
|
-
* options {var_name, pad}
|
|
330
|
-
* var_name
|
|
331
|
-
*
|
|
332
|
-
* pad
|
|
335
|
+
* options {var_name, pad, with_diagram}
|
|
336
|
+
* var_name the array's name; defaults to
|
|
337
|
+
* `treedb_schema_<treedb id without its prefix>`
|
|
338
|
+
* pad column of the `\n\` continuation
|
|
339
|
+
* with_diagram the graph as a comment above the array
|
|
340
|
+
* (default true)
|
|
333
341
|
***************************************************************/
|
|
334
342
|
function schema_to_c(treedb, options)
|
|
343
|
+
{
|
|
344
|
+
return json_to_c(schema_to_json(treedb), options);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/***************************************************************
|
|
348
|
+
* json_to_c(json, options) -> the C source text, from the schema
|
|
349
|
+
* as its literal holds it. Same options as schema_to_c().
|
|
350
|
+
***************************************************************/
|
|
351
|
+
function json_to_c(json, options)
|
|
335
352
|
{
|
|
336
353
|
let opts = options || {};
|
|
337
|
-
let json = schema_to_json(treedb);
|
|
338
354
|
|
|
339
355
|
if(!json) {
|
|
340
356
|
return "";
|
|
@@ -368,7 +384,30 @@ function schema_to_c(treedb, options)
|
|
|
368
384
|
return `${line}${padding}\\n\\`;
|
|
369
385
|
});
|
|
370
386
|
|
|
371
|
-
|
|
387
|
+
let literal = `static char ${name}[]= "\\\n${body.join("\n")}\n";\n`;
|
|
388
|
+
if(opts.with_diagram === false) {
|
|
389
|
+
return literal;
|
|
390
|
+
}
|
|
391
|
+
return c_diagram_comment(schema_to_diagram(json)) + "\n" + literal;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/***************************************************************
|
|
395
|
+
* The diagram as the comment that heads the file.
|
|
396
|
+
***************************************************************/
|
|
397
|
+
function c_diagram_comment(diagram)
|
|
398
|
+
{
|
|
399
|
+
let lines = [
|
|
400
|
+
"/*",
|
|
401
|
+
" Generated from the literal below by schema_to_diagram() (gobj-ui).",
|
|
402
|
+
" This file holds this comment and the literal, nothing else: replace",
|
|
403
|
+
" it whole with an export of the schema editor.",
|
|
404
|
+
"",
|
|
405
|
+
];
|
|
406
|
+
for(let line of diagram.split("\n")) {
|
|
407
|
+
lines.push(line ? " " + line : "");
|
|
408
|
+
}
|
|
409
|
+
lines.push("*/");
|
|
410
|
+
return lines.join("\n") + "\n";
|
|
372
411
|
}
|
|
373
412
|
|
|
374
413
|
|
|
@@ -378,4 +417,5 @@ export {
|
|
|
378
417
|
COL_KEY_ORDER,
|
|
379
418
|
schema_to_json,
|
|
380
419
|
schema_to_c,
|
|
420
|
+
json_to_c,
|
|
381
421
|
};
|
package/src/schema_to_c.test.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
***********************************************************************/
|
|
13
13
|
import { describe, test, expect } from "vitest";
|
|
14
14
|
import { build_schema_model } from "./schema_model.js";
|
|
15
|
-
import { schema_to_json, schema_to_c } from "./schema_to_c.js";
|
|
15
|
+
import { schema_to_json, schema_to_c, json_to_c } from "./schema_to_c.js";
|
|
16
|
+
import { schema_to_diagram } from "./schema_to_diagram.js";
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
/***************************************************************
|
|
@@ -134,7 +135,24 @@ describe("schema_to_json", () => {
|
|
|
134
135
|
});
|
|
135
136
|
|
|
136
137
|
describe("schema_to_c — the round trip", () => {
|
|
137
|
-
const
|
|
138
|
+
const file = schema_to_c(TREEDB);
|
|
139
|
+
const source = file.slice(file.indexOf("static char"));
|
|
140
|
+
|
|
141
|
+
test("the file is the diagram as a comment, then the literal, nothing else", () => {
|
|
142
|
+
expect(file.startsWith("/*\n")).toBe(true);
|
|
143
|
+
const close = file.indexOf("\n*/\n");
|
|
144
|
+
expect(close).toBeGreaterThan(0);
|
|
145
|
+
expect(file.slice(close + 4)).toBe("\n" + source);
|
|
146
|
+
expect(file).toContain(schema_to_diagram(schema_to_json(TREEDB)).split("\n")[0]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("without the diagram it is the literal alone", () => {
|
|
150
|
+
expect(schema_to_c(TREEDB, {with_diagram: false})).toBe(source);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("json_to_c() writes the same file from the literal's JSON", () => {
|
|
154
|
+
expect(json_to_c(schema_to_json(TREEDB))).toBe(file);
|
|
155
|
+
});
|
|
138
156
|
|
|
139
157
|
test("it is a C array declaration with the conventional name", () => {
|
|
140
158
|
expect(source.startsWith("static char treedb_schema_sample[]= \"\\\n")).toBe(true);
|
|
@@ -153,7 +171,7 @@ describe("schema_to_c — the round trip", () => {
|
|
|
153
171
|
});
|
|
154
172
|
|
|
155
173
|
test("the caller can name the array and set the continuation column", () => {
|
|
156
|
-
const custom = schema_to_c(TREEDB, {var_name: "my_schema", pad: 40});
|
|
174
|
+
const custom = schema_to_c(TREEDB, {var_name: "my_schema", pad: 40, with_diagram: false});
|
|
157
175
|
expect(custom.startsWith("static char my_schema[]=")).toBe(true);
|
|
158
176
|
expect(load_like_the_yuno(custom)).toEqual(schema_to_json(TREEDB));
|
|
159
177
|
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* schema_to_diagram.js
|
|
3
|
+
*
|
|
4
|
+
* The graph of a treedb schema, drawn as text.
|
|
5
|
+
*
|
|
6
|
+
* It is the comment that heads every `treedb_schema_<db>.c`: one
|
|
7
|
+
* box per topic, in the order the schema declares them, a row per
|
|
8
|
+
* column, and every hook -> fkey link drawn as a line from the
|
|
9
|
+
* hook's row (`◀`) to the fkey's row. It is DERIVED from the
|
|
10
|
+
* schema, never drawn by hand, so the file that holds the literal
|
|
11
|
+
* can be replaced whole with an export and its picture is never
|
|
12
|
+
* behind it.
|
|
13
|
+
*
|
|
14
|
+
* Each link runs down its own LANE to the right of the boxes, the
|
|
15
|
+
* shorter links nearer to them, so no search is needed to lay it
|
|
16
|
+
* out: the only ambiguity left is where a line crosses a lane that
|
|
17
|
+
* is in use, and that is drawn as a junction.
|
|
18
|
+
*
|
|
19
|
+
* The input is the schema as its literal holds it (what
|
|
20
|
+
* schema_to_json() answers, or the parsed literal itself):
|
|
21
|
+
* {id, schema_version, topics: [{id, pkey2s, tkey, main_topic,
|
|
22
|
+
* cols: {name: {type, flag, hook}}}]}
|
|
23
|
+
*
|
|
24
|
+
* Copyright (c) 2026, ArtGins.
|
|
25
|
+
* All Rights Reserved.
|
|
26
|
+
***********************************************************************/
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
/* Where the boxes start, and their narrowest inside: the widths of
|
|
30
|
+
* the diagrams drawn by hand before this, so a generated one reads
|
|
31
|
+
* like them. */
|
|
32
|
+
const INDENT = 8;
|
|
33
|
+
const MIN_INNER = 27;
|
|
34
|
+
|
|
35
|
+
/* From a box's right edge to its first lane, and between lanes. */
|
|
36
|
+
const LANE_GAP = 4;
|
|
37
|
+
|
|
38
|
+
const LEGEND = [
|
|
39
|
+
"{} dict hook (N unique children)",
|
|
40
|
+
"[] list hook (n not-unique children)",
|
|
41
|
+
"() string hook (1 unique child)",
|
|
42
|
+
"(↖) 1 fkey (1 parent)",
|
|
43
|
+
"[↖] n fkeys (n parents)",
|
|
44
|
+
"{↖} N fkeys (N parents)",
|
|
45
|
+
"",
|
|
46
|
+
"(2) pkey2 - secondary key",
|
|
47
|
+
"(t) tkey - time key",
|
|
48
|
+
"* field required",
|
|
49
|
+
"= field inherited",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/* A cell of a line is a set of the directions it joins; the glyph is
|
|
53
|
+
* what those directions draw. */
|
|
54
|
+
const L = 1, R = 2, U = 4, D = 8;
|
|
55
|
+
const GLYPH = {
|
|
56
|
+
[L|R]: "─", [L]: "─", [R]: "─",
|
|
57
|
+
[U|D]: "│", [U]: "│", [D]: "│",
|
|
58
|
+
[R|D]: "┌", [L|D]: "┐", [R|U]: "└", [L|U]: "┘",
|
|
59
|
+
[L|R|D]: "┬", [L|R|U]: "┴", [U|D|R]: "├", [U|D|L]: "┤",
|
|
60
|
+
[L|R|U|D]: "┼",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
/***************************************************************
|
|
65
|
+
* A value that may be one string or a list of them, as a list.
|
|
66
|
+
***************************************************************/
|
|
67
|
+
function as_list(value)
|
|
68
|
+
{
|
|
69
|
+
if(typeof value === "string") {
|
|
70
|
+
return value ? [value] : [];
|
|
71
|
+
}
|
|
72
|
+
if(Array.isArray(value)) {
|
|
73
|
+
let list = [];
|
|
74
|
+
for(let v of value) {
|
|
75
|
+
list.push(...as_list(v));
|
|
76
|
+
}
|
|
77
|
+
return list;
|
|
78
|
+
}
|
|
79
|
+
if(value && typeof value === "object") {
|
|
80
|
+
let list = [];
|
|
81
|
+
for(let v of Object.values(value)) {
|
|
82
|
+
list.push(...as_list(v));
|
|
83
|
+
}
|
|
84
|
+
return list;
|
|
85
|
+
}
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/***************************************************************
|
|
90
|
+
* The columns of a topic as [name, desc] in declared order: a
|
|
91
|
+
* dict in the literals, a list of {id} in some stores.
|
|
92
|
+
***************************************************************/
|
|
93
|
+
function topic_cols(topic)
|
|
94
|
+
{
|
|
95
|
+
let cols = topic.cols || {};
|
|
96
|
+
if(Array.isArray(cols)) {
|
|
97
|
+
return cols.map((c) => [c.id, c]);
|
|
98
|
+
}
|
|
99
|
+
return Object.entries(cols);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/***************************************************************
|
|
103
|
+
* What a link column looks like at its end of the line.
|
|
104
|
+
***************************************************************/
|
|
105
|
+
function hook_marker(type)
|
|
106
|
+
{
|
|
107
|
+
if(type === "dict" || type === "object") {
|
|
108
|
+
return "{}";
|
|
109
|
+
}
|
|
110
|
+
if(type === "array" || type === "list") {
|
|
111
|
+
return "[]";
|
|
112
|
+
}
|
|
113
|
+
return "()";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fkey_marker(type)
|
|
117
|
+
{
|
|
118
|
+
if(type === "dict" || type === "object") {
|
|
119
|
+
return "{↖}";
|
|
120
|
+
}
|
|
121
|
+
if(type === "array" || type === "list") {
|
|
122
|
+
return "[↖]";
|
|
123
|
+
}
|
|
124
|
+
return "(↖)";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/***************************************************************
|
|
128
|
+
* One row of a box: the text inside it, and whether it is a
|
|
129
|
+
* link column (written against the right edge, where its line
|
|
130
|
+
* leaves the box).
|
|
131
|
+
***************************************************************/
|
|
132
|
+
function col_row(topic, name, desc, pkey2s)
|
|
133
|
+
{
|
|
134
|
+
let flags = as_list(desc.flag);
|
|
135
|
+
if(flags.indexOf("hook") >= 0) {
|
|
136
|
+
return {text: `${name} ${hook_marker(desc.type)}`, link: true};
|
|
137
|
+
}
|
|
138
|
+
if(flags.indexOf("fkey") >= 0) {
|
|
139
|
+
return {text: `${name} ${fkey_marker(desc.type)}`, link: true};
|
|
140
|
+
}
|
|
141
|
+
let mark = flags.indexOf("required") >= 0 ? "*" :
|
|
142
|
+
flags.indexOf("inherit") >= 0 ? "=" : " ";
|
|
143
|
+
let text = `${mark} ${name}`;
|
|
144
|
+
if(pkey2s.indexOf(name) >= 0) {
|
|
145
|
+
text += " (2)";
|
|
146
|
+
}
|
|
147
|
+
if(topic.tkey && topic.tkey === name) {
|
|
148
|
+
text += " (t)";
|
|
149
|
+
}
|
|
150
|
+
return {text: text, link: false};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/***************************************************************
|
|
154
|
+
* schema_to_diagram(schema) -> the text, one string with a
|
|
155
|
+
* newline per line and no trailing spaces.
|
|
156
|
+
***************************************************************/
|
|
157
|
+
function schema_to_diagram(schema)
|
|
158
|
+
{
|
|
159
|
+
if(!schema || !Array.isArray(schema.topics)) {
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/*
|
|
164
|
+
* The boxes, and where each column landed
|
|
165
|
+
*/
|
|
166
|
+
let boxes = [];
|
|
167
|
+
let inner = MIN_INNER;
|
|
168
|
+
for(let topic of schema.topics) {
|
|
169
|
+
let pkey2s = as_list(topic.pkey2s);
|
|
170
|
+
let rows = topic_cols(topic).map(([name, desc]) =>
|
|
171
|
+
Object.assign({name: name}, col_row(topic, name, desc || {}, pkey2s))
|
|
172
|
+
);
|
|
173
|
+
for(let row of rows) {
|
|
174
|
+
inner = Math.max(inner, row.text.length + 2);
|
|
175
|
+
}
|
|
176
|
+
let title = topic.id + (topic.main_topic ? " (main_topic)" : "");
|
|
177
|
+
inner = Math.max(inner, title.length);
|
|
178
|
+
boxes.push({topic: topic, title: title, rows: rows});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let lines = [];
|
|
182
|
+
let where = {}; /* "topic`col" -> line index */
|
|
183
|
+
let edge = INDENT + inner + 1;
|
|
184
|
+
|
|
185
|
+
lines.push(`${schema.id || ""} (schema_version ${schema.schema_version || "?"})`);
|
|
186
|
+
lines.push("");
|
|
187
|
+
for(let l of LEGEND) {
|
|
188
|
+
lines.push(l);
|
|
189
|
+
}
|
|
190
|
+
lines.push("");
|
|
191
|
+
lines.push("");
|
|
192
|
+
|
|
193
|
+
for(let box of boxes) {
|
|
194
|
+
let pad = Math.max(0, Math.floor((inner + 2 - box.title.length) / 2));
|
|
195
|
+
lines.push(" ".repeat(INDENT + pad) + box.title);
|
|
196
|
+
lines.push(" ".repeat(INDENT) + "┌" + "─".repeat(inner) + "┐");
|
|
197
|
+
for(let row of box.rows) {
|
|
198
|
+
let text = row.link ?
|
|
199
|
+
row.text.padStart(inner - 1) + " " :
|
|
200
|
+
row.text.padEnd(inner);
|
|
201
|
+
where[`${box.topic.id}\`${row.name}`] = lines.length;
|
|
202
|
+
lines.push(" ".repeat(INDENT) + "│" + text + "│");
|
|
203
|
+
}
|
|
204
|
+
lines.push(" ".repeat(INDENT) + "└" + "─".repeat(inner) + "┘");
|
|
205
|
+
lines.push("");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/*
|
|
209
|
+
* The links: from a hook's row to the row of the fkey it fills
|
|
210
|
+
*/
|
|
211
|
+
let links = [];
|
|
212
|
+
for(let topic of schema.topics) {
|
|
213
|
+
for(let [name, desc] of topic_cols(topic)) {
|
|
214
|
+
desc = desc || {};
|
|
215
|
+
if(as_list(desc.flag).indexOf("hook") < 0) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
let hook = desc.hook;
|
|
219
|
+
if(!hook || typeof hook !== "object" || Array.isArray(hook)) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
for(let [child_topic, fkey_col] of Object.entries(hook)) {
|
|
223
|
+
let from = where[`${topic.id}\`${name}`];
|
|
224
|
+
let to = where[`${child_topic}\`${fkey_col}`];
|
|
225
|
+
if(from === undefined || to === undefined) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
links.push({from: from, to: to,
|
|
229
|
+
top: Math.min(from, to), bottom: Math.max(from, to)});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* Shorter links nearer the boxes: a lane holds links whose rows
|
|
235
|
+
* do not overlap. */
|
|
236
|
+
links.sort((a, b) => (a.bottom - a.top) - (b.bottom - b.top) || a.top - b.top);
|
|
237
|
+
let lanes = [];
|
|
238
|
+
for(let link of links) {
|
|
239
|
+
let k = 0;
|
|
240
|
+
for(;; k++) {
|
|
241
|
+
let used = lanes[k] || [];
|
|
242
|
+
let free = used.every((o) => link.bottom < o.top || o.bottom < link.top);
|
|
243
|
+
if(free) {
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
lanes[k] = (lanes[k] || []).concat([link]);
|
|
248
|
+
link.x = edge + LANE_GAP + LANE_GAP * k;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/*
|
|
252
|
+
* Draw them on a grid of direction bits, then as glyphs
|
|
253
|
+
*/
|
|
254
|
+
let bits = {};
|
|
255
|
+
let fixed = {};
|
|
256
|
+
let join = (y, x, b) => {
|
|
257
|
+
let key = `${y},${x}`;
|
|
258
|
+
bits[key] = (bits[key] || 0) | b;
|
|
259
|
+
};
|
|
260
|
+
let hline = (y, x1, x2) => {
|
|
261
|
+
for(let x = x1; x <= x2; x++) {
|
|
262
|
+
join(y, x, (x > x1 ? L : 0) | (x < x2 ? R : 0));
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
let vline = (x, y1, y2) => {
|
|
266
|
+
for(let y = y1; y <= y2; y++) {
|
|
267
|
+
join(y, x, (y > y1 ? U : 0) | (y < y2 ? D : 0));
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
for(let link of links) {
|
|
271
|
+
hline(link.from, edge + 2, link.x);
|
|
272
|
+
hline(link.to, edge + 2, link.x);
|
|
273
|
+
vline(link.x, link.top, link.bottom);
|
|
274
|
+
fixed[`${link.from},${edge + 2}`] = "◀";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let out = lines.map((line, y) => {
|
|
278
|
+
let chars = Array.from(line);
|
|
279
|
+
for(let key of Object.keys(bits)) {
|
|
280
|
+
let [ky, kx] = key.split(",").map(Number);
|
|
281
|
+
if(ky !== y) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
while(chars.length <= kx) {
|
|
285
|
+
chars.push(" ");
|
|
286
|
+
}
|
|
287
|
+
chars[kx] = fixed[key] || GLYPH[bits[key]] || " ";
|
|
288
|
+
}
|
|
289
|
+
return chars.join("").replace(/\s+$/, "");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
while(out.length && out[out.length - 1] === "") {
|
|
293
|
+
out.pop();
|
|
294
|
+
}
|
|
295
|
+
return out.join("\n");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
export {
|
|
300
|
+
schema_to_diagram,
|
|
301
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* schema_to_diagram.test.js
|
|
3
|
+
*
|
|
4
|
+
* The graph of a schema as text, pinned on a small schema that has
|
|
5
|
+
* every case: a self-referent hook, a hook to another topic, one
|
|
6
|
+
* hook filling two topics, a pkey2, a tkey, required and inherited
|
|
7
|
+
* fields, and the main topic.
|
|
8
|
+
***********************************************************************/
|
|
9
|
+
import { describe, test, expect } from "vitest";
|
|
10
|
+
import { schema_to_diagram } from "./schema_to_diagram.js";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const SCHEMA = {
|
|
14
|
+
id: "treedb_sample",
|
|
15
|
+
schema_version: "4",
|
|
16
|
+
topics: [
|
|
17
|
+
{
|
|
18
|
+
id: "departments",
|
|
19
|
+
pkey: "id",
|
|
20
|
+
main_topic: true,
|
|
21
|
+
cols: {
|
|
22
|
+
id: {type: "string", flag: ["persistent", "required"]},
|
|
23
|
+
departments: {type: "dict", flag: ["hook"], hook: {departments: "department_id"}},
|
|
24
|
+
department_id: {type: "string", flag: ["fkey"]},
|
|
25
|
+
users: {type: "dict", flag: ["hook"], hook: {users: "departments"}},
|
|
26
|
+
managers: {type: "list", flag: ["hook"],
|
|
27
|
+
hook: {users: "manager", departments: "manager"}},
|
|
28
|
+
manager: {type: "array", flag: ["fkey"]},
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "users",
|
|
33
|
+
pkey: "id",
|
|
34
|
+
pkey2s: "name",
|
|
35
|
+
tkey: "tm",
|
|
36
|
+
cols: {
|
|
37
|
+
id: {type: "string", flag: "required"},
|
|
38
|
+
name: {type: "string", flag: ["persistent"]},
|
|
39
|
+
enabled: {type: "boolean", flag: ["persistent", "inherit"]},
|
|
40
|
+
tm: {type: "integer", flag: ["time"]},
|
|
41
|
+
departments: {type: "array", flag: ["fkey"]},
|
|
42
|
+
manager: {type: "array", flag: ["fkey"]},
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
]
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const EXPECTED = `treedb_sample (schema_version 4)
|
|
49
|
+
|
|
50
|
+
{} dict hook (N unique children)
|
|
51
|
+
[] list hook (n not-unique children)
|
|
52
|
+
() string hook (1 unique child)
|
|
53
|
+
(↖) 1 fkey (1 parent)
|
|
54
|
+
[↖] n fkeys (n parents)
|
|
55
|
+
{↖} N fkeys (N parents)
|
|
56
|
+
|
|
57
|
+
(2) pkey2 - secondary key
|
|
58
|
+
(t) tkey - time key
|
|
59
|
+
* field required
|
|
60
|
+
= field inherited
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
departments (main_topic)
|
|
64
|
+
┌───────────────────────────┐
|
|
65
|
+
│* id │
|
|
66
|
+
│ departments {} │ ◀─┐
|
|
67
|
+
│ department_id (↖) │ ──┘
|
|
68
|
+
│ users {} │ ◀─────┐
|
|
69
|
+
│ managers [] │ ◀─┬───┼───┐
|
|
70
|
+
│ manager [↖] │ ──┘ │ │
|
|
71
|
+
└───────────────────────────┘ │ │
|
|
72
|
+
│ │
|
|
73
|
+
users │ │
|
|
74
|
+
┌───────────────────────────┐ │ │
|
|
75
|
+
│* id │ │ │
|
|
76
|
+
│ name (2) │ │ │
|
|
77
|
+
│= enabled │ │ │
|
|
78
|
+
│ tm (t) │ │ │
|
|
79
|
+
│ departments [↖] │ ──────┘ │
|
|
80
|
+
│ manager [↖] │ ──────────┘
|
|
81
|
+
└───────────────────────────┘`;
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
describe("schema_to_diagram", () => {
|
|
85
|
+
const diagram = schema_to_diagram(SCHEMA);
|
|
86
|
+
|
|
87
|
+
test("the whole picture", () => {
|
|
88
|
+
expect(diagram).toBe(EXPECTED);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("no line ends in a space", () => {
|
|
92
|
+
for(const line of diagram.split("\n")) {
|
|
93
|
+
expect(line).toBe(line.trimEnd());
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a link to a column that does not exist is not drawn", () => {
|
|
98
|
+
const broken = JSON.parse(JSON.stringify(SCHEMA));
|
|
99
|
+
broken.topics[0].cols.users.hook = {users: "nowhere"};
|
|
100
|
+
expect(schema_to_diagram(broken)).not.toContain("◀─────┐");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("nothing to draw is an empty string, not a throw", () => {
|
|
104
|
+
expect(schema_to_diagram(null)).toBe("");
|
|
105
|
+
expect(schema_to_diagram({id: "x"})).toBe("");
|
|
106
|
+
});
|
|
107
|
+
});
|