@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.
- package/dist/gobj-ui.cjs.js +95 -21
- package/dist/gobj-ui.es.js +96 -22
- package/package.json +1 -1
- package/src/c_g6_nodes_tree.js +53 -23
- package/src/graph_save_plan.js +108 -0
- package/src/graph_save_plan.test.js +146 -0
package/dist/gobj-ui.cjs.js
CHANGED
|
@@ -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))
|
|
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
|
|
60165
|
-
for (
|
|
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__",
|
package/dist/gobj-ui.es.js
CHANGED
|
@@ -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))
|
|
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
|
|
60160
|
-
for (
|
|
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
package/src/c_g6_nodes_tree.js
CHANGED
|
@@ -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
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
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
|
+
});
|