@yuneta/gobj-ui 7.23.185 → 7.23.186

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.
@@ -30260,6 +30260,88 @@ function register_c_yui_treedb_topic_with_form() {
30260
30260
  return create_gclass$5(GCLASS_NAME$5);
30261
30261
  }
30262
30262
  //#endregion
30263
+ //#region src/graph_save_plan.js
30264
+ /***********************************************************************
30265
+ * graph_save_plan.js
30266
+ *
30267
+ * Which topics a Save of the graph has to write, and which it
30268
+ * must leave alone.
30269
+ *
30270
+ * The arrangement of a treedb graph lives in `__graphs__`, ONE
30271
+ * record per topic. The Save collected every topic the view had
30272
+ * loaded and wrote all of them, whatever had moved: on a treedb
30273
+ * of five topics, dragging one card appended five records, four
30274
+ * of them saying exactly what the record under them said. The
30275
+ * store is append-only, so those four are there for ever.
30276
+ *
30277
+ * This module is the decision, and nothing else: no G6, no DOM,
30278
+ * no gobj. Give it what the view is holding and what the backend
30279
+ * already has, and it answers which topics differ.
30280
+ *
30281
+ * Two things it deliberately does NOT do:
30282
+ *
30283
+ * - It does not ask the history plugin. The Save button lights
30284
+ * from two places -- `history.canUndo()` for what G6 records
30285
+ * (a move, a resize) and `mark_graph_dirty()` for what it does
30286
+ * not (a colour, a pill, a per-topic default) -- so the undo
30287
+ * stack is not the list of what changed, and reading it as one
30288
+ * would silently drop half the changes. A value comparison
30289
+ * sees both, and keeps working through undo, redo and a
30290
+ * history that was just cleared.
30291
+ *
30292
+ * - It does not compare `__origin__`. That key is the node_uuid
30293
+ * of whoever wrote the record, bookkeeping about the write and
30294
+ * not about the arrangement; comparing it would make every
30295
+ * topic differ the first time a second browser saved, and the
30296
+ * record it forces would say nothing new.
30297
+ *
30298
+ * Copyright (c) 2026, ArtGins.
30299
+ * All Rights Reserved.
30300
+ ***********************************************************************/
30301
+ /***************************************************************
30302
+ * The properties as they are worth comparing: everything
30303
+ * except who wrote them.
30304
+ ***************************************************************/
30305
+ function arrangement_of(properties) {
30306
+ if (!(0, _yuneta_gobj_js.is_object)(properties)) return {};
30307
+ let out = {};
30308
+ for (const [key, value] of Object.entries(properties)) {
30309
+ if (key === "__origin__") continue;
30310
+ out[key] = value;
30311
+ }
30312
+ return out;
30313
+ }
30314
+ /***************************************************************
30315
+ * TRUE when the arrangement the view holds for `topic_name`
30316
+ * is not the one the backend already has.
30317
+ *
30318
+ * A topic with no saved record has never been written, so
30319
+ * anything it holds is a change -- and an EMPTY arrangement
30320
+ * is not: a topic the view loaded and nobody ever arranged
30321
+ * has nothing to say, and saying it would create a record
30322
+ * that means "no layout".
30323
+ ***************************************************************/
30324
+ function topic_arrangement_changed(live, saved) {
30325
+ let a = arrangement_of(live);
30326
+ if (!saved) return Object.keys(a).length > 0;
30327
+ return !(0, _yuneta_gobj_js.json_is_identical)(a, arrangement_of(saved));
30328
+ }
30329
+ /***************************************************************
30330
+ * The topics a Save has to write.
30331
+ *
30332
+ * live {topic_name: properties} what the view holds
30333
+ * saved {topic_name: properties} what the backend has
30334
+ *
30335
+ * Returns their names, in the order the view holds them.
30336
+ ***************************************************************/
30337
+ function plan_graph_saves(live, saved) {
30338
+ let plan = [];
30339
+ if (!(0, _yuneta_gobj_js.is_object)(live)) return plan;
30340
+ let have = (0, _yuneta_gobj_js.is_object)(saved) ? saved : {};
30341
+ for (const [topic_name, properties] of Object.entries(live)) if (topic_arrangement_changed(properties, have[topic_name])) plan.push(topic_name);
30342
+ return plan;
30343
+ }
30344
+ //#endregion
30263
30345
  //#region src/treedb_node_label.js
