@yuneta/gobj-ui 7.23.141 → 7.23.142

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
@@ -1294,8 +1294,8 @@ a level or two, with a count on every cut.
1294
1294
  parent shows it or it is a shown root — the **union**, so a device that hangs
1295
1295
  from its place AND its controller is drawn once, under whichever opened
1296
1296
  first, and folding one of the two leaves it where the other shows it.
1297
- - **`expand_depth`** (default `2`) is how much opens on load: the roots and
1298
- their children. **`fold_page_size`** (default `24`) is how many children of
1297
+ - **`expand_depth`** (default `1` since `7.23.142`, `2` before) is how much
1298
+ opens on load until the reader steps: the roots alone. **`fold_page_size`** (default `24`) is how many children of
1299
1299
  one hook are shown at a time; the rest is a **`+N` chip** at the end of the
1300
1300
  page that opens the next one. Both are attrs of `C_YUI_TREEDB_GRAPH`,
1301
1301
  forwarded to the engine — and since `7.23.93` so are the seven the engine
@@ -1596,18 +1596,31 @@ them:
1596
1596
  | control | what it does |
1597
1597
  |---|---|
1598
1598
  | the chip body | **show / hide** the topic. A hidden topic leaves the MODEL, not just the drawing: no card, no pill counts it, no edge reaches it, and whatever hung from it alone has no parent any more. The name is struck through and the count is the total. |
1599
- | `★` / `☆` | the **main topic**: the trunk the tree hangs from. Filled on the main one (a mark, not a button — the main topic cannot be hidden, because hiding the trunk turns everything into roots, which is the pile); hollow on the others, where it moves the star. |
1599
+ | `★` / `☆` | the **main topic**: the trunk the tree hangs from, always a **hierarchical** topic (hooked to itself); the hollow star appears only on the other hierarchical topics, and only when there are two or more (`7.23.142`). Filled on the main one (a mark, not a button — the main topic cannot be hidden, because hiding the trunk turns everything into roots, which is the pile); hollow on the others, where it moves the star. |
1600
1600
  | `+N` | the topic's **loose records**: the ones that should hang from the main tree and do not (a device with no place). Counted here, shown only on request. |
1601
1601
  | `⌖` | **highlight** the topic — the focus, with `reveal: "all"`. |
1602
1602
 
1603
- The **main topic is deduced** when none is chosen: the topic whose hooks reach
1604
- the most OTHER topics, a self-referent hook breaking a tie (a tree of places
1605
- over a flat list of groups). On the yunovatios central that is `places` (it
1606
- reaches places, devices, users and controllers); `device_types` reaches one.
1607
- An `extended` topic (hooks, no fkeys) never wins: the engine draws no edge
1608
- from one, so its records hang nothing. A treedb where no topic reaches
1609
- another has no trunk, and every topic is a tree of its own — the behaviour
1610
- before this existed.
1603
+ **Only a HIERARCHICAL topic can be the main one** (since `7.23.142`): a topic
1604
+ hooked to ITSELF — places inside places, groups inside groups, roles inside
1605
+ roles (`fold_is_hierarchical()`). A topic that only reaches OTHER topics is a
1606
+ list with children, and the old rule (*the topic whose hooks reach the most
1607
+ others*) drew the agent's treedb from `yunos` instead of `realms`. In order:
1608
+
1609
+ 1. the reader's pick (the star), if it is hierarchical — a saved pick the rule
1610
+ refuses is ignored;
1611
+ 2. the topic the schema **marks**, `main_topic: true` on the topic, sent in the
1612
+ desc by SDK 7.19+ (`tranger2_topic_desc()`); the schema allows one, and only
1613
+ on a topic hooked to itself;
1614
+ 3. the hierarchical topic that reaches the most other topics, schema order
1615
+ breaking a tie.
1616
+
1617
+ In every treedb of the SDK and the apps there is exactly one hierarchical
1618
+ topic (`places`, `device_groups`, `systems`, `roles`, `realms`), so the mark
1619
+ only has something to decide once a schema grows a second one. A treedb with
1620
+ no topic hooked to itself has no trunk, and every topic is a tree of its own.
1621
+
1622
+ (Not the engine's card tier: a topic with hooks AND fkeys is an `entity` card
1623
+ — it was called `hierarchical` until `7.23.142` — and that only sizes a card.)
1611
1624
 
1612
1625
  What the main topic governs, in `treedb_fold_model.js`:
1613
1626
 
