feat(graph): duplicate a selected subgraph server-side
A secret-safe equivalent of blueprint import: the graph editor's overflow menu gains "Duplicate selection", which deep-clones the selected value and colour-strip sources server-side (full config preserved, never crossing the wire) and rewires references that point within the selection — shared dependencies (devices, HA sources, …) stay shared. - graph_schema.remap_refs: write-twin of extract_refs (same dot/list/bindable grammar) that rewrites only in-selection ids; 8 unit tests. - BaseSqliteStore.clone(): faithful deep-copy clone (no schema round-trip, so no field is lost), prefix-preserving fresh id; reusable by any store. - POST /api/v1/graph/duplicate: two-pass clone-then-rewire restricted to value / colour-strip sources (no inline secrets), with a safety net flagging any unremapped reference; 7 integration tests vs real stores. - Frontend: duplicateSubgraph (+cache invalidation), graphDuplicateSelection (reload + reselect the new cluster), overflow-menu item, i18n (en/ru/zh).
This commit is contained in:
@@ -279,6 +279,54 @@ def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]:
|
||||
return [v for v in current if isinstance(v, str) and v]
|
||||
|
||||
|
||||
def remap_refs(entity: dict[str, Any], field_path: str, id_map: dict[str, str]) -> int:
|
||||
"""Rewrite referenced ids under ``field_path`` *in place*, using ``id_map``.
|
||||
|
||||
The write-twin of :func:`extract_refs`: it walks the same dot/list/bindable
|
||||
grammar and replaces any leaf id present in ``id_map`` with its mapped value.
|
||||
Ids absent from ``id_map`` (references to entities outside the remap set) are
|
||||
left untouched, so a clone keeps sharing its un-cloned dependencies. Unbound
|
||||
bindables (a plain number where an object was expected) and missing keys are
|
||||
tolerated. Returns the number of ids rewritten.
|
||||
"""
|
||||
segments = field_path.split(".")
|
||||
# Descend to the container(s) that hold the final key.
|
||||
parents: list[Any] = [entity]
|
||||
for segment in segments[:-1]:
|
||||
is_list = segment.endswith("[]")
|
||||
key = segment[:-2] if is_list else segment
|
||||
nxt: list[Any] = []
|
||||
for obj in parents:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
val = obj.get(key)
|
||||
if is_list:
|
||||
if isinstance(val, list):
|
||||
nxt.extend(val)
|
||||
elif isinstance(val, dict):
|
||||
nxt.append(val)
|
||||
parents = nxt
|
||||
|
||||
last = segments[-1]
|
||||
last_is_list = last.endswith("[]")
|
||||
key = last[:-2] if last_is_list else last
|
||||
count = 0
|
||||
for obj in parents:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
val = obj.get(key)
|
||||
if last_is_list:
|
||||
if isinstance(val, list):
|
||||
for i, item in enumerate(val):
|
||||
if isinstance(item, str) and item in id_map:
|
||||
val[i] = id_map[item]
|
||||
count += 1
|
||||
elif isinstance(val, str) and val in id_map:
|
||||
obj[key] = id_map[val]
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def serialize_entity(model: Any) -> dict[str, Any]:
|
||||
"""Best-effort serialize a storage model to a plain dict for graph use.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user