30264
30346
  /***********************************************************************
30265
30347
  * treedb_node_label.js
@@ -58487,6 +58569,7 @@ var PRIVATE_DATA$4 = {
58487
58569
  graph: null,
58488
58570
  __graphs__: [],
58489
58571
  _graph_properties: {},
58572
+ _saved_graph_properties: {},
58490
58573
  yet_showed: false,
58491
58574
  edit_mode: false,
58492
58575
  operation_mode: null,
@@ -60072,11 +60155,15 @@ function clear_link(gobj, child_topic, child_id, source_col, fkey, verbose) {
60072
60155
  function build_graph_properties(gobj) {
60073
60156
  let priv = gobj.priv;
60074
60157
  priv._graph_properties = {};
60158
+ priv._saved_graph_properties = {};
60075
60159
  for (let i = 0; i < priv.__graphs__.length; i++) {
60076
60160
  let rec = priv.__graphs__[i];
60077
60161
  if (!rec.active || !rec.topic) continue;
60078
60162
  let props = rec.properties;
60079
- if ((0, _yuneta_gobj_js.is_object)(props)) priv._graph_properties[rec.topic] = props;
60163
+ if ((0, _yuneta_gobj_js.is_object)(props)) {
60164
+ priv._graph_properties[rec.topic] = props;
60165
+ priv._saved_graph_properties[rec.topic] = (0, _yuneta_gobj_js.json_deep_copy)(props);
60166
+ }
60080
60167
  }
60081
60168
  }
60082
60169
  /************************************************************
@@ -60161,35 +60248,22 @@ function save_geometry(gobj) {
60161
60248
  }
60162
60249
  const edges = graph.getData().edges;
60163
60250
  for (let i = 0; i < edges.length; i++) update_edge_geometry(gobj, edges[i].id);
60164
- let origin = (0, _yuneta_gobj_js.gobj_read_str_attr)((0, _yuneta_gobj_js.gobj_yuno)(), "node_uuid");
60165
- for (const [topic_name, properties] of Object.entries(priv._graph_properties)) {
60166
- properties.__origin__ = origin;
60167
- let kw_update = {
60168
- treedb_name: priv.treedb_name,
60169
- topic_name: "__graphs__",
60170
- record: {
60171
- id: topic_name,
60172
- topic: topic_name,
60173
- active: true,
60174
- properties
60175
- },
60176
- options: {
60177
- list_dict: true,
60178
- autolink: false,
60179
- create: true
60180
- }
60181
- };
60182
- (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_UPDATE_NODE", kw_update);
60183
- }
60251
+ let plan = plan_graph_saves(priv._graph_properties, priv._saved_graph_properties);
60252
+ for (let i = 0; i < plan.length; i++) save_topic_graph_properties(gobj, plan[i]);
60184
60253
  }
60185
60254
  /************************************************************
60186
60255
  * Save __graphs__ properties for a single topic to backend.
60256
+ *
60257
+ * The ONE door to `__graphs__`: a caller that knows a topic moved
60258
+ * says so, and this decides whether the backend needs telling.
60187
60259
  ************************************************************/
