@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 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
@@ -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 the array's name; defaults to
70666
- * `treedb_schema_<treedb id without its prefix>`
70667
- * pad column of the `\n\` continuation
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
- return `static char ${name}[]= "\\\n${body.join("\n")}\n";\n`;
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
@@ -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 the array's name; defaults to
70661
- * `treedb_schema_<treedb id without its prefix>`
70662
- * pad column of the `\n\` continuation
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
- return `static char ${name}[]= "\\\n${body.join("\n")}\n";\n`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.196",
3
+ "version": "7.25.0",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -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 literal: single
23
- * quotes, `\n\` continuations, padded, ready
24
- * to paste.
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 the array's name; defaults to
331
- * `treedb_schema_<treedb id without its prefix>`
332
- * pad column of the `\n\` continuation
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
- return `static char ${name}[]= "\\\n${body.join("\n")}\n";\n`;
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
  };
@@ -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 source = schema_to_c(TREEDB);
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
+ });