@@ -27675,7 +27675,9 @@ function fold_build_model(descs, records, opts) {
27675
27675
  opts = (0, _yuneta_gobj_js.is_object)(opts) ? opts : {};
27676
27676
  let hidden = new Set((0, _yuneta_gobj_js.is_array)(opts.hidden_topics) ? opts.hidden_topics : []);
27677
27677
  let loose_shown = new Set((0, _yuneta_gobj_js.is_array)(opts.loose_topics) ? opts.loose_topics : []);
27678
- let main_topic = opts.main_topic || fold_main_topic(descs);
27678
+ let main_topic = opts.main_topic || "";
27679
+ if (main_topic && !fold_is_hierarchical(descs, main_topic)) main_topic = "";
27680
+ if (!main_topic) main_topic = fold_main_topic(descs);
27679
27681
  if (main_topic && (!descs[main_topic] || hidden.has(main_topic))) main_topic = "";
27680
27682
  let linked = fold_linked_topics(descs, main_topic);
27681
27683
  let totals = {};
@@ -27796,28 +27798,49 @@ function count_levels(model) {
27796
27798
  model.levels = deepest;
27797
27799
  }
27798
27800
  /************************************************************
27799
- * The topic the schema hangs the others from: the one whose hooks
27800
- * reach the most OTHER topics, a self-referent hook breaking a tie
27801
- * (a tree of places over a flat list of groups), schema order
27802
- * breaking the rest. "" when no topic reaches another -- a treedb of
27803
- * unrelated lists has no trunk, and every topic is a tree of its own.
27801
+ * A HIERARCHICAL topic: one hooked to ITSELF -- places inside
27802
+ * places, groups inside groups, roles inside roles. It is the
27803
+ * only kind of topic a tree can hang from: a topic that only
27804
+ * reaches OTHER topics is a list with children, and making it
27805
+ * the trunk drew the agent's treedb from `yunos` instead of
27806
+ * `realms`.
27807
+ *
27808
+ * (Not the engine's `entity` tier, which is any topic with
27809
+ * hooks AND fkeys: that one sizes a card and says nothing about
27810
+ * where a tree starts.)
27811
+ ************************************************************/
27812
+ function fold_is_hierarchical(descs, topic_name) {
27813
+ if (!(0, _yuneta_gobj_js.is_object)(descs) || !descs[topic_name]) return false;
27814
+ return hook_targets(descs, topic_name).has(topic_name);
27815
+ }
27816
+ /************************************************************
27817
+ * The topic the tree hangs from. Only a HIERARCHICAL topic can
27818
+ * be it, and in this order:
27819
+ *
27820
+ * the one the schema MARKS (`main_topic: true` on the topic,
27821
+ * sent by the backend in its desc since SDK 7.19; the first
27822
+ * one in schema order if a schema marks two);
27823
+ * else the one whose hooks reach the most OTHER topics,
27824
+ * schema order breaking a tie.
27825
+ *
27826
+ * "" when no topic is hooked to itself: there is no trunk, and
27827
+ * every topic is a tree of its own. (A pick by the reader comes
27828
+ * first of all, and is fold_build_model's business.)
27804
27829
  ************************************************************/
27805
27830
  function fold_main_topic(descs) {
27806
27831
  descs = (0, _yuneta_gobj_js.is_object)(descs) ? descs : {};
27832
+ let candidates = Object.keys(descs).filter((topic_name) => {
27833
+ return topic_name.substring(0, 2) !== "__" && fold_is_hierarchical(descs, topic_name);
27834
+ });
27835
+ for (let topic_name of candidates) if (descs[topic_name].main_topic === true) return topic_name;
27807
27836
  let best = "";
27808
- let best_score = 0;
27809
- let best_self = false;
27810
- for (let topic_name of Object.keys(descs)) {
27811
- if (topic_name.substring(0, 2) === "__") continue;
27837
+ let best_score = -1;
27838
+ for (let topic_name of candidates) {
27812
27839
  let reach = hook_targets(descs, topic_name);
27813
- let self = reach.has(topic_name);
27814
27840
  reach.delete(topic_name);
27815
- let score = reach.size;
27816
- if (score === 0) continue;
27817
- if (score > best_score || score === best_score && self && !best_self) {
27841
+ if (reach.size > best_score) {
27818
27842
  best = topic_name;
27819
- best_score = score;
27820
- best_self = self;
27843
+ best_score = reach.size;
27821
27844
  }
27822
27845
  }
27823
27846
  return best;
@@ -55303,13 +55326,13 @@ var NODE_MODES$1 = [
55303
55326
  "shape"
55304
55327
  ];
55305
55328
  var PILL_SIZE = {
55306
- hierarchical: [200, 30],
55329
+ entity: [200, 30],
55307
55330
  extended: [180, 28],
55308
55331
  child: [160, 26]
55309
55332
  };
55310
55333
  var PORT_R_PILL = 6;
55311
55334
  var SHAPE_SIZE = {
55312
- hierarchical: 32,
55335
+ entity: 32,
55313
55336
  extended: 28,
55314
55337
  child: 22
55315
55338
  };
@@ -55397,7 +55420,7 @@ var attrs_table$4 = [
55397
55420
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_LIST, "layout_names", _yuneta_gobj_js.sdata_flag_t.SDF_RD, JSON.stringify(Object.keys(_layouts)), "Available layout names (read-only, for parent to query)"),
55398
55421
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "hook_port_position", 0, "bottom", "Hook port position"),
55399
55422
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "fkey_port_position", 0, "top", "Fkey port position"),
55400
- (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_INTEGER, "expand_depth", 0, 2, "Levels open when a treedb loads, until the reader steps (`fold_level`): 1 = the roots alone, 2 = the roots and their children. Every open hook shows its first page"),
55423
+ (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_INTEGER, "expand_depth", 0, 1, "Levels open when a treedb loads, until the reader steps (`fold_level`): 1 = the roots alone (the default), 2 = the roots and their children. Every open hook shows its first page"),
55401
55424
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_INTEGER, "fold_level", 0, 0, "The level of the MAIN tree the toolbar's stepper stands on: 1 = its roots, N = N-1 levels under them; 0 = `expand_depth`. The host persists it and sends EV_SET_FOLD_LEVEL; it is published back in EV_LEGEND_STATE (`fold`) with the levels the tree has"),
55402
55425
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of one hook shown per page; the `+N` chip after the page opens the next one"),
55403
55426
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_LIST, "hidden_topics", 0, "[]", "Topics left out of the tree: no card, no pill counts them, no edge reaches them"),
@@ -55489,7 +55512,7 @@ var PRIVATE_DATA$4 = {
55489
55512
  _layout_asked: "",
55490
55513
  _nodes_total: 0,
55491
55514
  _nodes_placed: 0,
55492
- expand_depth: 2,
55515
+ expand_depth: 1,
55493
55516
  fold_level: 0,
55494
55517
  fold_page_size: 24,
55495
55518
  hidden_topics: null,
@@ -56213,7 +56236,12 @@ function configure_behaviour(gobj) {
56213
56236
  graph_write_behaviors(gobj, behaviors);
56214
56237
  }
56215
56238
  /************************************************************
56216
- * Count hooks and fkeys in topic desc, classify node type
56239
+ * Count hooks and fkeys in topic desc, classify node type:
56240
+ * `child` (no hooks), `extended` (hooks, no fkeys), `entity`
56241
+ * (both). It sizes the card and nothing else. It used to be
56242
+ * called `hierarchical`, and that word means something else now:
56243
+ * a topic hooked to ITSELF, the only kind a tree can hang from
56244
+ * (treedb_fold_model).
56217
56245
  ************************************************************/
56218
56246
  function calculate_hooks_fkeys_counter(desc) {
56219
56247
  let cols = desc.cols;
@@ -56230,7 +56258,7 @@ function calculate_hooks_fkeys_counter(desc) {
56230
56258
  }
56231
56259
  if (desc.hooks_counter === 0) desc.node_treedb_type = "child";
56232
56260
  else if (desc.fkeys_counter === 0) desc.node_treedb_type = "extended";
56233
- else desc.node_treedb_type = "hierarchical";
56261
+ else desc.node_treedb_type = "entity";
56234
56262
  }
56235
56263
  /************************************************************
56236
56264
  * Build ports for a topic desc
@@ -56603,7 +56631,7 @@ function dress_ports(gobj, style, ports, geometry, topic_defaults, r_default, fi
56603
56631
  ************************************************************/
56604
56632
  function pill_shape_of(gobj, desc, record, geometry) {
56605
56633
  let priv = gobj.priv;
56606
- let size = PILL_SIZE[desc.node_treedb_type] || PILL_SIZE.hierarchical;
56634
+ let size = PILL_SIZE[desc.node_treedb_type] || PILL_SIZE.entity;
56607
56635
  let ports = build_ports(gobj, desc);
56608
56636
  let paint = node_paint_of(gobj, desc, geometry);
56609
56637
  let style = {
@@ -56652,7 +56680,7 @@ function figure_shape_of(gobj, desc, record, geometry, flags) {
56652
56680
  let dark = priv.theme === "dark";
56653
56681
  let f = flags || {};
56654
56682
  let tier = desc.node_treedb_type;
56655
- let side = SHAPE_SIZE[tier] || SHAPE_SIZE.hierarchical;
56683
+ let side = SHAPE_SIZE[tier] || SHAPE_SIZE.entity;
56656
56684
  let labels = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "node_labels");
56657
56685
  let figure = node_figure_of(gobj, desc, geometry || {});
56658
56686
  let paint = node_paint_of(gobj, desc, geometry);
@@ -59718,7 +59746,7 @@ function show_node_detail_popover(gobj, node_id) {
59718
59746
  clamp_popover_position(gobj, popover);
59719
59747
  }
59720
59748
  /************************************************************
59721
- * Re-render every HTML (hierarchical) node so its card adopts
59749
+ * Re-render every HTML (entity) node so its card adopts
59722
59750
  * the given theme: entity cards and leaf chips (baked innerHTML,
59723
59751
  * not re-themed by setTheme()) are regenerated; structural
59724
59752
  * junction diamonds get their neutral fill/stroke re-themed.
@@ -59837,7 +59865,7 @@ function refresh_minimap(gobj) {
59837
59865
  }
59838
59866
  /************************************************************
59839
59867
  * The innerHTML of ONE node, in the given theme and highlight state.
59840
- * The three treedb tiers (hierarchical / extended / child) each have
59868
+ * The three treedb tiers (entity / extended / child) each have
59841
59869
  * their own card, and both the theme refresh and the highlight need the
59842
59870
  * same three-way choice — it lived inline in the theme refresh and is
59843
59871
  * shared now. Returns null for a node that carries no desc.
@@ -59854,7 +59882,7 @@ function node_innerHTML_of(nd, theme, highlight, selected, anchored, pills_html)
59854
59882
  switch (desc.node_treedb_type) {
59855
59883
  case "child": return build_chip_innerHTML(fill, theme, record.icon, label, record.id, highlight, selected, anchored);
59856
59884
  case "extended": return build_node_innerHTML(fill, theme, record.icon, label, desc.topic_name, true, record.id, highlight, selected, anchored, pills_html);
59857
- case "hierarchical": return build_node_innerHTML(fill, theme, record.icon, label, desc.topic_name, false, record.id, highlight, selected, anchored, pills_html);
59885
+ case "entity": return build_node_innerHTML(fill, theme, record.icon, label, desc.topic_name, false, record.id, highlight, selected, anchored, pills_html);
59858
59886
  }
59859
59887
  return null;
59860
59888
  }
@@ -60051,7 +60079,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight, selecte
60051
60079
  `;
60052
60080
  }
60053
60081
  /************************************************************
60054
- * Build innerHTML for hierarchical (HTML) nodes.
60082
+ * Build innerHTML for entity (HTML) nodes.
60055
60083
  *
60056
60084
  * Card style modelled on the documentation schema graphs
60057
60085
  * (docs gen_treedb_graphs.py): rounded card, soft tint of the
@@ -61369,12 +61397,13 @@ function publish_legend_state(gobj, visible) {
61369
61397
  loose: loose.length,
61370
61398
  loose_shown: model.loose_shown.has(topic_name),
61371
61399
  hidden: model.hidden.has(topic_name),
61372
- linked: model.linked.has(topic_name)
61400
+ linked: model.linked.has(topic_name),
61401
+ hierarchical: fold_is_hierarchical(priv.descs, topic_name)
61373
61402
  });
61374
61403
  }
61375
61404
  (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_LEGEND_STATE", {
61376
61405
  main_topic: model.main_topic,
61377
- main_chosen: !!(0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "main_topic"),
61406
+ main_chosen: !!model.main_topic && (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "main_topic") === model.main_topic,
61378
61407
  topics,
61379
61408
  fold: {
61380
61409
  topic: model.main_topic,
@@ -62950,7 +62979,7 @@ var attrs_table$3 = [
62950
62979
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
62951
62980
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_LIST, "operation_modes", 0, "[\"reading\", \"operation\", \"writing\", \"edition\"]", "Available **permission** or behaviour modes. These operation modes are required to be accomplish by the graph handler (G6 child). TODO permissions must match treedb permissions."),
62952
62981
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_treedb_tables", 0, false, "Include treedb tables"),
62953
- (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_INTEGER, "expand_depth", 0, 2, "Levels of the treedb open when it loads: 1 = the roots alone, 2 = the roots and their children. Forwarded to the G6 child"),
62982
+ (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_INTEGER, "expand_depth", 0, 1, "Levels of the treedb open when it loads, until the reader steps (`fold_level`): 1 = the roots alone (the default), 2 = the roots and their children. Forwarded to the G6 child"),
62954
62983
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of one hook shown per page in the graph; a `+N` chip opens the next page. Forwarded to the G6 child"),
62955
62984
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_INTEGER, "minimap_min_nodes", 0, 30, "Show the minimap from this many nodes on (0 = never). Forwarded to the G6 child"),
62956
62985
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_gridline", 0, true, "Draw the grid under the graph. Forwarded to the G6 child"),
@@ -64601,6 +64630,7 @@ function refresh_legend(gobj) {
64601
64630
  let topics = state.topics.slice().sort((a, b) => {
64602
64631
  return String(a.topic).localeCompare(String(b.topic));
64603
64632
  });
64633
+ let hierarchical_count = topics.filter((e) => e.hierarchical && !e.hidden).length;
64604
64634
  for (let entry of topics) {
64605
64635
  let topic_name = entry.topic;
64606
64636
  let is_main = topic_name === state.main_topic;
@@ -64695,7 +64725,7 @@ function refresh_legend(gobj) {
64695
64725
  "★"
64696
64726
  ]]
64697
64727
  ]);
64698
- else if (!hidden) extras.push([
64728
+ else if (!hidden && entry.hierarchical && hierarchical_count > 1) extras.push([
64699
64729
  "button",
64700
64730
  {
64701
64731
  class: "GRAPH_LEGEND_STAR button",
@@ -66044,7 +66074,7 @@ function sort_by_order(list) {
66044
66074
  * schema_version, c_schema_version, system_schema_version,
66045
66075
  * topics: [{
66046
66076
  * id, name, order, record,
66047
- * pkey, topic_version, system_topic,
66077
+ * pkey, topic_version, system_topic, main_topic,
66048
66078
  * cols: [{id, name, order, record}]
66049
66079
  * }]
66050
66080
  * }],
@@ -66093,6 +66123,7 @@ function build_schema_model(records) {
66093
66123
  tkey: record.tkey || "",
66094
66124
  topic_version: record.topic_version,
66095
66125
  system_topic: !!record.system_topic,
66126
+ main_topic: !!record.main_topic,
66096
66127
  cols: []
66097
66128
  };
66098
66129
  by_topic_id[entry.id] = entry;
@@ -66389,6 +66420,12 @@ function validate_schema(treedb, options) {
66389
66420
  if (is_fkey && FKEY_TYPES.indexOf(type) < 0) add("error", "fkey has a bad type", topic.name, col.name, type);
66390
66421
  }
66391
66422
  }
66423
+ let marked = treedb.topics.filter((topic) => topic.main_topic);
66424
+ if (marked.length > 1) add("error", "more than one main topic", marked[1].name, "", marked.map((topic) => topic.name).join(", "));
66425
+ for (let topic of marked) if (!topic.cols.some((col) => {
66426
+ let hook = col_hook(col.record);
66427
+ return col_flags(col.record).indexOf("hook") >= 0 && !!hook && Object.prototype.hasOwnProperty.call(hook, topic.name);
66428
+ })) add("error", "main topic not hooked to itself", topic.name, "", "");
66392
66429
  let hooked = {};
66393
66430
  for (let topic of treedb.topics) for (let col of topic.cols) {
66394
66431
  let hook = col_hook(col.record);
@@ -66580,7 +66617,8 @@ var TOPIC_KEY_ORDER = [
66580
66617
  "system_flag",
66581
66618
  "tkey",
66582
66619
  "topic_version",
66583
- "system_topic"
66620
+ "system_topic",
66621
+ "main_topic"
66584
66622
  ];
66585
66623
  var COL_KEY_ORDER = [
66586
66624
  "header",
@@ -66838,7 +66876,8 @@ var TOPIC_FIELDS = [
66838
66876
  "system_flag",
66839
66877
  "tkey",
66840
66878
  "topic_version",
66841
- "system_topic"
66879
+ "system_topic",
66880
+ "main_topic"
66842
66881
  ];
66843
66882
  var COL_FIELDS = [
66844
66883
  "header",
@@ -68455,6 +68494,20 @@ function render_topics(gobj, $body) {
68455
68494
  {},
68456
68495
  ""
68457
68496
  ],
68497
+ topic.main_topic ? [
68498
+ "span",
68499
+ {
68500
+ class: "SCHEMA_TOPIC_MAIN ml-2",
68501
+ style: "color:#d4a017;",
68502
+ title: (0, i18next.t)("main topic"),
68503
+ "data-i18n-title": "main topic"
68504
+ },
68505
+ "★"
68506
+ ] : [
68507
+ "span",
68508
+ {},
68509
+ ""
68510
+ ],
68458
68511
  ambiguous ? [
68459
68512
  "div",
68460
68513
  { class: "SCHEMA_TOPIC_ID is-size-7 yui-text-quiet" },
@@ -69449,6 +69502,26 @@ function open_topic_form(gobj, treedb, topic) {
69449
69502
  ]]
69450
69503
  ]]
69451
69504
  ],
69505
+ [
69506
+ "div",
69507
+ { class: "SCHEMA_TOPIC_FORM_MAIN field mb-3" },
69508
+ [[
69509
+ "label",
69510
+ { class: "checkbox" },
69511
+ [["input", el_attrs({
69512
+ type: "checkbox",
69513
+ "data-name": "main_topic",
69514
+ checked: record.main_topic ? "checked" : void 0
69515
+ })], [
69516
+ "span",
69517
+ {
69518
+ class: "ml-1",
69519
+ i18n: "main topic"
69520
+ },
69521
+ (0, i18next.t)("main topic")
69522
+ ]]
69523
+ ]]
69524
+ ],
69452
69525
  [
69453
69526
  "div",
69454
69527
  {
@@ -70241,6 +70314,7 @@ function topic_record(gobj, treedb, topic, values) {
70241
70314
  record.tkey = values.tkey || "";
70242
70315
  record.topic_version = values.topic_version === "" ? 1 : Number(values.topic_version);
70243
70316
  record.system_topic = !!values.system_topic;
70317
+ record.main_topic = !!values.main_topic;
70244
70318
  return record;
70245
70319
  }
70246
70320
  /***************************
@@ -27670,7 +27670,9 @@ function fold_build_model(descs, records, opts) {
27670
27670
  opts = is_object(opts) ? opts : {};
27671
27671
  let hidden = new Set(is_array(opts.hidden_topics) ? opts.hidden_topics : []);
27672
27672
  let loose_shown = new Set(is_array(opts.loose_topics) ? opts.loose_topics : []);
27673
- let main_topic = opts.main_topic || fold_main_topic(descs);
27673
+ let main_topic = opts.main_topic || "";
27674
+ if (main_topic && !fold_is_hierarchical(descs, main_topic)) main_topic = "";
27675
+ if (!main_topic) main_topic = fold_main_topic(descs);
27674
27676
  if (main_topic && (!descs[main_topic] || hidden.has(main_topic))) main_topic = "";
27675
27677
  let linked = fold_linked_topics(descs, main_topic);
27676
27678
  let totals = {};
@@ -27791,28 +27793,49 @@ function count_levels(model) {
27791
27793
  model.levels = deepest;
27792
27794
  }
27793
27795
  /************************************************************
27794
- * The topic the schema hangs the others from: the one whose hooks
27795
- * reach the most OTHER topics, a self-referent hook breaking a tie
27796
- * (a tree of places over a flat list of groups), schema order
27797
- * breaking the rest. "" when no topic reaches another -- a treedb of
27798
- * unrelated lists has no trunk, and every topic is a tree of its own.
27796
+ * A HIERARCHICAL topic: one hooked to ITSELF -- places inside
27797
+ * places, groups inside groups, roles inside roles. It is the
27798
+ * only kind of topic a tree can hang from: a topic that only
27799
+ * reaches OTHER topics is a list with children, and making it
27800
+ * the trunk drew the agent's treedb from `yunos` instead of
27801
+ * `realms`.
27802
+ *
27803
+ * (Not the engine's `entity` tier, which is any topic with
27804
+ * hooks AND fkeys: that one sizes a card and says nothing about
27805
+ * where a tree starts.)
27806
+ ************************************************************/
27807
+ function fold_is_hierarchical(descs, topic_name) {
27808
+ if (!is_object(descs) || !descs[topic_name]) return false;
27809
+ return hook_targets(descs, topic_name).has(topic_name);
27810
+ }
27811
+ /************************************************************
27812
+ * The topic the tree hangs from. Only a HIERARCHICAL topic can
27813
+ * be it, and in this order:
27814
+ *
27815
+ * the one the schema MARKS (`main_topic: true` on the topic,
27816
+ * sent by the backend in its desc since SDK 7.19; the first
27817
+ * one in schema order if a schema marks two);
27818
+ * else the one whose hooks reach the most OTHER topics,
27819
+ * schema order breaking a tie.
27820
+ *
27821
+ * "" when no topic is hooked to itself: there is no trunk, and
27822
+ * every topic is a tree of its own. (A pick by the reader comes
27823
+ * first of all, and is fold_build_model's business.)
27799
27824
  ************************************************************/
27800
27825
  function fold_main_topic(descs) {
27801
27826
  descs = is_object(descs) ? descs : {};
27827
+ let candidates = Object.keys(descs).filter((topic_name) => {
27828
+ return topic_name.substring(0, 2) !== "__" && fold_is_hierarchical(descs, topic_name);
27829
+ });
27830
+ for (let topic_name of candidates) if (descs[topic_name].main_topic === true) return topic_name;
27802
27831
  let best = "";
27803
- let best_score = 0;
27804
- let best_self = false;
27805
- for (let topic_name of Object.keys(descs)) {
27806
- if (topic_name.substring(0, 2) === "__") continue;
27832
+ let best_score = -1;
27833
+ for (let topic_name of candidates) {
27807
27834
  let reach = hook_targets(descs, topic_name);
27808
- let self = reach.has(topic_name);
27809
27835
  reach.delete(topic_name);
27810
- let score = reach.size;
27811
- if (score === 0) continue;
27812
- if (score > best_score || score === best_score && self && !best_self) {
27836
+ if (reach.size > best_score) {
27813
27837
  best = topic_name;
27814
- best_score = score;
27815
- best_self = self;
27838
+ best_score = reach.size;
27816
27839
  }
27817
27840
  }
27818
27841
  return best;
@@ -55298,13 +55321,13 @@ var NODE_MODES$1 = [
55298
55321
  "shape"
55299
55322
  ];
55300
55323
  var PILL_SIZE = {
55301
- hierarchical: [200, 30],
55324
+ entity: [200, 30],
55302
55325
  extended: [180, 28],
55303
55326
  child: [160, 26]
55304
55327
  };
55305
55328
  var PORT_R_PILL = 6;
55306
55329
  var SHAPE_SIZE = {
55307
- hierarchical: 32,
55330
+ entity: 32,
55308
55331
  extended: 28,
55309
55332
  child: 22
55310
55333
  };
@@ -55392,7 +55415,7 @@ var attrs_table$4 = [
55392
55415
  SDATA(data_type_t.DTP_LIST, "layout_names", sdata_flag_t.SDF_RD, JSON.stringify(Object.keys(_layouts)), "Available layout names (read-only, for parent to query)"),
55393
55416
  SDATA(data_type_t.DTP_STRING, "hook_port_position", 0, "bottom", "Hook port position"),
55394
55417
  SDATA(data_type_t.DTP_STRING, "fkey_port_position", 0, "top", "Fkey port position"),
55395
- SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 2, "Levels open when a treedb loads, until the reader steps (`fold_level`): 1 = the roots alone, 2 = the roots and their children. Every open hook shows its first page"),
55418
+ SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 1, "Levels open when a treedb loads, until the reader steps (`fold_level`): 1 = the roots alone (the default), 2 = the roots and their children. Every open hook shows its first page"),
55396
55419
  SDATA(data_type_t.DTP_INTEGER, "fold_level", 0, 0, "The level of the MAIN tree the toolbar's stepper stands on: 1 = its roots, N = N-1 levels under them; 0 = `expand_depth`. The host persists it and sends EV_SET_FOLD_LEVEL; it is published back in EV_LEGEND_STATE (`fold`) with the levels the tree has"),
55397
55420
  SDATA(data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of one hook shown per page; the `+N` chip after the page opens the next one"),
55398
55421
  SDATA(data_type_t.DTP_LIST, "hidden_topics", 0, "[]", "Topics left out of the tree: no card, no pill counts them, no edge reaches them"),
@@ -55484,7 +55507,7 @@ var PRIVATE_DATA$4 = {
55484
55507
  _layout_asked: "",
55485
55508
  _nodes_total: 0,
55486
55509
  _nodes_placed: 0,
55487
- expand_depth: 2,
55510
+ expand_depth: 1,
55488
55511
  fold_level: 0,
55489
55512
  fold_page_size: 24,
55490
55513
  hidden_topics: null,
@@ -56208,7 +56231,12 @@ function configure_behaviour(gobj) {
56208
56231
  graph_write_behaviors(gobj, behaviors);
56209
56232
  }
56210
56233
  /************************************************************
56211
- * Count hooks and fkeys in topic desc, classify node type
56234
+ * Count hooks and fkeys in topic desc, classify node type:
56235
+ * `child` (no hooks), `extended` (hooks, no fkeys), `entity`
56236
+ * (both). It sizes the card and nothing else. It used to be
56237
+ * called `hierarchical`, and that word means something else now:
56238
+ * a topic hooked to ITSELF, the only kind a tree can hang from
56239
+ * (treedb_fold_model).
56212
56240
  ************************************************************/
56213
56241
  function calculate_hooks_fkeys_counter(desc) {
56214
56242
  let cols = desc.cols;
@@ -56225,7 +56253,7 @@ function calculate_hooks_fkeys_counter(desc) {
56225
56253
  }
56226
56254
  if (desc.hooks_counter === 0) desc.node_treedb_type = "child";
56227
56255
  else if (desc.fkeys_counter === 0) desc.node_treedb_type = "extended";
56228
- else desc.node_treedb_type = "hierarchical";
56256
+ else desc.node_treedb_type = "entity";
56229
56257
  }
56230
56258
  /************************************************************
56231
56259
  * Build ports for a topic desc
@@ -56598,7 +56626,7 @@ function dress_ports(gobj, style, ports, geometry, topic_defaults, r_default, fi
56598
56626
  ************************************************************/
56599
56627
  function pill_shape_of(gobj, desc, record, geometry) {
56600
56628
  let priv = gobj.priv;
56601
- let size = PILL_SIZE[desc.node_treedb_type] || PILL_SIZE.hierarchical;
56629
+ let size = PILL_SIZE[desc.node_treedb_type] || PILL_SIZE.entity;
56602
56630
  let ports = build_ports(gobj, desc);
56603
56631
  let paint = node_paint_of(gobj, desc, geometry);
56604
56632
  let style = {
@@ -56647,7 +56675,7 @@ function figure_shape_of(gobj, desc, record, geometry, flags) {
56647
56675
  let dark = priv.theme === "dark";
56648
56676
  let f = flags || {};
56649
56677
  let tier = desc.node_treedb_type;
56650
- let side = SHAPE_SIZE[tier] || SHAPE_SIZE.hierarchical;
56678
+ let side = SHAPE_SIZE[tier] || SHAPE_SIZE.entity;
56651
56679
  let labels = gobj_read_bool_attr(gobj, "node_labels");
56652
56680
  let figure = node_figure_of(gobj, desc, geometry || {});
56653
56681
  let paint = node_paint_of(gobj, desc, geometry);
@@ -59713,7 +59741,7 @@ function show_node_detail_popover(gobj, node_id) {
59713
59741
  clamp_popover_position(gobj, popover);
59714
59742
  }
59715
59743
  /************************************************************
59716
- * Re-render every HTML (hierarchical) node so its card adopts
59744
+ * Re-render every HTML (entity) node so its card adopts
59717
59745
  * the given theme: entity cards and leaf chips (baked innerHTML,
59718
59746
  * not re-themed by setTheme()) are regenerated; structural
59719
59747
  * junction diamonds get their neutral fill/stroke re-themed.
@@ -59832,7 +59860,7 @@ function refresh_minimap(gobj) {
59832
59860
  }
59833
59861
  /************************************************************
59834
59862
  * The innerHTML of ONE node, in the given theme and highlight state.
59835
- * The three treedb tiers (hierarchical / extended / child) each have
59863
+ * The three treedb tiers (entity / extended / child) each have
59836
59864
  * their own card, and both the theme refresh and the highlight need the
59837
59865
  * same three-way choice — it lived inline in the theme refresh and is
59838
59866
  * shared now. Returns null for a node that carries no desc.
@@ -59849,7 +59877,7 @@ function node_innerHTML_of(nd, theme, highlight, selected, anchored, pills_html)
59849
59877
  switch (desc.node_treedb_type) {
59850
59878
  case "child": return build_chip_innerHTML(fill, theme, record.icon, label, record.id, highlight, selected, anchored);
59851
59879
  case "extended": return build_node_innerHTML(fill, theme, record.icon, label, desc.topic_name, true, record.id, highlight, selected, anchored, pills_html);
59852
- case "hierarchical": return build_node_innerHTML(fill, theme, record.icon, label, desc.topic_name, false, record.id, highlight, selected, anchored, pills_html);
59880
+ case "entity": return build_node_innerHTML(fill, theme, record.icon, label, desc.topic_name, false, record.id, highlight, selected, anchored, pills_html);
59853
59881
  }
59854
59882
  return null;
59855
59883
  }
@@ -60046,7 +60074,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight, selecte
60046
60074
  `;
60047
60075
  }
60048
60076
  /************************************************************
60049
- * Build innerHTML for hierarchical (HTML) nodes.
60077
+ * Build innerHTML for entity (HTML) nodes.
60050
60078
  *
60051
60079
  * Card style modelled on the documentation schema graphs
60052
60080
  * (docs gen_treedb_graphs.py): rounded card, soft tint of the
@@ -61364,12 +61392,13 @@ function publish_legend_state(gobj, visible) {
61364
61392
  loose: loose.length,
61365
61393
  loose_shown: model.loose_shown.has(topic_name),
61366
61394
  hidden: model.hidden.has(topic_name),
61367
- linked: model.linked.has(topic_name)
61395
+ linked: model.linked.has(topic_name),
61396
+ hierarchical: fold_is_hierarchical(priv.descs, topic_name)
61368
61397
  });
61369
61398
  }
61370
61399
  gobj_publish_event(gobj, "EV_LEGEND_STATE", {
61371
61400
  main_topic: model.main_topic,
61372
- main_chosen: !!gobj_read_str_attr(gobj, "main_topic"),
61401
+ main_chosen: !!model.main_topic && gobj_read_str_attr(gobj, "main_topic") === model.main_topic,
61373
61402
  topics,
61374
61403
  fold: {
61375
61404
  topic: model.main_topic,
@@ -62945,7 +62974,7 @@ var attrs_table$3 = [
62945
62974
  SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
62946
62975
  SDATA(data_type_t.DTP_LIST, "operation_modes", 0, "[\"reading\", \"operation\", \"writing\", \"edition\"]", "Available **permission** or behaviour modes. These operation modes are required to be accomplish by the graph handler (G6 child). TODO permissions must match treedb permissions."),
62947
62976
  SDATA(data_type_t.DTP_BOOLEAN, "with_treedb_tables", 0, false, "Include treedb tables"),
62948
- SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 2, "Levels of the treedb open when it loads: 1 = the roots alone, 2 = the roots and their children. Forwarded to the G6 child"),
62977
+ SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 1, "Levels of the treedb open when it loads, until the reader steps (`fold_level`): 1 = the roots alone (the default), 2 = the roots and their children. Forwarded to the G6 child"),
62949
62978
  SDATA(data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of one hook shown per page in the graph; a `+N` chip opens the next page. Forwarded to the G6 child"),
62950
62979
  SDATA(data_type_t.DTP_INTEGER, "minimap_min_nodes", 0, 30, "Show the minimap from this many nodes on (0 = never). Forwarded to the G6 child"),
62951
62980
  SDATA(data_type_t.DTP_BOOLEAN, "with_gridline", 0, true, "Draw the grid under the graph. Forwarded to the G6 child"),
@@ -64596,6 +64625,7 @@ function refresh_legend(gobj) {
64596
64625
  let topics = state.topics.slice().sort((a, b) => {
64597
64626
  return String(a.topic).localeCompare(String(b.topic));
64598
64627
  });
64628
+ let hierarchical_count = topics.filter((e) => e.hierarchical && !e.hidden).length;
64599
64629
  for (let entry of topics) {
64600
64630
  let topic_name = entry.topic;
64601
64631
  let is_main = topic_name === state.main_topic;
@@ -64690,7 +64720,7 @@ function refresh_legend(gobj) {
64690
64720
  "★"
64691
64721
  ]]
64692
64722
  ]);
64693
- else if (!hidden) extras.push([
64723
+ else if (!hidden && entry.hierarchical && hierarchical_count > 1) extras.push([
64694
64724
  "button",
64695
64725
  {
64696
64726
  class: "GRAPH_LEGEND_STAR button",
@@ -66039,7 +66069,7 @@ function sort_by_order(list) {
66039
66069
  * schema_version, c_schema_version, system_schema_version,
66040
66070
  * topics: [{
66041
66071
  * id, name, order, record,
66042
- * pkey, topic_version, system_topic,
66072
+ * pkey, topic_version, system_topic, main_topic,
66043
66073
  * cols: [{id, name, order, record}]
66044
66074
  * }]
66045
66075
  * }],
@@ -66088,6 +66118,7 @@ function build_schema_model(records) {
66088
66118
  tkey: record.tkey || "",
66089
66119
  topic_version: record.topic_version,
66090
66120
  system_topic: !!record.system_topic,
66121
+ main_topic: !!record.main_topic,
66091
66122
  cols: []
66092
66123
  };
66093
66124
  by_topic_id[entry.id] = entry;
@@ -66384,6 +66415,12 @@ function validate_schema(treedb, options) {
66384
66415
  if (is_fkey && FKEY_TYPES.indexOf(type) < 0) add("error", "fkey has a bad type", topic.name, col.name, type);
66385
66416
  }
66386
66417
  }
66418
+ let marked = treedb.topics.filter((topic) => topic.main_topic);
66419
+ if (marked.length > 1) add("error", "more than one main topic", marked[1].name, "", marked.map((topic) => topic.name).join(", "));
66420
+ for (let topic of marked) if (!topic.cols.some((col) => {
66421
+ let hook = col_hook(col.record);
66422
+ return col_flags(col.record).indexOf("hook") >= 0 && !!hook && Object.prototype.hasOwnProperty.call(hook, topic.name);
66423
+ })) add("error", "main topic not hooked to itself", topic.name, "", "");
66387
66424
  let hooked = {};
66388
66425
  for (let topic of treedb.topics) for (let col of topic.cols) {
66389
66426
  let hook = col_hook(col.record);
@@ -66575,7 +66612,8 @@ var TOPIC_KEY_ORDER = [
66575
66612
  "system_flag",
66576
66613
  "tkey",
66577
66614
  "topic_version",
66578
- "system_topic"
66615
+ "system_topic",
66616
+ "main_topic"
66579
66617
  ];
66580
66618
  var COL_KEY_ORDER = [
66581
66619
  "header",
@@ -66833,7 +66871,8 @@ var TOPIC_FIELDS = [
66833
66871
  "system_flag",
66834
66872
  "tkey",
66835
66873
  "topic_version",
66836
- "system_topic"
66874
+ "system_topic",
66875
+ "main_topic"
66837
66876
  ];
66838
66877
  var COL_FIELDS = [
66839
66878
  "header",
@@ -68450,6 +68489,20 @@ function render_topics(gobj, $body) {
68450
68489
  {},
68451
68490
  ""
68452
68491
  ],
68492
+ topic.main_topic ? [
68493
+ "span",
68494
+ {
68495
+ class: "SCHEMA_TOPIC_MAIN ml-2",
68496
+ style: "color:#d4a017;",
68497
+ title: t("main topic"),
68498
+ "data-i18n-title": "main topic"
68499
+ },
68500
+ "★"
68501
+ ] : [
68502
+ "span",
68503
+ {},
68504
+ ""
68505
+ ],
68453
68506
  ambiguous ? [
68454
68507
  "div",
68455
68508
  { class: "SCHEMA_TOPIC_ID is-size-7 yui-text-quiet" },
@@ -69444,6 +69497,26 @@ function open_topic_form(gobj, treedb, topic) {
69444
69497
  ]]
69445
69498
  ]]
69446
69499
  ],
69500
+ [
69501
+ "div",
69502
+ { class: "SCHEMA_TOPIC_FORM_MAIN field mb-3" },
69503
+ [[
69504
+ "label",
69505
+ { class: "checkbox" },
69506
+ [["input", el_attrs({
69507
+ type: "checkbox",
69508
+ "data-name": "main_topic",
69509
+ checked: record.main_topic ? "checked" : void 0
69510
+ })], [
69511
+ "span",
69512
+ {
69513
+ class: "ml-1",
69514
+ i18n: "main topic"
69515
+ },
69516
+ t("main topic")
69517
+ ]]
69518
+ ]]
69519
+ ],
69447
69520
  [
69448
69521
  "div",
69449
69522
  {
@@ -70236,6 +70309,7 @@ function topic_record(gobj, treedb, topic, values) {
70236
70309
  record.tkey = values.tkey || "";
70237
70310
  record.topic_version = values.topic_version === "" ? 1 : Number(values.topic_version);
70238
70311
  record.system_topic = !!values.system_topic;
70312
+ record.main_topic = !!values.main_topic;
70239
70313
  return record;
70240
70314
  }
70241
70315
  /***************************
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.141",
3
+ "version": "7.23.142",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -122,6 +122,7 @@ import {
122
122
  fold_open_levels,
123
123
  fold_close_levels,
124
124
  fold_level_clamp,
125
+ fold_is_hierarchical,
125
126
  fold_toggle,
126
127
  fold_show_more,
127
128
  fold_reveal,
@@ -301,14 +302,14 @@ const PORT_LINE_WIDTH = 2;
301
302
  const NODE_MODES = ['expanded', 'compact', 'shape'];
302
303
 
303
304
  const PILL_SIZE = {
304
- hierarchical: [200, 30],
305
+ entity: [200, 30],
305
306
  extended: [180, 28],
306
307
  child: [160, 26],
307
308
  };
308
309
  const PORT_R_PILL = 6;
309
310
 
310
311
  const SHAPE_SIZE = {
311
- hierarchical: 32,
312
+ entity: 32,
312
313
  extended: 28,
313
314
  child: 22,
314
315
  };
@@ -436,7 +437,7 @@ SDATA(data_type_t.DTP_STRING, "hook_port_position", 0, "bottom", "Hook po
436
437
  SDATA(data_type_t.DTP_STRING, "fkey_port_position", 0, "top", "Fkey port position"),
437
438
 
438
439
  /*---------------- Folding ----------------*/
439
- SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 2, "Levels open when a treedb loads, until the reader steps (`fold_level`): 1 = the roots alone, 2 = the roots and their children. Every open hook shows its first page"),
440
+ SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 1, "Levels open when a treedb loads, until the reader steps (`fold_level`): 1 = the roots alone (the default), 2 = the roots and their children. Every open hook shows its first page"),
440
441
  SDATA(data_type_t.DTP_INTEGER, "fold_level", 0, 0, "The level of the MAIN tree the toolbar's stepper stands on: 1 = its roots, N = N-1 levels under them; 0 = `expand_depth`. The host persists it and sends EV_SET_FOLD_LEVEL; it is published back in EV_LEGEND_STATE (`fold`) with the levels the tree has"),
441
442
  SDATA(data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of one hook shown per page; the `+N` chip after the page opens the next one"),
442
443
  SDATA(data_type_t.DTP_LIST, "hidden_topics", 0, "[]", "Topics left out of the tree: no card, no pill counts them, no edge reaches them"),
@@ -545,7 +546,7 @@ let PRIVATE_DATA = {
545
546
  _nodes_placed: 0, // ...of which carry a saved position
546
547
 
547
548
  /*---------------- folding ----------------*/
548
- expand_depth: 2,
549
+ expand_depth: 1,
549
550
  fold_level: 0,
550
551
  fold_page_size: 24,
551
552
  hidden_topics: null,
@@ -1859,7 +1860,12 @@ function configure_behaviour(gobj)
1859
1860
  }
1860
1861
 
1861
1862
  /************************************************************
1862
- * Count hooks and fkeys in topic desc, classify node type
1863
+ * Count hooks and fkeys in topic desc, classify node type:
1864
+ * `child` (no hooks), `extended` (hooks, no fkeys), `entity`
1865
+ * (both). It sizes the card and nothing else. It used to be
1866
+ * called `hierarchical`, and that word means something else now:
1867
+ * a topic hooked to ITSELF, the only kind a tree can hang from
1868
+ * (treedb_fold_model).
1863
1869
  ************************************************************/
1864
1870
  function calculate_hooks_fkeys_counter(desc)
1865
1871
  {
@@ -1885,7 +1891,7 @@ function calculate_hooks_fkeys_counter(desc)
1885
1891
  } else if(desc.fkeys_counter === 0) {
1886
1892
  desc.node_treedb_type = 'extended';
1887
1893
  } else {
1888
- desc.node_treedb_type = 'hierarchical';
1894
+ desc.node_treedb_type = 'entity';
1889
1895
  }
1890
1896
  }
1891
1897
 
@@ -2306,7 +2312,7 @@ function card_shape_of(gobj, desc, record, geometry, pills_html)
2306
2312
  desc.topic_name, true, record.id, false, false, false, pills_html
2307
2313
  );
2308
2314
  } else {
2309
- // Hierarchical entity (ROOT / container): largest tier.
2315
+ // Entity, hooks and fkeys (ROOT / container): largest tier.
2310
2316
  style.innerHTML = build_node_innerHTML(
2311
2317
  paint.fill, priv.theme, record.icon, label,
2312
2318
  desc.topic_name, false, record.id, false, false, false, pills_html
@@ -2409,7 +2415,7 @@ function pill_shape_of(gobj, desc, record, geometry)
2409
2415
  {
2410
2416
  let priv = gobj.priv;
2411
2417
  let tier = desc.node_treedb_type;
2412
- let size = PILL_SIZE[tier] || PILL_SIZE.hierarchical;
2418
+ let size = PILL_SIZE[tier] || PILL_SIZE.entity;
2413
2419
  let ports = build_ports(gobj, desc);
2414
2420
  let paint = node_paint_of(gobj, desc, geometry);
2415
2421
 
@@ -2472,7 +2478,7 @@ function figure_shape_of(gobj, desc, record, geometry, flags)
2472
2478
  let dark = (priv.theme === "dark");
2473
2479
  let f = flags || {};
2474
2480
  let tier = desc.node_treedb_type;
2475
- let side = SHAPE_SIZE[tier] || SHAPE_SIZE.hierarchical;
2481
+ let side = SHAPE_SIZE[tier] || SHAPE_SIZE.entity;
2476
2482
  let labels = gobj_read_bool_attr(gobj, "node_labels");
2477
2483
  let figure = node_figure_of(gobj, desc, geometry || {});
2478
2484
  let paint = node_paint_of(gobj, desc, geometry);
@@ -7042,7 +7048,7 @@ function show_node_detail_popover(gobj, node_id)
7042
7048
  }
7043
7049
 
7044
7050
  /************************************************************
7045
- * Re-render every HTML (hierarchical) node so its card adopts
7051
+ * Re-render every HTML (entity) node so its card adopts
7046
7052
  * the given theme: entity cards and leaf chips (baked innerHTML,
7047
7053
  * not re-themed by setTheme()) are regenerated; structural
7048
7054
  * junction diamonds get their neutral fill/stroke re-themed.
@@ -7258,7 +7264,7 @@ function refresh_minimap(gobj)
7258
7264
 
7259
7265
  /************************************************************
7260
7266
  * The innerHTML of ONE node, in the given theme and highlight state.
7261
- * The three treedb tiers (hierarchical / extended / child) each have
7267
+ * The three treedb tiers (entity / extended / child) each have
7262
7268
  * their own card, and both the theme refresh and the highlight need the
7263
7269
  * same three-way choice — it lived inline in the theme refresh and is
7264
7270
  * shared now. Returns null for a node that carries no desc.
@@ -7295,7 +7301,7 @@ function node_innerHTML_of(nd, theme, highlight, selected, anchored, pills_html)
7295
7301
  desc.topic_name, true, record.id, highlight, selected, anchored,
7296
7302
  pills_html
7297
7303
  );
7298
- case 'hierarchical':
7304
+ case 'entity':
7299
7305
  return build_node_innerHTML(
7300
7306
  fill, theme, record.icon, label,
7301
7307
  desc.topic_name, false, record.id, highlight, selected, anchored,
@@ -7569,7 +7575,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight, selecte
7569
7575
  }
7570
7576
 
7571
7577
  /************************************************************
7572
- * Build innerHTML for hierarchical (HTML) nodes.
7578
+ * Build innerHTML for entity (HTML) nodes.
7573
7579
  *
7574
7580
  * Card style modelled on the documentation schema graphs
7575
7581
  * (docs gen_treedb_graphs.py): rounded card, soft tint of the
@@ -9538,12 +9544,16 @@ function publish_legend_state(gobj, visible)
9538
9544
  loose_shown: model.loose_shown.has(topic_name),
9539
9545
  hidden: model.hidden.has(topic_name),
9540
9546
  linked: model.linked.has(topic_name),
9547
+ hierarchical: fold_is_hierarchical(priv.descs, topic_name),
9541
9548
  });
9542
9549
  }
9543
9550
 
9544
9551
  gobj_publish_event(gobj, "EV_LEGEND_STATE", {
9545
9552
  main_topic: model.main_topic,
9546
- main_chosen: !!gobj_read_str_attr(gobj, "main_topic"),
9553
+ /* CHOSEN only when the reader's pick is the one in force: a
9554
+ * pick the rule refuses (not hierarchical) is ignored. */
9555
+ main_chosen: !!model.main_topic &&
9556
+ gobj_read_str_attr(gobj, "main_topic") === model.main_topic,
9547
9557
  topics: topics,
9548
9558
  /* The toolbar's stepper: the tree it walks, where it stands,
9549
9559
  * and how deep it goes. `levels` 0 = nothing to step through. */
@@ -1061,6 +1061,13 @@ function render_topics(gobj, $body)
1061
1061
  ? ["span", {class: "SCHEMA_TOPIC_SYSTEM tag ml-2",
1062
1062
  i18n: "system"}, t("system")]
1063
1063
  : ["span", {}, ""],
1064
+ /* The schema's mark of the topic the tree hangs from: the
1065
+ * same gold star the treedb graph's legend wears. */
1066
+ topic.main_topic
1067
+ ? ["span", {class: "SCHEMA_TOPIC_MAIN ml-2",
1068
+ style: "color:#d4a017;",
1069
+ title: t("main topic"), "data-i18n-title": "main topic"}, "★"]
1070
+ : ["span", {}, ""],
1064
1071
  ambiguous
1065
1072
  ? ["div", {class: "SCHEMA_TOPIC_ID is-size-7 yui-text-quiet"},
1066
1073
  [["code", {}, `${topic.id}`]]]
@@ -1898,6 +1905,14 @@ function open_topic_form(gobj, treedb, topic)
1898
1905
  ["span", {class: "ml-1", i18n: "system topic"}, t("system topic")]
1899
1906
  ]]
1900
1907
  ]],
1908
+ /* A LABEL that wraps its control names it: no aria-label. */
1909
+ ["div", {class: "SCHEMA_TOPIC_FORM_MAIN field mb-3"}, [
1910
+ ["label", {class: "checkbox"}, [
1911
+ ["input", el_attrs({type: "checkbox", "data-name": "main_topic",
1912
+ checked: record.main_topic ? "checked" : undefined})],
1913
+ ["span", {class: "ml-1", i18n: "main topic"}, t("main topic")]
1914
+ ]]
1915
+ ]],
1901
1916
  ["div", {class: "SCHEMA_TOPIC_FORM_ACTIONS is-flex mt-4",
1902
1917
  style: "gap:.5rem; justify-content:flex-end;"}, [
1903
1918
  ["button", {class: "SCHEMA_TOPIC_FORM_CANCEL button", type: "button"},
@@ -2501,6 +2516,7 @@ function topic_record(gobj, treedb, topic, values)
2501
2516
  record.tkey = values.tkey || "";
2502
2517
  record.topic_version = values.topic_version === "" ? 1 : Number(values.topic_version);
2503
2518
  record.system_topic = !!values.system_topic;
2519
+ record.main_topic = !!values.main_topic;
2504
2520
 
2505
2521
  return record;
2506
2522
  }
@@ -111,7 +111,7 @@ SDATA(data_type_t.DTP_LIST, "operation_modes", 0,
111
111
  '["reading", "operation", "writing", "edition"]',
112
112
  "Available **permission** or behaviour modes. These operation modes are required to be accomplish by the graph handler (G6 child). TODO permissions must match treedb permissions."),
113
113
  SDATA(data_type_t.DTP_BOOLEAN, "with_treedb_tables",0, false, "Include treedb tables"),
114
- SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 2, "Levels of the treedb open when it loads: 1 = the roots alone, 2 = the roots and their children. Forwarded to the G6 child"),
114
+ SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 1, "Levels of the treedb open when it loads, until the reader steps (`fold_level`): 1 = the roots alone (the default), 2 = the roots and their children. Forwarded to the G6 child"),
115
115
  SDATA(data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of one hook shown per page in the graph; a `+N` chip opens the next page. Forwarded to the G6 child"),
116
116
 
117
117
  /*---------------- Forwarded to the G6 child (its own defaults) ----------------*/
@@ -2699,6 +2699,11 @@ function refresh_legend(gobj)
2699
2699
  return String(a.topic).localeCompare(String(b.topic));
2700
2700
  });
2701
2701
 
2702
+ /* Only a topic hooked to ITSELF can be the main one, so only those
2703
+ * offer the star -- and only when there is more than one to pick
2704
+ * from: with a single hierarchical topic the choice is made. */
2705
+ let hierarchical_count = topics.filter((e) => e.hierarchical && !e.hidden).length;
2706
+
2702
2707
  for(let entry of topics) {
2703
2708
  let topic_name = entry.topic;
2704
2709
  let is_main = (topic_name === state.main_topic);
@@ -2813,7 +2818,7 @@ function refresh_legend(gobj)
2813
2818
  style: 'padding:0 .5rem; cursor:default;',
2814
2819
  title: t('main topic'), 'data-i18n-title': 'main topic'},
2815
2820
  [['span', {style: LEGEND_STAR_MAIN_STYLE}, '★']]]);
2816
- } else if(!hidden) {
2821
+ } else if(!hidden && entry.hierarchical && hierarchical_count > 1) {
2817
2822
  extras.push(['button', {class: 'GRAPH_LEGEND_STAR button',
2818
2823
  type: 'button', style: 'padding:0 .5rem;',
2819
2824
  title: t('main topic'), 'data-i18n-title': 'main topic',
@@ -38,7 +38,8 @@ import {
38
38
  * the fkey, `order`) is composed, and `_geometry` belongs to whoever
39
39
  * laid the graph out — an import must not move their boxes. */
40
40
  const TOPIC_FIELDS = [
41
- "pkey", "pkey2s", "system_flag", "tkey", "topic_version", "system_topic"
41
+ "pkey", "pkey2s", "system_flag", "tkey", "topic_version", "system_topic",
42
+ "main_topic"
42
43
  ];
43
44
 
44
45
  /* Same for a `cols` record. */
@@ -183,7 +183,7 @@ function sort_by_order(list)
183
183
  * schema_version, c_schema_version, system_schema_version,
184
184
  * topics: [{
185
185
  * id, name, order, record,
186
- * pkey, topic_version, system_topic,
186
+ * pkey, topic_version, system_topic, main_topic,
187
187
  * cols: [{id, name, order, record}]
188
188
  * }]
189
189
  * }],
@@ -239,6 +239,7 @@ function build_schema_model(records)
239
239
  tkey: record.tkey || "",
240
240
  topic_version: record.topic_version,
241
241
  system_topic: !!record.system_topic,
242
+ main_topic: !!record.main_topic,
242
243
  cols: []
243
244
  };
244
245
  by_topic_id[entry.id] = entry;
@@ -47,7 +47,7 @@ const DEFAULT_PAD = 68;
47
47
  * and every export diffs against the last one. */
48
48
  const TOPIC_KEY_ORDER = [
49
49
  "id", "pkey", "pkey2s", "system_flag", "tkey",
50
- "topic_version", "system_topic"
50
+ "topic_version", "system_topic", "main_topic"
51
51
  ];
52
52
 
53
53
  /* Same for a column, in the order the .c literals write it. */
@@ -189,6 +189,26 @@ function validate_schema(treedb, options)
189
189
  }