60188
60260
  function save_topic_graph_properties(gobj, topic_name) {
60189
60261
  let priv = gobj.priv;
60190
60262
  let properties = priv._graph_properties[topic_name];
60191
60263
  if (!properties) return;
60264
+ if (!topic_arrangement_changed(properties, priv._saved_graph_properties[topic_name])) return;
60192
60265
  properties.__origin__ = (0, _yuneta_gobj_js.gobj_read_str_attr)((0, _yuneta_gobj_js.gobj_yuno)(), "node_uuid");
60266
+ priv._saved_graph_properties[topic_name] = (0, _yuneta_gobj_js.json_deep_copy)(properties);
60193
60267
  let kw_update = {
60194
60268
  treedb_name: priv.treedb_name,
60195
60269
  topic_name: "__graphs__",
@@ -1,4 +1,4 @@
1
- import { SDATA, SDATA_END, clean_name, createElement2, createOneHtml, create_template_record, data_type_t, debounce, delete_from_list, duplicate_objects, empty_json, empty_string, escapeHtml, event_flag_t, flat_diff, gclass_create, gclass_find_by_name, gclass_flag_t, getPositionRelativeToBody, gobj_bottom_gobj, gobj_change_state, gobj_command, gobj_create, gobj_create_pure_child, gobj_create_service, gobj_current_state, gobj_destroy, gobj_find_child, gobj_find_service, gobj_full_name, gobj_gclass_name, gobj_get_gclass_trace_level2, gobj_global_trace_level, gobj_global_trace_no_level, gobj_is_destroying, gobj_is_playing, gobj_is_pure_child, gobj_is_running, gobj_is_service, gobj_is_volatil, gobj_match_children, gobj_name, gobj_parent, gobj_post_event, gobj_publish_event, gobj_read_attr, gobj_read_bool_attr, gobj_read_integer_attr, gobj_read_pointer_attr, gobj_read_str_attr, gobj_save_persistent_attrs, gobj_send_event, gobj_set_trace_machine_format, gobj_short_name, gobj_start, gobj_start_tree, gobj_stop, gobj_stop_children, gobj_subscribe_event, gobj_unsubscribe_event, gobj_write_attr, gobj_write_bool_attr, gobj_write_integer_attr, gobj_write_str_attr, gobj_yuno, gobj_yuno_name, is_array, is_boolean, is_date, is_gobj, is_null, is_number, is_object, is_string, json2flat, json_deep_copy, json_object_size, json_object_update, json_object_update_missing, json_size, kw_clone_by_keys, kw_flag_t, kw_get_dict, kw_get_dict_value, kw_get_int, kw_get_local_storage_value, kw_get_str, kw_has_key, kw_set_dict_value, kw_set_local_storage_value, kwid_find_one_record, kwid_new_dict, log_error, log_warning, msg_iev_get_stack, parseBoolean, parseSVG, refresh_language, safeSrc, sdata_flag_t, set_console_log_enabled, set_log_callback, sprintf, str_in_list, template_get_field_desc, trace_level_t, trace_msg, treedb_decoder_fkey, treedb_encoder_fkey, treedb_get_field_desc, treedb_hook_data_size } from "@yuneta/gobj-js";
1
+ import { SDATA, SDATA_END, clean_name, createElement2, createOneHtml, create_template_record, data_type_t, debounce, delete_from_list, duplicate_objects, empty_json, empty_string, escapeHtml, event_flag_t, flat_diff, gclass_create, gclass_find_by_name, gclass_flag_t, getPositionRelativeToBody, gobj_bottom_gobj, gobj_change_state, gobj_command, gobj_create, gobj_create_pure_child, gobj_create_service, gobj_current_state, gobj_destroy, gobj_find_child, gobj_find_service, gobj_full_name, gobj_gclass_name, gobj_get_gclass_trace_level2, gobj_global_trace_level, gobj_global_trace_no_level, gobj_is_destroying, gobj_is_playing, gobj_is_pure_child, gobj_is_running, gobj_is_service, gobj_is_volatil, gobj_match_children, gobj_name, gobj_parent, gobj_post_event, gobj_publish_event, gobj_read_attr, gobj_read_bool_attr, gobj_read_integer_attr, gobj_read_pointer_attr, gobj_read_str_attr, gobj_save_persistent_attrs, gobj_send_event, gobj_set_trace_machine_format, gobj_short_name, gobj_start, gobj_start_tree, gobj_stop, gobj_stop_children, gobj_subscribe_event, gobj_unsubscribe_event, gobj_write_attr, gobj_write_bool_attr, gobj_write_integer_attr, gobj_write_str_attr, gobj_yuno, gobj_yuno_name, is_array, is_boolean, is_date, is_gobj, is_null, is_number, is_object, is_string, json2flat, json_deep_copy, json_is_identical, json_object_size, json_object_update, json_object_update_missing, json_size, kw_clone_by_keys, kw_flag_t, kw_get_dict, kw_get_dict_value, kw_get_int, kw_get_local_storage_value, kw_get_str, kw_has_key, kw_set_dict_value, kw_set_local_storage_value, kwid_find_one_record, kwid_new_dict, log_error, log_warning, msg_iev_get_stack, parseBoolean, parseSVG, refresh_language, safeSrc, sdata_flag_t, set_console_log_enabled, set_log_callback, sprintf, str_in_list, template_get_field_desc, trace_level_t, trace_msg, treedb_decoder_fkey, treedb_encoder_fkey, treedb_get_field_desc, treedb_hook_data_size } from "@yuneta/gobj-js";
2
2
  import i18next, { t } from "i18next";
3
3
  import TomSelect from "tom-select";
4
4
  import { createJSONEditor } from "vanilla-jsoneditor";
@@ -30255,6 +30255,88 @@ function register_c_yui_treedb_topic_with_form() {
30255
30255
  return create_gclass$5(GCLASS_NAME$5);
30256
30256
  }
30257
30257
  //#endregion
30258
+ //#region src/graph_save_plan.js
30259
+ /***********************************************************************
30260
+ * graph_save_plan.js
30261
+ *
30262
+ * Which topics a Save of the graph has to write, and which it
30263
+ * must leave alone.
30264
+ *
30265
+ * The arrangement of a treedb graph lives in `__graphs__`, ONE
30266
+ * record per topic. The Save collected every topic the view had
30267
+ * loaded and wrote all of them, whatever had moved: on a treedb
30268
+ * of five topics, dragging one card appended five records, four
30269
+ * of them saying exactly what the record under them said. The
30270
+ * store is append-only, so those four are there for ever.
30271
+ *
30272
+ * This module is the decision, and nothing else: no G6, no DOM,
30273
+ * no gobj. Give it what the view is holding and what the backend
30274
+ * already has, and it answers which topics differ.
30275
+ *
30276
+ * Two things it deliberately does NOT do:
30277
+ *
30278
+ * - It does not ask the history plugin. The Save button lights
30279
+ * from two places -- `history.canUndo()` for what G6 records
30280
+ * (a move, a resize) and `mark_graph_dirty()` for what it does
30281
+ * not (a colour, a pill, a per-topic default) -- so the undo
30282
+ * stack is not the list of what changed, and reading it as one
30283
+ * would silently drop half the changes. A value comparison
30284
+ * sees both, and keeps working through undo, redo and a
30285
+ * history that was just cleared.
30286
+ *
30287
+ * - It does not compare `__origin__`. That key is the node_uuid
30288
+ * of whoever wrote the record, bookkeeping about the write and
30289
+ * not about the arrangement; comparing it would make every
30290
+ * topic differ the first time a second browser saved, and the
30291
+ * record it forces would say nothing new.
30292
+ *
30293
+ * Copyright (c) 2026, ArtGins.
30294
+ * All Rights Reserved.
30295
+ ***********************************************************************/
30296
+ /***************************************************************
30297
+ * The properties as they are worth comparing: everything
30298
+ * except who wrote them.
30299
+ ***************************************************************/
30300
+ function arrangement_of(properties) {
30301
+ if (!is_object(properties)) return {};
30302
+ let out = {};
30303
+ for (const [key, value] of Object.entries(properties)) {
30304
+ if (key === "__origin__") continue;
30305
+ out[key] = value;
30306
+ }
30307
+ return out;
30308
+ }
30309
+ /***************************************************************
30310
+ * TRUE when the arrangement the view holds for `topic_name`
30311
+ * is not the one the backend already has.
30312
+ *
30313
+ * A topic with no saved record has never been written, so
30314
+ * anything it holds is a change -- and an EMPTY arrangement
30315
+ * is not: a topic the view loaded and nobody ever arranged
30316
+ * has nothing to say, and saying it would create a record
30317
+ * that means "no layout".
30318
+ ***************************************************************/
30319
+ function topic_arrangement_changed(live, saved) {
30320
+ let a = arrangement_of(live);
30321
+ if (!saved) return Object.keys(a).length > 0;
30322
+ return !json_is_identical(a, arrangement_of(saved));
30323
+ }
30324
+ /***************************************************************
30325
+ * The topics a Save has to write.
30326
+ *
30327
+ * live {topic_name: properties} what the view holds
30328
+ * saved {topic_name: properties} what the backend has
30329
+ *
30330
+ * Returns their names, in the order the view holds them.
30331
+ ***************************************************************/
30332
+ function plan_graph_saves(live, saved) {
30333
+ let plan = [];
30334
+ if (!is_object(live)) return plan;
30335
+ let have = is_object(saved) ? saved : {};
30336
+ for (const [topic_name, properties] of Object.entries(live)) if (topic_arrangement_changed(properties, have[topic_name])) plan.push(topic_name);
30337
+ return plan;
30338
+ }
30339
+ //#endregion
30258
30340
  //#region src/treedb_node_label.js
30259
30341
  /***********************************************************************
30260
30342
  * treedb_node_label.js
@@ -58482,6 +58564,7 @@ var PRIVATE_DATA$4 = {
58482
58564
  graph: null,
58483
58565
  __graphs__: [],
58484
58566
  _graph_properties: {},
58567
+ _saved_graph_properties: {},
58485
58568
  yet_showed: false,
58486
58569
  edit_mode: false,
58487
58570
  operation_mode: null,
@@ -60067,11 +60150,15 @@ function clear_link(gobj, child_topic, child_id, source_col, fkey, verbose) {
60067
60150
  function build_graph_properties(gobj) {
60068
60151
  let priv = gobj.priv;
60069
60152
  priv._graph_properties = {};
60153
+ priv._saved_graph_properties = {};
60070
60154
  for (let i = 0; i < priv.__graphs__.length; i++) {
60071
60155
  let rec = priv.__graphs__[i];
60072
60156
  if (!rec.active || !rec.topic) continue;
60073
60157
  let props = rec.properties;
60074
- if (is_object(props)) priv._graph_properties[rec.topic] = props;
60158
+ if (is_object(props)) {
60159
+ priv._graph_properties[rec.topic] = props;
60160
+ priv._saved_graph_properties[rec.topic] = json_deep_copy(props);
60161
+ }
60075
60162
  }
60076
60163
  }
60077
60164
  /************************************************************
@@ -60156,35 +60243,22 @@ function save_geometry(gobj) {
60156
60243
  }
60157
60244
  const edges = graph.getData().edges;
60158
60245
  for (let i = 0; i < edges.length; i++) update_edge_geometry(gobj, edges[i].id);
60159
- let origin = gobj_read_str_attr(gobj_yuno(), "node_uuid");
60160
- for (const [topic_name, properties] of Object.entries(priv._graph_properties)) {
60161
- properties.__origin__ = origin;
60162
- let kw_update = {
60163
- treedb_name: priv.treedb_name,
60164
- topic_name: "__graphs__",
60165
- record: {
60166
- id: topic_name,
60167
- topic: topic_name,
60168
- active: true,
60169
- properties
60170
- },
60171
- options: {
60172
- list_dict: true,
60173
- autolink: false,
60174
- create: true
60175
- }
60176
- };
60177
- gobj_publish_event(gobj, "EV_UPDATE_NODE", kw_update);
60178
- }
60246
+ let plan = plan_graph_saves(priv._graph_properties, priv._saved_graph_properties);
60247
+ for (let i = 0; i < plan.length; i++) save_topic_graph_properties(gobj, plan[i]);
60179
60248
  }
60180
60249
  /************************************************************
60181
60250
  * Save __graphs__ properties for a single topic to backend.
60251
+ *
60252
+ * The ONE door to `__graphs__`: a caller that knows a topic moved
60253
+ * says so, and this decides whether the backend needs telling.
60182
60254
  ************************************************************/
60183
60255
  function save_topic_graph_properties(gobj, topic_name) {
60184
60256
  let priv = gobj.priv;
60185
60257
  let properties = priv._graph_properties[topic_name];
60186
60258
  if (!properties) return;
60259
+ if (!topic_arrangement_changed(properties, priv._saved_graph_properties[topic_name])) return;
60187
60260
  properties.__origin__ = gobj_read_str_attr(gobj_yuno(), "node_uuid");
60261
+ priv._saved_graph_properties[topic_name] = json_deep_copy(properties);
60188
60262
  let kw_update = {
60189
60263
  treedb_name: priv.treedb_name,
60190
60264
  topic_name: "__graphs__",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.185",
3
+ "version": "7.23.186",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -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,
@@ -3051,6 +3058,7 @@ function build_graph_properties(gobj)
3051
3058
  {
3052
3059
  let priv = gobj.priv;
3053
3060
  priv._graph_properties = {};
3061
+ priv._saved_graph_properties = {};
3054
3062
 
3055
3063
  for(let i = 0; i < priv.__graphs__.length; i++) {
3056
3064
  let rec = priv.__graphs__[i];
@@ -3060,6 +3068,11 @@ function build_graph_properties(gobj)
3060
3068
  let props = rec.properties;
3061
3069
  if(is_object(props)) {
3062
3070
  priv._graph_properties[rec.topic] = props;
3071
+ /* A COPY: the line above hands the record's own object to
3072
+ * the view, which then arranges it in place, so anything
3073
+ * kept by reference would change with it and a Save would
3074
+ * never find a difference. */
3075
+ priv._saved_graph_properties[rec.topic] = json_deep_copy(props);
3063
3076
  }
3064
3077
  }
3065
3078
  }
@@ -3205,34 +3218,30 @@ function save_geometry(gobj)
3205
3218
  update_edge_geometry(gobj, edges[i].id);
3206
3219
  }
3207
3220
 
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);
3221
+ /* One __graphs__ record per topic that CHANGED, and nothing for
3222
+ * the rest. The Save button says that something happened, never
3223
+ * what: it lights from `history.canUndo()` for the moves G6
3224
+ * records and from mark_graph_dirty() for everything it does not
3225
+ * (a colour, a pill, a per-topic default). So the topics are
3226
+ * chosen by comparing what the view holds against what the
3227
+ * backend has -- which sees both kinds of change, and goes on
3228
+ * working after an undo, a redo, or the history.clear() that
3229
+ * ac_save_graph() does one line before calling this.
3230
+ *
3231
+ * The store is append-only: a topic written with the content it
3232
+ * already had is a record that says nothing, for ever. Moving one
3233
+ * card of a five-topic treedb used to append five. */
3234
+ let plan = plan_graph_saves(priv._graph_properties, priv._saved_graph_properties);
3235
+ for(let i = 0; i < plan.length; i++) {
3236
+ save_topic_graph_properties(gobj, plan[i]);
3231
3237
  }
3232
3238
  }
3233
3239
 
3234
3240
  /************************************************************
3235
3241
  * Save __graphs__ properties for a single topic to backend.
3242
+ *
3243
+ * The ONE door to `__graphs__`: a caller that knows a topic moved
3244
+ * says so, and this decides whether the backend needs telling.
3236
3245
  ************************************************************/
3237
3246
  function save_topic_graph_properties(gobj, topic_name)
3238
3247
  {
@@ -3242,9 +3251,30 @@ function save_topic_graph_properties(gobj, topic_name)
3242
3251
  return;
3243
3252
  }
3244
3253
 
3254
+ /* A record that says what the record under it says is a record
3255
+ * that will be read for ever and mean nothing -- the store is
3256
+ * append-only. It reached here by two doors: the Save, which
3257
+ * wrote every topic the view had loaded, and the delete of a
3258
+ * node that carried no geometry of its own. */
3259
+ if(!topic_arrangement_changed(properties, priv._saved_graph_properties[topic_name])) {
3260
+ return;
3261
+ }
3262
+
3245
3263
  let origin = gobj_read_str_attr(gobj_yuno(), "node_uuid");
3246
3264
  properties.__origin__ = origin;
3247
3265
 
3266
+ /* What the backend will hold once this is written. Taken BEFORE
3267
+ * the publish and as a copy, because the object below travels by
3268
+ * reference and goes on being arranged after the write.
3269
+ *
3270
+ * Nothing answers an `EV_UPDATE_NODE` of `__graphs__` -- the view
3271
+ * writes that topic itself and is deliberately not told about it
3272
+ * (see the EV_RECORD_WRITTEN of C_YUI_TREEDB_GRAPH) -- so this is
3273
+ * the write taken for granted. A refused one leaves the view
3274
+ * believing the backend holds an arrangement it does not, until
3275
+ * the next load reads the records again. */
3276
+ priv._saved_graph_properties[topic_name] = json_deep_copy(properties);
3277
+
3248
3278
  let kw_update = {
3249
3279
  treedb_name: priv.treedb_name,
3250
3280
  topic_name: "__graphs__",
@@ -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
+ };
@@ -0,0 +1,146 @@
1
+ /***********************************************************************
2
+ * graph_save_plan.test.js
3
+ *
4
+ * A Save of the graph writes the topics that CHANGED.
5
+ *
6
+ * Measured before this existed, on a live agent treedb: one node
7
+ * of `public_services` dragged, Save pressed, and the store took
8
+ * five `__graphs__` records -- one per topic the view had loaded
9
+ * -- of which four were identical, byte for byte, to the records
10
+ * under them. An append-only store keeps them for ever.
11
+ *
12
+ * The cases below are the ones that decide whether a topic is
13
+ * written, and two of them are the reason this is a module of its
14
+ * own rather than three lines inside the Save.
15
+ *
16
+ * Copyright (c) 2026, ArtGins.
17
+ * All Rights Reserved.
18
+ ***********************************************************************/
19
+ import {describe, test, expect} from "vitest";
20
+ import {
21
+ plan_graph_saves, topic_arrangement_changed, arrangement_of
22
+ } from "./graph_save_plan.js";
23
+
24
+ const ORIGIN = "f3163a9f-1f37-412a-b8c9-87a4341f14de";
25
+
26
+ function layout(nodes, extra)
27
+ {
28
+ return Object.assign({nodes: nodes, __origin__: ORIGIN}, extra || {});
29
+ }
30
+
31
+ /* The shape of the real case: five topics, one card moved. */
32
+ function five_topics()
33
+ {
34
+ return {
35
+ realms: layout({"artgins.utilities.all": {x: 398, y: 605}}),
36
+ yunos: layout({"1": {x: 130, y: 815}, "2": {x: 398, y: 815}}),
37
+ binaries: layout({emailsender: {x: 197, y: 985}}),
38
+ configurations: layout({"emailsender.artgins": {x: 63, y: 985}}),
39
+ public_services: layout({"1": {x: 993.7299656271935, y: 1119.9412968754768}}),
40
+ };
41
+ }
42
+
43
+ describe("plan_graph_saves", () => {
44
+ test("nothing moved, nothing is written", () => {
45
+ const live = five_topics();
46
+ const saved = five_topics();
47
+ expect(plan_graph_saves(live, saved)).toEqual([]);
48
+ });
49
+
50
+ test("one card moved writes ONE topic, not five", () => {
51
+ const live = five_topics();
52
+ const saved = five_topics();
53
+ live.public_services.nodes["1"].x = 811.7299656271935;
54
+ live.public_services.nodes["1"].y = 1167.9412968754768;
55
+ expect(plan_graph_saves(live, saved)).toEqual(["public_services"]);
56
+ });
57
+
58
+ test("a topic the backend has never seen is written", () => {
59
+ const live = five_topics();
60
+ const saved = five_topics();
61
+ delete saved.binaries;
62
+ expect(plan_graph_saves(live, saved)).toEqual(["binaries"]);
63
+ });
64
+
65
+ test("...unless it holds nothing: an empty arrangement is not a layout", () => {
66
+ const live = five_topics();
67
+ const saved = five_topics();
68
+ delete saved.binaries;
69
+ live.binaries = {};
70
+ expect(plan_graph_saves(live, saved)).toEqual([]);
71
+ });
72
+
73
+ test("no saved side at all is the first Save: every topic with content", () => {
74
+ expect(plan_graph_saves(five_topics(), {})).toEqual(
75
+ ["realms", "yunos", "binaries", "configurations", "public_services"]
76
+ );
77
+ });
78
+
79
+ test("a different __origin__ is not a change", () => {
80
+ const live = five_topics();
81
+ const saved = five_topics();
82
+ live.yunos.__origin__ = "another-browser-entirely";
83
+ expect(plan_graph_saves(live, saved)).toEqual([]);
84
+ });
85
+
86
+ test("the order the keys were built in is not a change", () => {
87
+ const saved = {yunos: {nodes: {a: {x: 1, y: 2}}, __origin__: ORIGIN}};
88
+ const live = {yunos: {__origin__: ORIGIN, nodes: {a: {y: 2, x: 1}}}};
89
+ expect(plan_graph_saves(live, saved)).toEqual([]);
90
+ });
91
+
92
+ test("a node that lost its entry is a change", () => {
93
+ const live = five_topics();
94
+ const saved = five_topics();
95
+ delete live.yunos.nodes["2"];
96
+ expect(plan_graph_saves(live, saved)).toEqual(["yunos"]);
97
+ });
98
+
99
+ test("what is not a position counts too: defaults and edges", () => {
100
+ const live = five_topics();
101
+ const saved = five_topics();
102
+ live.realms.defaults = {fill: "#123456"};
103
+ live.binaries.edges = {"binaries^a|yunos": {lineWidth: 3}};
104
+ expect(plan_graph_saves(live, saved)).toEqual(["realms", "binaries"]);
105
+ });
106
+
107
+ test("a size added to a card is a change", () => {
108
+ const live = five_topics();
109
+ const saved = five_topics();
110
+ live.binaries.nodes.emailsender.size = [260, 120];
111
+ expect(plan_graph_saves(live, saved)).toEqual(["binaries"]);
112
+ });
113
+
114
+ test("it answers a plan, never undefined", () => {
115
+ expect(plan_graph_saves(null, null)).toEqual([]);
116
+ expect(plan_graph_saves(undefined, five_topics())).toEqual([]);
117
+ });
118
+ });
119
+
120
+ describe("topic_arrangement_changed", () => {
121
+ test("no saved record and content: TRUE", () => {
122
+ expect(topic_arrangement_changed(layout({a: {x: 1, y: 1}}), undefined)).toBe(true);
123
+ });
124
+
125
+ test("no saved record and nothing to say: FALSE", () => {
126
+ expect(topic_arrangement_changed({}, undefined)).toBe(false);
127
+ expect(topic_arrangement_changed({__origin__: ORIGIN}, undefined)).toBe(false);
128
+ });
129
+
130
+ test("the same arrangement: FALSE", () => {
131
+ const props = layout({a: {x: 1, y: 1}});
132
+ expect(topic_arrangement_changed(props, props)).toBe(false);
133
+ });
134
+ });
135
+
136
+ describe("arrangement_of", () => {
137
+ test("drops who wrote it and keeps the rest", () => {
138
+ expect(arrangement_of(layout({a: {x: 1, y: 1}}, {defaults: {fill: "#fff"}})))
139
+ .toEqual({nodes: {a: {x: 1, y: 1}}, defaults: {fill: "#fff"}});
140
+ });
141
+
142
+ test("anything that is not an object holds no arrangement", () => {
143
+ expect(arrangement_of(null)).toEqual({});
144
+ expect(arrangement_of("nodes")).toEqual({});
145
+ });
146
+ });