@yuneta/gobj-ui 7.23.185 → 7.23.187

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.
@@ -77,6 +77,7 @@ import {
77
77
  kw_get_dict_value,
78
78
  kw_set_dict_value,
79
79
  kw_clone_by_keys,
80
+ json_deep_copy,
80
81
  json_object_update,
81
82
  json_object_update_missing,
82
83
  json_object_size,
@@ -91,6 +92,11 @@ import {
91
92
  safeSrc,
92
93
  } from "@yuneta/gobj-js";
93
94
 
95
+ import {
96
+ plan_graph_saves,
97
+ topic_arrangement_changed,
98
+ } from "./graph_save_plan.js";
99
+
94
100
  import {
95
101
  addClasses,
96
102
  removeClasses,
@@ -483,6 +489,7 @@ let PRIVATE_DATA = {
483
489
  graph: null, // Instance of G6
484
490
  __graphs__: [], // Rows of __graphs__
485
491
  _graph_properties: {}, // topic_name → {nodes: {node_id: {x,y,size,...}}}
492
+ _saved_graph_properties: {}, // the same, as the BACKEND has it: what a Save compares against
486
493
  yet_showed: false,
487
494
  edit_mode: false,
488
495
  operation_mode: null,
@@ -960,7 +967,12 @@ function build_graph(gobj)
960
967
  container: priv.$container,
961
968
  animation: false,
962
969
  autoResize: false,
963
- zoomRange: [0.2, 4],
970
+ /* The floor was 0.2, and a treedb of a few hundred records does
971
+ * not fit in a screen at 20%: the reader could see the shape of
972
+ * the whole thing in the minimap and never on the canvas. Fit
973
+ * still stops at a legible zoom on purpose (graph_fit_readable);
974
+ * zooming out by hand is the reader's business. */
975
+ zoomRange: [0.02, 4],
964
976
  node: { // WARNING this affect to all nodes with prevalence over individual defines!
965
977
  palette: {
966
978
  type: 'group',
@@ -2931,6 +2943,7 @@ function draw_link(
2931
2943
  parent_topic: parent_topic,
2932
2944
  parent_id: parent_id,
2933
2945
  hook_name: hook_name,
2946
+ child_topic: child_topic,
2934
2947
  child_id: child_id,
2935
2948
  is_tree: is_tree,
2936
2949
  });
@@ -3051,6 +3064,7 @@ function build_graph_properties(gobj)
3051
3064
  {
3052
3065
  let priv = gobj.priv;
3053
3066
  priv._graph_properties = {};
3067
+ priv._saved_graph_properties = {};
3054
3068
 
3055
3069
  for(let i = 0; i < priv.__graphs__.length; i++) {
3056
3070
  let rec = priv.__graphs__[i];
@@ -3060,6 +3074,11 @@ function build_graph_properties(gobj)
3060
3074
  let props = rec.properties;
3061
3075
  if(is_object(props)) {
3062
3076
  priv._graph_properties[rec.topic] = props;
3077
+ /* A COPY: the line above hands the record's own object to
3078
+ * the view, which then arranges it in place, so anything
3079
+ * kept by reference would change with it and a Save would
3080
+ * never find a difference. */
3081
+ priv._saved_graph_properties[rec.topic] = json_deep_copy(props);
3063
3082
  }
3064
3083
  }
3065
3084
  }
@@ -3205,34 +3224,30 @@ function save_geometry(gobj)
3205
3224
  update_edge_geometry(gobj, edges[i].id);
3206
3225
  }
3207
3226
 
3208
- // Save one __graphs__ record per topic
3209
- let origin = gobj_read_str_attr(gobj_yuno(), "node_uuid");
3210
-
3211
- for(const [topic_name, properties] of Object.entries(priv._graph_properties)) {
3212
- // Add origin metadata
3213
- properties.__origin__ = origin;
3214
-
3215
- let kw_update = {
3216
- treedb_name: priv.treedb_name,
3217
- topic_name: "__graphs__",
3218
- record: {
3219
- id: topic_name,
3220
- topic: topic_name,
3221
- active: true,
3222
- properties: properties
3223
- },
3224
- options: {
3225
- list_dict: true,
3226
- autolink: false,
3227
- create: true // Create if doesn't exist, update if it does
3228
- }
3229
- };
3230
- gobj_publish_event(gobj, "EV_UPDATE_NODE", kw_update);
3227
+ /* One __graphs__ record per topic that CHANGED, and nothing for
3228
+ * the rest. The Save button says that something happened, never
3229
+ * what: it lights from `history.canUndo()` for the moves G6
3230
+ * records and from mark_graph_dirty() for everything it does not
3231
+ * (a colour, a pill, a per-topic default). So the topics are
3232
+ * chosen by comparing what the view holds against what the
3233
+ * backend has -- which sees both kinds of change, and goes on
3234
+ * working after an undo, a redo, or the history.clear() that
3235
+ * ac_save_graph() does one line before calling this.
3236
+ *
3237
+ * The store is append-only: a topic written with the content it
3238
+ * already had is a record that says nothing, for ever. Moving one
3239
+ * card of a five-topic treedb used to append five. */
3240
+ let plan = plan_graph_saves(priv._graph_properties, priv._saved_graph_properties);
3241
+ for(let i = 0; i < plan.length; i++) {
3242
+ save_topic_graph_properties(gobj, plan[i]);
3231
3243
  }
3232
3244
  }
3233
3245
 
3234
3246
  /************************************************************
3235
3247
  * Save __graphs__ properties for a single topic to backend.
3248
+ *
3249
+ * The ONE door to `__graphs__`: a caller that knows a topic moved
3250
+ * says so, and this decides whether the backend needs telling.
3236
3251
  ************************************************************/
3237
3252
  function save_topic_graph_properties(gobj, topic_name)
3238
3253
  {
@@ -3242,9 +3257,30 @@ function save_topic_graph_properties(gobj, topic_name)
3242
3257
  return;
3243
3258
  }
3244
3259
 
3260
+ /* A record that says what the record under it says is a record
3261
+ * that will be read for ever and mean nothing -- the store is
3262
+ * append-only. It reached here by two doors: the Save, which
3263
+ * wrote every topic the view had loaded, and the delete of a
3264
+ * node that carried no geometry of its own. */
3265
+ if(!topic_arrangement_changed(properties, priv._saved_graph_properties[topic_name])) {
3266
+ return;
3267
+ }
3268
+
3245
3269
  let origin = gobj_read_str_attr(gobj_yuno(), "node_uuid");
3246
3270
  properties.__origin__ = origin;
3247
3271
 
3272
+ /* What the backend will hold once this is written. Taken BEFORE
3273
+ * the publish and as a copy, because the object below travels by
3274
+ * reference and goes on being arranged after the write.
3275
+ *
3276
+ * Nothing answers an `EV_UPDATE_NODE` of `__graphs__` -- the view
3277
+ * writes that topic itself and is deliberately not told about it
3278
+ * (see the EV_RECORD_WRITTEN of C_YUI_TREEDB_GRAPH) -- so this is
3279
+ * the write taken for granted. A refused one leaves the view
3280
+ * believing the backend holds an arrangement it does not, until
3281
+ * the next load reads the records again. */
3282
+ priv._saved_graph_properties[topic_name] = json_deep_copy(properties);
3283
+
3248
3284
  let kw_update = {
3249
3285
  treedb_name: priv.treedb_name,
3250
3286
  topic_name: "__graphs__",
@@ -3688,7 +3724,7 @@ async function graph_fit_selection(gobj, ids)
3688
3724
  let bh = Math.max(1, maxy - miny);
3689
3725
 
3690
3726
  let zoom = Math.min(vw / bw, vh / bh);
3691
- let range = graph.getZoomRange() || [0.2, 4];
3727
+ let range = graph.getZoomRange() || [0.02, 4];
3692
3728
  zoom = Math.max(range[0], Math.min(range[1], zoom));
3693
3729
 
3694
3730
  await graph.zoomTo(zoom);
@@ -7765,7 +7801,6 @@ function refresh_default_edges_theme(gobj, theme)
7765
7801
  if(!graph) {
7766
7802
  return;
7767
7803
  }
7768
- let dark = (theme === "dark");
7769
7804
  let edges = graph.getData().edges || [];
7770
7805
  let updates = [];
7771
7806
  for(let i = 0; i < edges.length; i++) {
@@ -7773,10 +7808,7 @@ function refresh_default_edges_theme(gobj, theme)
7773
7808
  if(!d.themed_default) {
7774
7809
  continue;
7775
7810
  }
7776
- let stroke = d.is_tree
7777
- ? (dark ? '#22a7c2' : '#0e7490')
7778
- : (dark ? '#8b94a3' : '#6b7280');
7779
- updates.push({ id: edges[i].id, style: { stroke: stroke } });
7811
+ updates.push({id: edges[i].id, style: {stroke: default_edge_stroke(gobj, d, theme)}});
7780
7812
  }
7781
7813
  if(updates.length > 0) {
7782
7814
  graph.updateEdgeData(updates);
@@ -8658,26 +8690,60 @@ function apply_edge_properties(gobj, edge_id, lineWidth, stroke, scope)
8658
8690
  deselect_edge(gobj);
8659
8691
  }
8660
8692
 
8693
+ /************************************************************
8694
+ * The colour a link is DRAWN in: the colour of the two knobs it
8695
+ * joins.
8696
+ *
8697
+ * Both ends of a link wear the CHILD topic's colour -- the
8698
+ * parent's hook port is painted with the colour of whatever may
8699
+ * hang there, and the child's fkey port with its own (see
8700
+ * build_ports) -- so the line between them has one obvious
8701
+ * colour, and a reader follows a link by its hue instead of
8702
+ * tracing a grey thread across the canvas.
8703
+ *
8704
+ * It goes through port_rim(): the FILL of a port says which
8705
+ * topic, and a line on the canvas is a graphical object that
8706
+ * needs 3:1 against it, which that mix clears in both themes
8707
+ * (9.11 dark, 3.80 light) while keeping the hue. So a link is
8708
+ * drawn in exactly the colour of the rim of the two knobs it
8709
+ * ends on.
8710
+ *
8711
+ * The containment/tree relation (parent and child of the same
8712
+ * topic) is no longer marked by a teal of its own -- it is that
8713
+ * topic's colour like any other link, and what still tells it
8714
+ * apart is its WIDTH.
8715
+ *
8716
+ * A topic with no colour, or an edge of a graph with no schema
8717
+ * yet, keeps the neutral grey this used to give everything.
8718
+ ************************************************************/
8719
+ function default_edge_stroke(gobj, d, theme)
8720
+ {
8721
+ let priv = gobj.priv;
8722
+ let desc = is_object(priv.descs)? priv.descs[d.child_topic] : null;
8723
+ if(desc && desc.color) {
8724
+ return port_rim(desc.color, theme);
8725
+ }
8726
+ let dark = (theme === "dark");
8727
+ return d.is_tree? (dark? '#22a7c2' : '#0e7490') : (dark? '#8b94a3' : '#6b7280');
8728
+ }
8729
+
8661
8730
  /************************************************************
8662
8731
  * The style an edge INHERITS, before anything saved for the
8663
8732
  * edge itself: its hook's default in the parent topic, else the
8664
8733
  * parent topic's default for every edge, else the library's.
8665
8734
  *
8666
- * The library's: neutral grey, teal for the containment/tree
8667
- * relation (parent and child of the same topic, a
8668
- * self-hierarchy). `themed_default` says the colour is still the
8669
- * library's, so a theme toggle re-themes it and leaves a chosen
8670
- * one alone.
8735
+ * The library's: the colour of the ports it joins (see
8736
+ * default_edge_stroke), and a wider line for the
8737
+ * containment/tree relation. `themed_default` says the colour is
8738
+ * still the library's, so a theme toggle re-themes it and leaves
8739
+ * a chosen one alone.
8671
8740
  ************************************************************/
8672
8741
  function edge_inherited_style_of(gobj, d)
8673
8742
  {
8674
8743
  let priv = gobj.priv;
8675
- let dark = (priv.theme === "dark");
8676
8744
  let style = {
8677
8745
  lineWidth: d.is_tree ? 2 : 1.6,
8678
- stroke: d.is_tree
8679
- ? (dark ? '#22a7c2' : '#0e7490')
8680
- : (dark ? '#8b94a3' : '#6b7280'),
8746
+ stroke: default_edge_stroke(gobj, d, priv.theme),
8681
8747
  themed_default: true,
8682
8748
  };
8683
8749
 
@@ -810,7 +810,9 @@ function render_toolbar(gobj)
810
810
  *----------------------------------------------*/
811
811
  let $right = [];
812
812
  if(priv.treedb_id && has_model) {
813
- $right.push(toolbar_button("SCHEMA_DIAGRAM_BTN", "yi-hexagon-nodes",
813
+ /* The compass, like every other door to a SCHEMA: the
814
+ * `hexagon-nodes` it wore is the graph of the DATA. */
815
+ $right.push(toolbar_button("SCHEMA_DIAGRAM_BTN", "yi-compass-drafting",
814
816
  priv.diagram ? "topics" : "diagram", "EV_TOGGLE_DIAGRAM", false));
815
817
  $right.push(toolbar_button("SCHEMA_VALIDATE_BTN", "yi-square-check",
816
818
  "check", "EV_VALIDATE", false));
@@ -335,12 +335,19 @@ function build_ui(gobj)
335
335
  }
336
336
  }],
337
337
  /* Landing view toggle: cards grid <-> schema graph
338
- * (topics as nodes). Shown only on the landing. */
338
+ * (topics as nodes). Shown only on the landing.
339
+ *
340
+ * The glyph is the draughtsman's compass, not the
341
+ * `hexagon-nodes` of the graph: the button one place
342
+ * to its left opens the graph of the DATA, and the
343
+ * two wore the same shape with only their labels
344
+ * telling them apart -- which on a narrow toolbar is
345
+ * nothing. A schema is the PLAN of the treedb. */
339
346
  ['button', {class: 'button TREEDB_LANDING_TOGGLE is-hidden',
340
347
  title: t('schema graph'), 'aria-label': t('schema graph'),
341
348
  'data-i18n-title': 'schema graph',
342
349
  'data-i18n-aria-label': 'schema graph'}, [
343
- ['span', {class: 'icon'}, [yui_toolbar_icon('yi-hexagon-nodes')]],
350
+ ['span', {class: 'icon'}, [yui_toolbar_icon('yi-compass-drafting')]],
344
351
  ['span', {i18n: 'schema'}, 'schema']
345
352
  ], {
346
353
  click: (evt) => {
@@ -348,6 +355,24 @@ function build_ui(gobj)
348
355
  gobj_send_event(gobj, "EV_TOGGLE_LANDING_VIEW", {}, gobj);
349
356
  }
350
357
  }],
358
+ /* The schema as JSON -- the `descs` the view is drawing,
359
+ * which is what a person writing a schema literal, or
360
+ * chasing a flag that did not take, actually needs to
361
+ * read. Landing-only, like the toggle it stands next
362
+ * to; the `raw json` button further right is another
363
+ * thing entirely (the tranger's content). */
364
+ ['button', {class: 'button TREEDB_SCHEMA_JSON_BTN is-hidden',
365
+ title: t('schema json'), 'aria-label': t('schema json'),
366
+ 'data-i18n-title': 'schema json',
367
+ 'data-i18n-aria-label': 'schema json'}, [
368
+ ['span', {class: 'icon'}, [yui_toolbar_icon('yi-code')]],
369
+ ['span', {i18n: 'schema json'}, 'schema json']
370
+ ], {
371
+ click: (evt) => {
372
+ evt.stopPropagation();
373
+ gobj_send_event(gobj, "EV_OPEN_SCHEMA_JSON", {}, gobj);
374
+ }
375
+ }],
351
376
  /* The backend this view browses. The tab that hosts this
352
377
  * view is labelled with the TREEDB name, and the same
353
378
  * treedb name lives on more than one node — two tabs
@@ -817,6 +842,7 @@ function show_topics_landing(gobj)
817
842
  let $info = $container.querySelector(".TREEDB_TOPIC_INFO");
818
843
  let $back = $container.querySelector(".TREEDB_TOPICS_BACK");
819
844
  let $toggle = $container.querySelector(".TREEDB_LANDING_TOGGLE");
845
+ let $schema_json = $container.querySelector(".TREEDB_SCHEMA_JSON_BTN");
820
846
  if($tabs) {
821
847
  $tabs.classList.add("is-hidden");
822
848
  }
@@ -835,6 +861,11 @@ function show_topics_landing(gobj)
835
861
  $toggle.classList.remove("is-hidden"); /* the toggle lives on the landing */
836
862
  $toggle.classList.toggle("is-primary", schema_mode);
837
863
  }
864
+ if($schema_json) {
865
+ /* The schema's json is worth reading when the schema is what is
866
+ * on screen; on the cards grid it is one more button in the way. */
867
+ $schema_json.classList.toggle("is-hidden", !schema_mode);
868
+ }
838
869
  if(schema_mode) {
839
870
  if($landing) {
840
871
  $landing.classList.add("is-hidden");
@@ -946,6 +977,7 @@ function show_topic_detail(gobj)
946
977
  let $info = $container.querySelector(".TREEDB_TOPIC_INFO");
947
978
  let $back = $container.querySelector(".TREEDB_TOPICS_BACK");
948
979
  let $toggle = $container.querySelector(".TREEDB_LANDING_TOGGLE");
980
+ let $schema_json = $container.querySelector(".TREEDB_SCHEMA_JSON_BTN");
949
981
  if($landing) {
950
982
  $landing.classList.add("is-hidden");
951
983
  }
@@ -955,6 +987,9 @@ function show_topic_detail(gobj)
955
987
  if($info) {
956
988
  $info.classList.add("is-hidden");
957
989
  }
990
+ if($schema_json) {
991
+ $schema_json.classList.add("is-hidden");
992
+ }
958
993
  if($toggle) {
959
994
  $toggle.classList.add("is-hidden"); /* landing-only */
960
995
  }
@@ -1010,12 +1045,16 @@ function show_topic_info(gobj, topic)
1010
1045
  let $schema = $container.querySelector(".TREEDB_TOPICS_SCHEMA");
1011
1046
  let $back = $container.querySelector(".TREEDB_TOPICS_BACK");
1012
1047
  let $toggle = $container.querySelector(".TREEDB_LANDING_TOGGLE");
1048
+ let $schema_json = $container.querySelector(".TREEDB_SCHEMA_JSON_BTN");
1013
1049
  if($landing) {
1014
1050
  $landing.classList.add("is-hidden");
1015
1051
  }
1016
1052
  if($schema) {
1017
1053
  $schema.classList.add("is-hidden");
1018
1054
  }
1055
+ if($schema_json) {
1056
+ $schema_json.classList.add("is-hidden");
1057
+ }
1019
1058
  if($toggle) {
1020
1059
  $toggle.classList.add("is-hidden");
1021
1060
  }
@@ -1307,7 +1346,7 @@ function register_sub_routes(gobj)
1307
1346
  let nodes = [];
1308
1347
  if(is_object(gobj_read_attr(gobj, "landing_routes"))) {
1309
1348
  nodes.push({route: base + "/schema", label: "schema",
1310
- icon: "yi-hexagon-nodes", gclass: "C_YUI_TREEDB_SCHEMA"});
1349
+ icon: "yi-compass-drafting", gclass: "C_YUI_TREEDB_SCHEMA"});
1311
1350
  }
1312
1351
  if(is_object(descs)) {
1313
1352
  for(let topic of Object.keys(descs)) {
@@ -1655,13 +1694,18 @@ function is_mobile()
1655
1694
  * whole service (lazy drill). CHILD model: the viewer publishes
1656
1695
  * EV_EXPAND_PATH to us.
1657
1696
  ************************************************************/
1658
- function open_json_viewer(gobj)
1697
+ function open_json_viewer(gobj, opts)
1659
1698
  {
1660
1699
  let priv = gobj.priv;
1700
+ let schema = !!(opts && opts.schema);
1661
1701
 
1662
- /* Already open: just re-fetch. */
1702
+ /* Already open: just re-fetch. The schema needs no fetch -- it is
1703
+ * the `descs` this view already holds -- so a second click on a
1704
+ * viewer that is already showing it does nothing. */
1663
1705
  if(priv.json_win || priv.json_modal) {
1664
- request_print_tranger(gobj, "");
1706
+ if(!schema) {
1707
+ request_print_tranger(gobj, "");
1708
+ }
1665
1709
  return;
1666
1710
  }
1667
1711
 
@@ -1681,7 +1725,11 @@ function open_json_viewer(gobj)
1681
1725
  /* No `title`: the host titles it — the window's title bar on
1682
1726
  * desktop, the dialog's header on mobile. The viewer's own
1683
1727
  * title would land INSIDE that host, doubling it. */
1684
- subscriber: gobj /* publishes EV_EXPAND_PATH to us */
1728
+ subscriber: gobj, /* publishes EV_EXPAND_PATH to us */
1729
+ /* The schema is here already and it is small: it goes in
1730
+ * whole, and no drill-down happens because nothing is
1731
+ * collapsed. */
1732
+ json_data: schema? gobj_read_attr(gobj, "descs") : null
1685
1733
  },
1686
1734
  gobj
1687
1735
  );
@@ -1703,7 +1751,7 @@ function open_json_viewer(gobj)
1703
1751
  dialog: true,
1704
1752
  logical_class: "TREEDB_JSON_SHEET",
1705
1753
  title_prefix: priv.treedb_name,
1706
- title: "raw json",
1754
+ title: schema? "schema json" : "raw json",
1707
1755
  t: t,
1708
1756
  on_close: () => {
1709
1757
  if(gobj_is_destroying(gobj)) {
@@ -1733,8 +1781,8 @@ function open_json_viewer(gobj)
1733
1781
  height: 620,
1734
1782
  logical_class: "TREEDB_JSON_WINDOW",
1735
1783
  title_prefix: priv.treedb_name,
1736
- title: "raw json",
1737
- icon: "yi-eye",
1784
+ title: schema? "schema json" : "raw json",
1785
+ icon: schema? "yi-code" : "yi-eye",
1738
1786
  body: $box,
1739
1787
  manager: null,
1740
1788
  on_close: () => {
@@ -1754,7 +1802,9 @@ function open_json_viewer(gobj)
1754
1802
  gobj_start(priv.json_win);
1755
1803
  }
1756
1804
 
1757
- request_print_tranger(gobj, "");
1805
+ if(!schema) {
1806
+ request_print_tranger(gobj, "");
1807
+ }
1758
1808
  }
1759
1809
 
1760
1810
  /************************************************************
@@ -2718,6 +2768,24 @@ function ac_open_json(gobj, event, kw, src)
2718
2768
  return 0;
2719
2769
  }
2720
2770
 
2771
+ /********************************************
2772
+ * The schema, as the json it is: the `descs` the schema graph is
2773
+ * drawing. Same viewer and same host as the tranger's raw json --
2774
+ * one at a time, so it closes whatever was open first.
2775
+ ********************************************/
2776
+ function ac_open_schema_json(gobj, event, kw, src)
2777
+ {
2778
+ if(!is_object(gobj_read_attr(gobj, "descs"))) {
2779
+ /* Not reachable by hand -- the button only exists while the
2780
+ * schema is on screen, which needs the descs -- so this is a
2781
+ * broken caller, not something to tell the reader about. */
2782
+ log_error(`${gobj_short_name(gobj)}: schema json with no descs`);
2783
+ return -1;
2784
+ }
2785
+ open_json_viewer(gobj, {schema: true});
2786
+ return 0;
2787
+ }
2788
+
2721
2789
  /********************************************
2722
2790
  * Open the graph where the reader left it: its last visited
2723
2791
  * route under the graph's base -- `<graph>/<topic>` when a topic
@@ -2823,6 +2891,7 @@ function create_gclass(gclass_name)
2823
2891
  ["EV_DELETE_RECORD", ac_delete_record, null],
2824
2892
  ["EV_REFRESH_TOPIC", ac_refresh_topic, null],
2825
2893
  ["EV_OPEN_JSON", ac_open_json, null],
2894
+ ["EV_OPEN_SCHEMA_JSON", ac_open_schema_json, null],
2826
2895
  ["EV_OPEN_GRAPH", ac_open_graph, null],
2827
2896
  ["EV_EXPAND_PATH", ac_json_expand_path, null],
2828
2897
  ["EV_JSON_CLOSED", ac_json_closed, null],
@@ -2857,6 +2926,7 @@ function create_gclass(gclass_name)
2857
2926
  event_flag_t.EVF_NO_WARN_SUBS],
2858
2927
  ["EV_REFRESH_TOPIC", 0],
2859
2928
  ["EV_OPEN_JSON", 0],
2929
+ ["EV_OPEN_SCHEMA_JSON", 0],
2860
2930
  ["EV_OPEN_GRAPH", 0],
2861
2931
  ["EV_EXPAND_PATH", 0],
2862
2932
  ["EV_JSON_CLOSED", 0],
@@ -0,0 +1,108 @@
1
+ /***********************************************************************
2
+ * graph_save_plan.js
3
+ *
4
+ * Which topics a Save of the graph has to write, and which it
5
+ * must leave alone.
6
+ *
7
+ * The arrangement of a treedb graph lives in `__graphs__`, ONE
8
+ * record per topic. The Save collected every topic the view had
9
+ * loaded and wrote all of them, whatever had moved: on a treedb
10
+ * of five topics, dragging one card appended five records, four
11
+ * of them saying exactly what the record under them said. The
12
+ * store is append-only, so those four are there for ever.
13
+ *
14
+ * This module is the decision, and nothing else: no G6, no DOM,
15
+ * no gobj. Give it what the view is holding and what the backend
16
+ * already has, and it answers which topics differ.
17
+ *
18
+ * Two things it deliberately does NOT do:
19
+ *
20
+ * - It does not ask the history plugin. The Save button lights
21
+ * from two places -- `history.canUndo()` for what G6 records
22
+ * (a move, a resize) and `mark_graph_dirty()` for what it does
23
+ * not (a colour, a pill, a per-topic default) -- so the undo
24
+ * stack is not the list of what changed, and reading it as one
25
+ * would silently drop half the changes. A value comparison
26
+ * sees both, and keeps working through undo, redo and a
27
+ * history that was just cleared.
28
+ *
29
+ * - It does not compare `__origin__`. That key is the node_uuid
30
+ * of whoever wrote the record, bookkeeping about the write and
31
+ * not about the arrangement; comparing it would make every
32
+ * topic differ the first time a second browser saved, and the
33
+ * record it forces would say nothing new.
34
+ *
35
+ * Copyright (c) 2026, ArtGins.
36
+ * All Rights Reserved.
37
+ ***********************************************************************/
38
+ import {
39
+ is_object,
40
+ json_is_identical,
41
+ } from "@yuneta/gobj-js";
42
+
43
+ /***************************************************************
44
+ * The properties as they are worth comparing: everything
45
+ * except who wrote them.
46
+ ***************************************************************/
47
+ function arrangement_of(properties)
48
+ {
49
+ if(!is_object(properties)) {
50
+ return {};
51
+ }
52
+ let out = {};
53
+ for(const [key, value] of Object.entries(properties)) {
54
+ if(key === "__origin__") {
55
+ continue;
56
+ }
57
+ out[key] = value;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /***************************************************************
63
+ * TRUE when the arrangement the view holds for `topic_name`
64
+ * is not the one the backend already has.
65
+ *
66
+ * A topic with no saved record has never been written, so
67
+ * anything it holds is a change -- and an EMPTY arrangement
68
+ * is not: a topic the view loaded and nobody ever arranged
69
+ * has nothing to say, and saying it would create a record
70
+ * that means "no layout".
71
+ ***************************************************************/
72
+ function topic_arrangement_changed(live, saved)
73
+ {
74
+ let a = arrangement_of(live);
75
+ if(!saved) {
76
+ return Object.keys(a).length > 0;
77
+ }
78
+ return !json_is_identical(a, arrangement_of(saved));
79
+ }
80
+
81
+ /***************************************************************
82
+ * The topics a Save has to write.
83
+ *
84
+ * live {topic_name: properties} what the view holds
85
+ * saved {topic_name: properties} what the backend has
86
+ *
87
+ * Returns their names, in the order the view holds them.
88
+ ***************************************************************/
89
+ function plan_graph_saves(live, saved)
90
+ {
91
+ let plan = [];
92
+ if(!is_object(live)) {
93
+ return plan;
94
+ }
95
+ let have = is_object(saved)? saved: {};
96
+ for(const [topic_name, properties] of Object.entries(live)) {
97
+ if(topic_arrangement_changed(properties, have[topic_name])) {
98
+ plan.push(topic_name);
99
+ }
100
+ }
101
+ return plan;
102
+ }
103
+
104
+ export {
105
+ plan_graph_saves,
106
+ topic_arrangement_changed,
107
+ arrangement_of,
108
+ };