190
190
  }
191
191
 
192
+ /* The topic the tree hangs from (`main_topic`): ONE per treedb, and
193
+ * only a topic hooked to itself. treedb_open_db logs either mistake
194
+ * and ignores the mark, so the restart succeeds and the graph goes
195
+ * on deducing -- better said here, before it. */
196
+ let marked = treedb.topics.filter((topic) => topic.main_topic);
197
+ if(marked.length > 1) {
198
+ add("error", "more than one main topic", marked[1].name, "",
199
+ marked.map((topic) => topic.name).join(", "));
200
+ }
201
+ for(let topic of marked) {
202
+ let hooks_itself = topic.cols.some((col) => {
203
+ let hook = col_hook(col.record);
204
+ return col_flags(col.record).indexOf("hook") >= 0 &&
205
+ !!hook && Object.prototype.hasOwnProperty.call(hook, topic.name);
206
+ });
207
+ if(!hooks_itself) {
208
+ add("error", "main topic not hooked to itself", topic.name, "", "");
209
+ }
210
+ }
211
+
192
212
  /* How many hooks name each fkey column. Both answers are a
193
213
  * finding: NONE and the parent side of the link was renamed or
194
214
  * deleted, so the references are written by nobody; TWO and the
@@ -94,6 +94,50 @@ describe("the topic will not open", () => {
94
94
  });
95
95
  });
96
96
 
97
+ describe("the main topic mark", () => {
98
+ /* departments hooked to itself: the one topic the mark can go on */
99
+ const self_hook = {
100
+ id: "db.departments.departments", value: "departments", order: 3,
101
+ type: "dict", flag: ["hook"], hook: {departments: "departments"},
102
+ topics: ["topics^db.departments^cols"]
103
+ };
104
+
105
+ test("on a topic hooked to itself, says nothing", () => {
106
+ const t = schema({extra_col: self_hook});
107
+ t.topics[0].main_topic = true;
108
+ const c = codes(validate_schema(t));
109
+ expect(c).not.toContain("main topic not hooked to itself");
110
+ expect(c).not.toContain("more than one main topic");
111
+ });
112
+
113
+ test("on a topic that is not hooked to itself", () => {
114
+ const t = schema();
115
+ t.topics[1].main_topic = true;
116
+ const f = validate_schema(t);
117
+ expect(codes(f)).toContain("main topic not hooked to itself");
118
+ expect(has_errors(f)).toBe(true);
119
+ });
120
+
121
+ test("on two topics", () => {
122
+ const t = schema({extra_col: self_hook});
123
+ t.topics[0].main_topic = true;
124
+ t.topics[1].main_topic = true;
125
+ const f = validate_schema(t);
126
+ expect(codes(f)).toContain("more than one main topic");
127
+ expect(f.find((x) => x.code === "more than one main topic").detail)
128
+ .toBe("departments, users");
129
+ });
130
+
131
+ test("the model reads the mark from the record", () => {
132
+ const model = build_schema_model({
133
+ treedbs: [{id: "db"}],
134
+ topics: [{id: "db.a", value: "a", main_topic: true, treedbs: ["treedbs^db^topics"]}],
135
+ cols: [],
136
+ });
137
+ expect(model.treedbs[0].topics[0].main_topic).toBe(true);
138
+ });
139
+ });
140
+
97
141
  describe("the link does nothing, and the write succeeded", () => {
98
142
  test("a hook with no mapping", () => {
99
143
  expect(codes(validate_schema(schema({hook: null})))).toContain("hook has no mapping");
@@ -152,7 +152,13 @@ export function fold_build_model(descs, records, opts)
152
152
 
153
153
  let hidden = new Set(is_array(opts.hidden_topics)? opts.hidden_topics : []);
154
154
  let loose_shown = new Set(is_array(opts.loose_topics)? opts.loose_topics : []);
155
- let main_topic = opts.main_topic || fold_main_topic(descs);
155
+ let main_topic = opts.main_topic || "";
156
+ if(main_topic && !fold_is_hierarchical(descs, main_topic)) {
157
+ main_topic = ""; /* a pick the rule does not allow (see fold_main_topic): deduce */
158
+ }
159
+ if(!main_topic) {
160
+ main_topic = fold_main_topic(descs);
161
+ }
156
162
  if(main_topic && (!descs[main_topic] || hidden.has(main_topic))) {
157
163
  main_topic = ""; /* a main topic that is not drawn governs nothing */
158
164
  }
@@ -355,34 +361,60 @@ function count_levels(model)
355
361
  }
356
362
 
357
363
  /************************************************************
358
- * The topic the schema hangs the others from: the one whose hooks
359
- * reach the most OTHER topics, a self-referent hook breaking a tie
360
- * (a tree of places over a flat list of groups), schema order
361
- * breaking the rest. "" when no topic reaches another -- a treedb of
362
- * unrelated lists has no trunk, and every topic is a tree of its own.
364
+ * A HIERARCHICAL topic: one hooked to ITSELF -- places inside
365
+ * places, groups inside groups, roles inside roles. It is the
366
+ * only kind of topic a tree can hang from: a topic that only
367
+ * reaches OTHER topics is a list with children, and making it
368
+ * the trunk drew the agent's treedb from `yunos` instead of
369
+ * `realms`.
370
+ *
371
+ * (Not the engine's `entity` tier, which is any topic with
372
+ * hooks AND fkeys: that one sizes a card and says nothing about
373
+ * where a tree starts.)
374
+ ************************************************************/
375
+ export function fold_is_hierarchical(descs, topic_name)
376
+ {
377
+ if(!is_object(descs) || !descs[topic_name]) {
378
+ return false;
379
+ }
380
+ return hook_targets(descs, topic_name).has(topic_name);
381
+ }
382
+
383
+ /************************************************************
384
+ * The topic the tree hangs from. Only a HIERARCHICAL topic can
385
+ * be it, and in this order:
386
+ *
387
+ * the one the schema MARKS (`main_topic: true` on the topic,
388
+ * sent by the backend in its desc since SDK 7.19; the first
389
+ * one in schema order if a schema marks two);
390
+ * else the one whose hooks reach the most OTHER topics,
391
+ * schema order breaking a tie.
392
+ *
393
+ * "" when no topic is hooked to itself: there is no trunk, and
394
+ * every topic is a tree of its own. (A pick by the reader comes
395
+ * first of all, and is fold_build_model's business.)
363
396
  ************************************************************/
364
397
  export function fold_main_topic(descs)
365
398
  {
366
399
  descs = is_object(descs)? descs : {};
367
- let best = "";
368
- let best_score = 0;
369
- let best_self = false;
400
+ let candidates = Object.keys(descs).filter((topic_name) => {
401
+ return topic_name.substring(0, 2) !== "__" && fold_is_hierarchical(descs, topic_name);
402
+ });
370
403
 
371
- for(let topic_name of Object.keys(descs)) {
372
- if(topic_name.substring(0, 2) === "__") {
373
- continue;
404
+ for(let topic_name of candidates) {
405
+ if(descs[topic_name].main_topic === true) {
406
+ return topic_name;
374
407
  }
408
+ }
409
+
410
+ let best = "";
411
+ let best_score = -1;
412
+ for(let topic_name of candidates) {
375
413
  let reach = hook_targets(descs, topic_name);
376
- let self = reach.has(topic_name);
377
414
  reach.delete(topic_name);
378
- let score = reach.size;
379
- if(score === 0) {
380
- continue;
381
- }
382
- if(score > best_score || (score === best_score && self && !best_self)) {
415
+ if(reach.size > best_score) {
383
416
  best = topic_name;
384
- best_score = score;
385
- best_self = self;
417
+ best_score = reach.size;
386
418
  }
387
419
  }
388
420
  return best;
@@ -31,11 +31,12 @@ import {
31
31
  fold_open_levels,
32
32
  fold_close_levels,
33
33
  fold_expand_to_level,
34
+ fold_is_hierarchical,
34
35
  } from "./treedb_fold_model.js";
35
36
 
36
37
  const places_desc = {
37
38
  topic_name: "places",
38
- node_treedb_type: "hierarchical",
39
+ node_treedb_type: "entity",
39
40
  cols: [
40
41
  {id: "id", flag: []},
41
42
  {id: "place", flag: ["fkey"], fkey: {places: "places"}},
@@ -56,13 +57,20 @@ const devices_desc = {
56
57
  };
57
58
  const controllers_desc = {
58
59
  topic_name: "controllers",
59
- node_treedb_type: "hierarchical",
60
+ node_treedb_type: "entity",
60
61
  cols: [
61
62
  {id: "id", flag: []},
62
63
  {id: "place", flag: ["fkey"], fkey: {places: "controllers"}},
63
64
  {id: "devices", flag: ["hook"], hook: {devices: "controller"}},
64
65
  ],
65
66
  };
67
+ /* controllers inside controllers: a second HIERARCHICAL topic */
68
+ const controllers_self = Object.assign({}, controllers_desc, {
69
+ cols: controllers_desc.cols.concat([
70
+ {id: "controllers", flag: ["hook"], hook: {controllers: "parent"}},
71
+ {id: "parent", flag: ["fkey"], fkey: {controllers: "controllers"}},
72
+ ]),
73
+ });
66
74
  const groups_desc = {
67
75
  topic_name: "device_groups",
68
76
  node_treedb_type: "extended",
@@ -189,10 +197,33 @@ describe("main topic, hidden topics, loose records", () => {
189
197
  expect(m.linked.has("device_groups")).toBe(false);
190
198
  });
191
199
 
200
+ test("only a topic hooked to ITSELF can be the main one", () => {
201
+ expect(fold_is_hierarchical(descs, "places")).toBe(true);
202
+ expect(fold_is_hierarchical(descs, "controllers")).toBe(false);
203
+ expect(fold_is_hierarchical(descs, "device_groups")).toBe(false);
204
+ /* c reaches x and nothing reaches itself: no trunk at all */
205
+ let c = {topic_name: "c", cols: [{id: "id", flag: []},
206
+ {id: "xs", flag: ["hook"], hook: {x: "c"}}]};
207
+ let x = {topic_name: "x", cols: [{id: "id", flag: []}, {id: "c", flag: ["fkey"]}]};
208
+ expect(fold_main_topic({c: c, x: x})).toBe("");
209
+ /* a tree of places alone, reaching no other topic, is still a trunk */
210
+ expect(fold_main_topic({places: places_desc})).toBe("places");
211
+ });
212
+
213
+ test("the schema's mark wins over the deduction, among hierarchical topics", () => {
214
+ let d = Object.assign({}, descs, {controllers: controllers_self});
215
+ expect(fold_main_topic(d)).toBe("places"); /* places reaches more */
216
+ let marked = Object.assign({}, d, {controllers: Object.assign({}, controllers_self, {main_topic: true})});
217
+ expect(fold_main_topic(marked)).toBe("controllers");
218
+ /* a mark on a topic that is not hierarchical is not a trunk */
219
+ let wrong = Object.assign({}, descs, {devices: Object.assign({}, devices_desc, {main_topic: true})});
220
+ expect(fold_main_topic(wrong)).toBe("places");
221
+ });
222
+
192
223
  test("a self-referent hook breaks a tie", () => {
193
224
  let a = {topic_name: "a", node_treedb_type: "extended",
194
225
  cols: [{id: "id", flag: []}, {id: "xs", flag: ["hook"], hook: {x: "a"}}]};
195
- let b = {topic_name: "b", node_treedb_type: "hierarchical",
226
+ let b = {topic_name: "b", node_treedb_type: "entity",
196
227
  cols: [{id: "id", flag: []}, {id: "xs", flag: ["hook"], hook: {x: "b"}},
197
228
  {id: "bs", flag: ["hook"], hook: {b: "parent"}},
198
229
  {id: "parent", flag: ["fkey"], fkey: {b: "bs"}}]};
@@ -201,12 +232,16 @@ describe("main topic, hidden topics, loose records", () => {
201
232
  expect(fold_main_topic({x: x})).toBe("");
202
233
  });
203
234
 
204
- test("an explicit main topic wins over the deduced one", () => {
205
- let m = fold_build_model(descs, make_records(3), {main_topic: "controllers"});
235
+ test("the reader's pick wins -- if it is a hierarchical topic", () => {
236
+ let d = Object.assign({}, descs, {controllers: controllers_self});
237
+ let m = fold_build_model(d, make_records(3), {main_topic: "controllers"});
206
238
  expect(m.main_topic).toBe("controllers");
207
239
  expect([...m.linked].sort()).toEqual(["controllers", "devices"]);
208
240
  /* places is not linked to controllers, so its roots are roots */
209
241
  expect(m.groups.get(fold_root_group_key("places"))).toEqual([K("places", "es")]);
242
+ /* a pick the rule refuses is ignored, and the trunk is deduced */
243
+ let m2 = fold_build_model(descs, make_records(3), {main_topic: "controllers"});
244
+ expect(m2.main_topic).toBe("places");
210
245
  });
211
246
 
212
247
  test("a device with no place is LOOSE: counted, not a root, unless asked for", () => {