@yuneta/gobj-ui 6.0.0 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,8 +31,10 @@ import {
31
31
  SDATA,
32
32
  SDATA_END,
33
33
  data_type_t,
34
+ event_flag_t,
34
35
  gclass_create,
35
36
  log_error,
37
+ gobj_publish_event,
36
38
  gobj_read_pointer_attr,
37
39
  gobj_subscribe_event,
38
40
  gobj_send_event,
@@ -615,13 +617,28 @@ function destroy_graph(gobj)
615
617
 
616
618
 
617
619
  /************************************************************
618
- * A topic node was clicked: open its table via the host route.
620
+ * A topic node was clicked.
621
+ *
622
+ * With a `node_route` the click IS a navigation and this view
623
+ * makes it: that is what the route was handed down for.
624
+ *
625
+ * Without one it is still an action, and it belongs to whoever
626
+ * mounted this view — the schema editor draws the same picture
627
+ * inside its own screens, where a topic opens in place and no
628
+ * hash is involved. So the click is published rather than
629
+ * dropped. A host that subscribes must DECLARE EV_NODE_CLICK in
630
+ * its own FSM, as with every event a child publishes.
619
631
  ************************************************************/
620
632
  function ac_node_click(gobj, event, kw, src)
621
633
  {
622
634
  let topic = kw && kw.node_id;
623
635
  let route = gobj_read_str_attr(gobj, "node_route");
624
- if(!topic || !route) {
636
+
637
+ if(!topic) {
638
+ return 0;
639
+ }
640
+ if(!route) {
641
+ gobj_publish_event(gobj, "EV_NODE_CLICK", {topic: topic});
625
642
  return 0;
626
643
  }
627
644
  let href = route.replace("{topic}", topic);
@@ -749,7 +766,9 @@ function create_gclass(gclass_name)
749
766
  ];
750
767
 
751
768
  const event_types = [
752
- ["EV_NODE_CLICK", 0],
769
+ /* Sent to itself by the graph, and published to the host when no
770
+ * `node_route` was given: optional there, so no warning. */
771
+ ["EV_NODE_CLICK", event_flag_t.EVF_OUTPUT_EVENT|event_flag_t.EVF_NO_WARN_SUBS],
753
772
  ["EV_SHOW", 0],
754
773
  ["EV_THEME", 0],
755
774
  ["EV_REBUILD", 0],
@@ -0,0 +1,172 @@
1
+ /***********************************************************************
2
+ * schema_descs.js
3
+ *
4
+ * The schema being EDITED, in the shape every treedb view already
5
+ * draws.
6
+ *
7
+ * `C_YUI_TREEDB_SCHEMA` draws a `descs` — `{topic_name: desc}` —
8
+ * and gets one from the treedb it opened. In the schema editor
9
+ * that treedb is `treedb_system_schema`, so the picture it draws
10
+ * is the META schema (treedbs -> topics -> cols): three cards,
11
+ * the same three on every yuno, and never the schema the operator
12
+ * came to look at.
13
+ *
14
+ * What the operator is editing is stored as RECORDS, and this
15
+ * turns those records back into a desc. The drawing then costs no
16
+ * backend call at all, and it follows an edit immediately —
17
+ * before the restart that publishes it, which is exactly when it
18
+ * is worth looking at.
19
+ *
20
+ * THE ONE THING THAT IS NOT A COPY: `fkey`. A schema literal
21
+ * declares the link ONCE, on the parent's `hook`; the treedb
22
+ * derives `col.fkey = {parent_topic: hook_name}` on the child's
23
+ * column when it opens the schema (tr_treedb.c). Views read that
24
+ * derived field — `fkey_in_col()`, the info panel's "→", the
25
+ * graph's edges — so a desc built without it draws every link
26
+ * half missing.
27
+ *
28
+ * Copyright (c) 2026, ArtGins.
29
+ * All Rights Reserved.
30
+ ***********************************************************************/
31
+ import {
32
+ col_flags,
33
+ col_hook,
34
+ col_enum,
35
+ topic_pkey2s,
36
+ as_json,
37
+ is_empty_value,
38
+ } from "./schema_model.js";
39
+
40
+
41
+ /* Storage-only fields of a `cols` record: they describe where the
42
+ * record LIVES in treedb_system_schema, not the column it declares.
43
+ * The C validator drops the same ones (_treedb_create_topic_cols_desc). */
44
+ const COL_STORAGE_FIELDS = ["id", "value", "topics", "order", "_geometry", "__md_treedb__"];
45
+
46
+ /* Same, one level up, for a `topics` record. */
47
+ const TOPIC_STORAGE_FIELDS = ["id", "value", "treedbs", "cols", "order", "_geometry",
48
+ "__md_treedb__"];
49
+
50
+ /* Fields of a column whose stored form is TEXT and whose desc form is
51
+ * the value: they are `blob` columns of the `cols` topic. */
52
+ const COL_JSON_FIELDS = ["enum", "hook", "default", "template", "properties", "pkey2s"];
53
+
54
+
55
+ /***************************************************************
56
+ * One column record -> the col of a desc.
57
+ *
58
+ * The desc keys a column by its BARE name under `id`; the record
59
+ * keys it by the qualified one and carries the bare name in
60
+ * `value`. Getting that backwards names every column after its
61
+ * whole path.
62
+ ***************************************************************/
63
+ function col_desc(col)
64
+ {
65
+ let record = col.record || {};
66
+ let desc = {};
67
+
68
+ for(let [key, value] of Object.entries(record)) {
69
+ if(COL_STORAGE_FIELDS.indexOf(key) >= 0) {
70
+ continue;
71
+ }
72
+ if(is_empty_value(value)) {
73
+ continue;
74
+ }
75
+ let read = COL_JSON_FIELDS.indexOf(key) >= 0 ? as_json(value) : value;
76
+ if(is_empty_value(read)) {
77
+ continue;
78
+ }
79
+ desc[key] = read;
80
+ }
81
+ desc.id = col.name;
82
+ desc.flag = col_flags(record);
83
+ let hook = col_hook(record);
84
+ if(hook && !is_empty_value(hook)) {
85
+ desc.hook = hook;
86
+ } else {
87
+ delete desc.hook;
88
+ }
89
+ let e = col_enum(record);
90
+ if(e.length > 0) {
91
+ desc.enum = e;
92
+ }
93
+ return desc;
94
+ }
95
+
96
+ /***************************************************************
97
+ * topic_descs(treedb) -> {topic_name: desc}
98
+ *
99
+ * treedb one entry of build_schema_model().treedbs
100
+ ***************************************************************/
101
+ function topic_descs(treedb)
102
+ {
103
+ let descs = {};
104
+
105
+ if(!treedb || !Array.isArray(treedb.topics)) {
106
+ return descs;
107
+ }
108
+
109
+ for(let topic of treedb.topics) {
110
+ let record = topic.record || {};
111
+ let desc = {};
112
+ for(let [key, value] of Object.entries(record)) {
113
+ if(TOPIC_STORAGE_FIELDS.indexOf(key) >= 0) {
114
+ continue;
115
+ }
116
+ if(is_empty_value(value)) {
117
+ continue;
118
+ }
119
+ desc[key] = value;
120
+ }
121
+ desc.topic_name = topic.name;
122
+ desc.pkey = topic.pkey || "id";
123
+ let pkey2s = topic_pkey2s(record);
124
+ if(pkey2s.length > 0) {
125
+ desc.pkey2s = pkey2s;
126
+ } else {
127
+ delete desc.pkey2s;
128
+ }
129
+ desc.cols = topic.cols.map(col_desc);
130
+ descs[topic.name] = desc;
131
+ }
132
+
133
+ /* The link the schema declares once, given to the half that does
134
+ * not declare it — the same derivation the treedb makes when it
135
+ * opens a schema. A hook naming a topic or a column that is not
136
+ * there derives nothing: that is the validator's finding, not a
137
+ * reason to draw a broken desc. */
138
+ for(let topic of treedb.topics) {
139
+ let desc = descs[topic.name];
140
+ for(let col of desc.cols) {
141
+ let hook = col.hook;
142
+ if(!hook || typeof hook !== "object") {
143
+ continue;
144
+ }
145
+ for(let [child_topic, fkey_col] of Object.entries(hook)) {
146
+ let child = descs[child_topic];
147
+ if(!child) {
148
+ continue;
149
+ }
150
+ for(let child_col of child.cols) {
151
+ if(child_col.id !== fkey_col) {
152
+ continue;
153
+ }
154
+ if(!child_col.fkey || typeof child_col.fkey !== "object") {
155
+ child_col.fkey = {};
156
+ }
157
+ child_col.fkey[topic.name] = col.id;
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ return descs;
164
+ }
165
+
166
+
167
+ export {
168
+ COL_STORAGE_FIELDS,
169
+ TOPIC_STORAGE_FIELDS,
170
+ col_desc,
171
+ topic_descs,
172
+ };
@@ -0,0 +1,170 @@
1
+ /***********************************************************************
2
+ * schema_descs.test.js
3
+ *
4
+ * The records drawn as the schema they describe, pinned.
5
+ *
6
+ * The half worth testing is the derived `fkey`: it is declared
7
+ * nowhere in the store, every view reads it, and a desc built
8
+ * without it draws links that are simply not there.
9
+ ***********************************************************************/
10
+ import { describe, test, expect } from "vitest";
11
+ import { build_schema_model } from "./schema_model.js";
12
+ import { topic_descs, col_desc } from "./schema_descs.js";
13
+
14
+
15
+ const MODEL = build_schema_model({
16
+ treedbs: [{id: "db", schema_version: 4}],
17
+ topics: [
18
+ {id: "db.departments", value: "departments", order: 1, pkey: "id",
19
+ topic_version: 3, system_flag: "sf_string_key", treedbs: ["treedbs^db^topics"]},
20
+ {id: "db.users", value: "users", order: 2, pkey: "id", topic_version: 5,
21
+ pkey2s: "name", treedbs: ["treedbs^db^topics"]},
22
+ ],
23
+ cols: [
24
+ {id: "db.departments.id", value: "id", order: 1, type: "string",
25
+ header: "Id", flag: ["persistent", "required"], topics: ["topics^db.departments^cols"]},
26
+ {id: "db.departments.users", value: "users", order: 2, type: "dict",
27
+ header: "Users", flag: ["hook"], hook: {users: "departments"},
28
+ topics: ["topics^db.departments^cols"]},
29
+ {id: "db.users.id", value: "id", order: 1, type: "string", header: "Id",
30
+ flag: ["persistent"], topics: ["topics^db.users^cols"]},
31
+ {id: "db.users.name", value: "name", order: 2, type: "string", header: "Name",
32
+ flag: ["persistent"], topics: ["topics^db.users^cols"]},
33
+ {id: "db.users.departments", value: "departments", order: 3, type: "array",
34
+ header: "Departments", flag: ["fkey"], topics: ["topics^db.users^cols"]},
35
+ ],
36
+ });
37
+
38
+
39
+ describe("topic_descs", () => {
40
+ const descs = topic_descs(MODEL.treedbs[0]);
41
+
42
+ test("one desc per topic, keyed by the topic NAME", () => {
43
+ expect(Object.keys(descs).sort()).toEqual(["departments", "users"]);
44
+ expect(descs.users.topic_name).toBe("users");
45
+ });
46
+
47
+ test("a column is keyed by its bare name, not by its whole path", () => {
48
+ expect(descs.users.cols.map(c => c.id)).toEqual(["id", "name", "departments"]);
49
+ });
50
+
51
+ test("the columns keep their declared order", () => {
52
+ expect(descs.departments.cols.map(c => c.id)).toEqual(["id", "users"]);
53
+ });
54
+
55
+ test("topic metadata the views read travels", () => {
56
+ expect(descs.users.topic_version).toBe(5);
57
+ expect(descs.users.pkey).toBe("id");
58
+ expect(descs.departments.system_flag).toBe("sf_string_key");
59
+ });
60
+
61
+ test("a pkey2 declared as a bare string becomes the list the desc uses", () => {
62
+ expect(descs.users.pkey2s).toEqual(["name"]);
63
+ });
64
+
65
+ test("no storage field leaks into the desc", () => {
66
+ for(const key of ["value", "treedbs", "order"]) {
67
+ expect(descs.users[key]).toBeUndefined();
68
+ }
69
+ for(const col of descs.users.cols) {
70
+ for(const key of ["value", "topics", "order"]) {
71
+ expect(col[key]).toBeUndefined();
72
+ }
73
+ }
74
+ });
75
+
76
+ test("the FKEY the store never holds is derived onto the child column", () => {
77
+ const fkey_col = descs.users.cols.find(c => c.id === "departments");
78
+ expect(fkey_col.fkey).toEqual({departments: "users"});
79
+ });
80
+
81
+ test("the hook stays on the parent, whole", () => {
82
+ const hook_col = descs.departments.cols.find(c => c.id === "users");
83
+ expect(hook_col.hook).toEqual({users: "departments"});
84
+ });
85
+ });
86
+
87
+ describe("what a broken hook derives", () => {
88
+ test("a hook naming a topic that is not there derives nothing and throws nothing", () => {
89
+ const model = build_schema_model({
90
+ treedbs: [{id: "db"}],
91
+ topics: [{id: "db.a", value: "a", treedbs: ["treedbs^db^topics"]}],
92
+ cols: [{id: "db.a.x", value: "x", type: "dict", flag: ["hook"],
93
+ hook: {gone: "parent"}, topics: ["topics^db.a^cols"]}],
94
+ });
95
+ const descs = topic_descs(model.treedbs[0]);
96
+ expect(descs.a.cols[0].hook).toEqual({gone: "parent"});
97
+ expect(descs.a.cols[0].fkey).toBeUndefined();
98
+ });
99
+
100
+ test("a hook naming a column that is not there derives nothing", () => {
101
+ const model = build_schema_model({
102
+ treedbs: [{id: "db"}],
103
+ topics: [{id: "db.a", value: "a", order: 1, treedbs: ["treedbs^db^topics"]},
104
+ {id: "db.b", value: "b", order: 2, treedbs: ["treedbs^db^topics"]}],
105
+ cols: [{id: "db.a.x", value: "x", type: "dict", flag: ["hook"],
106
+ hook: {b: "nope"}, topics: ["topics^db.a^cols"]},
107
+ {id: "db.b.y", value: "y", type: "string", topics: ["topics^db.b^cols"]}],
108
+ });
109
+ const descs = topic_descs(model.treedbs[0]);
110
+ expect(descs.b.cols[0].fkey).toBeUndefined();
111
+ });
112
+ });
113
+
114
+ describe("the fields stored as JSON text", () => {
115
+ test("an enum, hook and default written as text come back as values", () => {
116
+ const desc = col_desc({name: "x", record: {
117
+ id: "db.a.x", value: "x", type: "string",
118
+ flag: '["enum","persistent"]',
119
+ enum: '["a","b"]',
120
+ hook: '{"c":"parent"}',
121
+ default: '{"k":1}'
122
+ }});
123
+ expect(desc.flag).toEqual(["enum", "persistent"]);
124
+ expect(desc.enum).toEqual(["a", "b"]);
125
+ expect(desc.hook).toEqual({c: "parent"});
126
+ expect(desc.default).toEqual({k: 1});
127
+ });
128
+
129
+ test("a default that is plain text stays plain text", () => {
130
+ expect(col_desc({name: "x", record: {default: "hello"}}).default).toBe("hello");
131
+ });
132
+
133
+ test("an empty value is left out rather than declared as empty", () => {
134
+ const desc = col_desc({name: "x", record: {type: "string", tkey: "", hook: null}});
135
+ expect("tkey" in desc).toBe(false);
136
+ expect("hook" in desc).toBe(false);
137
+ });
138
+ });
139
+
140
+ describe("edges", () => {
141
+ test("no treedb gives an empty descs, never a throw", () => {
142
+ expect(topic_descs(null)).toEqual({});
143
+ expect(topic_descs({})).toEqual({});
144
+ });
145
+ });
146
+
147
+ describe("the empty collections the store answers with", () => {
148
+ const model = build_schema_model({
149
+ treedbs: [{id: "db"}],
150
+ topics: [{id: "db.t", value: "t", _geometry: {}, treedbs: ["treedbs^db^topics"]}],
151
+ cols: [{id: "db.t.id", value: "id", type: "string",
152
+ enum: {}, hook: {}, default: {}, _geometry: {},
153
+ topics: ["topics^db.t^cols"]}],
154
+ });
155
+ const descs = topic_descs(model.treedbs[0]);
156
+
157
+ test("an unset blob does not become a value in the desc", () => {
158
+ for(const key of ["enum", "hook", "default", "_geometry"]) {
159
+ expect(key in descs.t.cols[0]).toBe(false);
160
+ }
161
+ });
162
+
163
+ test("an empty hook is NOT a hook — the info panel would draw an arrow to nothing", () => {
164
+ expect(descs.t.cols[0].hook).toBeUndefined();
165
+ });
166
+
167
+ test("the topic record's own _geometry does not travel", () => {
168
+ expect("_geometry" in descs.t).toBe(false);
169
+ });
170
+ });
@@ -0,0 +1,237 @@
1
+ /***********************************************************************
2
+ * schema_flags.js
3
+ *
4
+ * WHAT A COLUMN FLAG MEANS, next to the checkbox that sets it.
5
+ *
6
+ * `flag` is the field of a column definition that decides the
7
+ * most and explains the least: an array picked from 30-odd words
8
+ * whose effects live in tr_treedb.c. Edited as a raw array — which
9
+ * is how it is edited today — the difference between `required`
10
+ * and `notnull`, or between `hook` and `fkey`, is something the
11
+ * operator has to already know or find out by restarting a yuno.
12
+ *
13
+ * So the flags are DATA here: grouped the way they act, each with
14
+ * one line of what it does, and each knowing which column types it
15
+ * is meaningful on. The editor draws checkboxes from this table
16
+ * and nothing else knows the list.
17
+ *
18
+ * The descriptions are English sentences used as i18n keys, the
19
+ * same convention as the rest of the library's strings.
20
+ *
21
+ * The catalogue is not a whitelist: a flag this table does not
22
+ * know is still shown and still editable. A newer node may declare
23
+ * one, and a schema editor that silently drops what it does not
24
+ * recognize is worse than one that admits it.
25
+ *
26
+ * Copyright (c) 2026, ArtGins.
27
+ * All Rights Reserved.
28
+ ***********************************************************************/
29
+
30
+ /* Every column type, for the flags that are meaningful on all of them. */
31
+ const ANY = null;
32
+
33
+ /* The groups, in the order the editor draws them: what the column IS
34
+ * before what it looks like. */
35
+ const FLAG_GROUPS = ["storage", "validation", "access", "relation", "key", "stats", "format"];
36
+
37
+ /* name the word written into `flag`
38
+ * group which block it is drawn in
39
+ * desc one line of what it does (an i18n key)
40
+ * types the column types it is meaningful on, or ANY */
41
+ const FLAG_CATALOG = [
42
+ /* ---- storage ---- */
43
+ {name: "persistent", group: "storage", types: ANY,
44
+ desc: "saved to disk; a column without it lives only while the yuno runs"},
45
+ {name: "wild", group: "storage", types: ANY,
46
+ desc: "convert a value of another type instead of refusing it"},
47
+ {name: "inherit", group: "storage", types: ANY,
48
+ desc: "copied from the primary record to its other instances"},
49
+
50
+ /* ---- validation ---- */
51
+ {name: "required", group: "validation", types: ANY,
52
+ desc: "the field must be present when the record is written"},
53
+ {name: "notnull", group: "validation", types: ANY,
54
+ desc: "the field may be absent, but never null"},
55
+ {name: "enum", group: "validation", types: ["string", "array", "list"],
56
+ desc: "the value must be one of the `enum` list"},
57
+ {name: "template", group: "validation", types: ANY,
58
+ desc: "the value is built from the `template` field"},
59
+
60
+ /* ---- access ---- */
61
+ {name: "writable", group: "access", types: ANY,
62
+ desc: "may be changed after the record is created"},
63
+ {name: "readable", group: "access", types: ANY,
64
+ desc: "may be read back"},
65
+ {name: "hidden", group: "access", types: ANY,
66
+ desc: "left out of listings"},
67
+
68
+ /* ---- relation ---- */
69
+ {name: "hook", group: "relation", types: ["dict", "object", "list", "array"],
70
+ desc: "holds the children this record owns; needs a `hook` mapping"},
71
+ {name: "fkey", group: "relation", types: ["string", "dict", "object", "list", "array"],
72
+ desc: "holds the reference to a parent; written by the parent's hook"},
73
+
74
+ /* ---- key ---- */
75
+ {name: "rowid", group: "key", types: ["string", "integer"],
76
+ desc: "key generated as a counter when none is given"},
77
+ {name: "uuid", group: "key", types: ["string"],
78
+ desc: "key generated as a uuid when none is given"},
79
+ {name: "qualified", group: "key", types: ["string"],
80
+ desc: "key composed as the parent's id plus this record's name"},
81
+ {name: "id", group: "key", types: ["string"],
82
+ desc: "the value is an identifier of something else"},
83
+
84
+ /* ---- stats ---- */
85
+ {name: "stats", group: "stats", types: ["integer", "real"],
86
+ desc: "a counter, reported in the yuno statistics"},
87
+ {name: "rstats", group: "stats", types: ["integer", "real"],
88
+ desc: "a counter read through mt_reading when it is asked for"},
89
+ {name: "pstats", group: "stats", types: ["integer", "real"],
90
+ desc: "a counter kept across restarts"},
91
+
92
+ /* ---- format ---- */
93
+ {name: "time", group: "format", types: ["integer", "string"],
94
+ desc: "the value is a timestamp"},
95
+ {name: "now", group: "format", types: ["integer"],
96
+ desc: "stamped with the current time when the record is created"},
97
+ {name: "date", group: "format", types: ["integer", "string"], desc: "a date"},
98
+ {name: "password", group: "format", types: ["string"],
99
+ desc: "shown masked, and never echoed back"},
100
+ {name: "email", group: "format", types: ["string"], desc: "an email address"},
101
+ {name: "url", group: "format", types: ["string"], desc: "a url"},
102
+ {name: "tel", group: "format", types: ["string"], desc: "a telephone number"},
103
+ {name: "color", group: "format", types: ["string"], desc: "a colour"},
104
+ {name: "image", group: "format", types: ["string"], desc: "an image"},
105
+ {name: "coordinates", group: "format", types: ["string", "array", "list"],
106
+ desc: "a geographic position"},
107
+ {name: "currency", group: "format", types: ["integer", "real"], desc: "an amount of money"},
108
+ {name: "percent", group: "format", types: ["integer", "real"], desc: "a percentage"},
109
+ {name: "hex", group: "format", types: ["integer", "string"], desc: "written in hexadecimal"},
110
+ {name: "binary", group: "format", types: ["string", "blob"], desc: "binary content"},
111
+ {name: "base64", group: "format", types: ["string", "blob"], desc: "encoded in base64"},
112
+ {name: "gbuffer", group: "format", types: ["blob"], desc: "carried as a gbuffer"},
113
+ {name: "table", group: "format", types: ["array", "list", "dict", "object"],
114
+ desc: "drawn as a table"},
115
+ ];
116
+
117
+ /* Flags that decide the SHAPE of the record and cannot both be on the
118
+ * same column: the treedb writes one half of a link, never both. */
119
+ const EXCLUSIVE = [["hook", "fkey"], ["rowid", "uuid", "qualified"]];
120
+
121
+
122
+ /***************************************************************
123
+ * flags_for_type(type) -> [{name, group, desc, meaningful}]
124
+ *
125
+ * The whole catalogue, every entry saying whether it is
126
+ * meaningful on this type. Not filtered: a flag already SET on
127
+ * a column of another type has to stay visible, or the editor
128
+ * silently drops it on the next save.
129
+ ***************************************************************/
130
+ function flags_for_type(type)
131
+ {
132
+ return FLAG_CATALOG.map((flag) => {
133
+ return {
134
+ name: flag.name,
135
+ group: flag.group,
136
+ desc: flag.desc,
137
+ meaningful: flag.types === ANY || flag.types.indexOf(type) >= 0
138
+ };
139
+ });
140
+ }
141
+
142
+ /***************************************************************
143
+ * The catalogue as the editor draws it: by group, plus a last
144
+ * group holding whatever this column carries that the catalogue
145
+ * does not know.
146
+ *
147
+ * grouped_flags(type, current) -> [{group, flags: [...]}]
148
+ ***************************************************************/
149
+ function grouped_flags(type, current)
150
+ {
151
+ let set = Array.isArray(current) ? current : [];
152
+ let known = {};
153
+ let by_group = {};
154
+
155
+ for(let flag of flags_for_type(type)) {
156
+ known[flag.name] = true;
157
+ if(!by_group[flag.group]) {
158
+ by_group[flag.group] = [];
159
+ }
160
+ by_group[flag.group].push(Object.assign({on: set.indexOf(flag.name) >= 0}, flag));
161
+ }
162
+
163
+ let out = [];
164
+ for(let group of FLAG_GROUPS) {
165
+ if(by_group[group]) {
166
+ out.push({group: group, flags: by_group[group]});
167
+ }
168
+ }
169
+
170
+ let unknown = set.filter(f => typeof f === "string" && f.length > 0 && !known[f]);
171
+ if(unknown.length > 0) {
172
+ out.push({
173
+ group: "other",
174
+ flags: unknown.map(name => ({
175
+ name: name, group: "other", desc: "", meaningful: true, on: true
176
+ }))
177
+ });
178
+ }
179
+ return out;
180
+ }
181
+
182
+ /***************************************************************
183
+ * Turning a flag on may turn another off. Returns the flag list
184
+ * the change produces, so the caller never has to know which
185
+ * pairs exclude each other.
186
+ *
187
+ * toggle_flag(current, name, on) -> [flag, ...]
188
+ ***************************************************************/
189
+ function toggle_flag(current, name, on)
190
+ {
191
+ let set = (Array.isArray(current) ? current : [])
192
+ .filter(f => typeof f === "string" && f.length > 0);
193
+
194
+ if(!on) {
195
+ return set.filter(f => f !== name);
196
+ }
197
+ let excluded = {};
198
+ for(let group of EXCLUSIVE) {
199
+ if(group.indexOf(name) < 0) {
200
+ continue;
201
+ }
202
+ for(let other of group) {
203
+ if(other !== name) {
204
+ excluded[other] = true;
205
+ }
206
+ }
207
+ }
208
+ let out = set.filter(f => !excluded[f]);
209
+ if(out.indexOf(name) < 0) {
210
+ out.push(name);
211
+ }
212
+ return out;
213
+ }
214
+
215
+ /***************************************************************
216
+ * What a flag does, or "" when the catalogue does not know it.
217
+ ***************************************************************/
218
+ function flag_description(name)
219
+ {
220
+ for(let flag of FLAG_CATALOG) {
221
+ if(flag.name === name) {
222
+ return flag.desc;
223
+ }
224
+ }
225
+ return "";
226
+ }
227
+
228
+
229
+ export {
230
+ FLAG_GROUPS,
231
+ FLAG_CATALOG,
232
+ EXCLUSIVE,
233
+ flags_for_type,
234
+ grouped_flags,
235
+ toggle_flag,
236
+ flag_description,
237
+ };