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]
|
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]:
|
def serialize_entity(model: Any) -> dict[str, Any]:
|
||||||
"""Best-effort serialize a storage model to a plain dict for graph use.
|
"""Best-effort serialize a storage model to a plain dict for graph use.
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ from ledgrab.api.graph_schema import (
|
|||||||
ENTITY_KINDS,
|
ENTITY_KINDS,
|
||||||
NODE_TYPE_FIELD,
|
NODE_TYPE_FIELD,
|
||||||
build_topology,
|
build_topology,
|
||||||
|
extract_refs,
|
||||||
find_dependents,
|
find_dependents,
|
||||||
|
remap_refs,
|
||||||
schema_as_dicts,
|
schema_as_dicts,
|
||||||
|
schema_for_kind,
|
||||||
|
serialize_entity,
|
||||||
serialize_entity_for_graph,
|
serialize_entity_for_graph,
|
||||||
validate_connection,
|
validate_connection,
|
||||||
)
|
)
|
||||||
@@ -122,3 +126,124 @@ async def validate_graph_connection(
|
|||||||
entities, body.target_kind, body.target_id, body.field, body.source_id
|
entities, body.target_kind, body.target_id, body.field, body.source_id
|
||||||
)
|
)
|
||||||
return {"ok": ok, "error": error}
|
return {"ok": ok, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Subgraph duplication (server-side blueprint instantiate) ─────────────────
|
||||||
|
# Only these kinds are cloned. They carry no inline secrets — they *reference*
|
||||||
|
# shared secret-bearing entities (devices, HA sources, HTTP endpoints) by id,
|
||||||
|
# and those are NOT cloned — and they have no hardware identity to conflict
|
||||||
|
# over. Output targets, automations, devices and integrations are out of scope.
|
||||||
|
_DUPLICABLE_KINDS: tuple[str, ...] = ("value_source", "color_strip_source")
|
||||||
|
_MAX_DUPLICATE = 200
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateRequest(BaseModel):
|
||||||
|
"""Duplicate a selected subgraph of value / colour-strip sources."""
|
||||||
|
|
||||||
|
node_ids: list[str] = Field(..., min_length=1, max_length=_MAX_DUPLICATE)
|
||||||
|
name_suffix: str = Field(default=" (copy)", max_length=40)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_name(existing: set[str], desired: str) -> str:
|
||||||
|
"""A name not already in ``existing`` (appends ' 2', ' 3', … on collision)."""
|
||||||
|
if desired not in existing:
|
||||||
|
return desired
|
||||||
|
i = 2
|
||||||
|
while f"{desired} {i}" in existing:
|
||||||
|
i += 1
|
||||||
|
return f"{desired} {i}"
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_subgraph(node_ids: list[str], name_suffix: str) -> dict[str, Any]:
|
||||||
|
"""Deep-clone selected value/colour-strip sources with new ids, rewiring
|
||||||
|
references that point *within* the selection (shared deps are left alone)."""
|
||||||
|
# Index every duplicable entity by id → (kind, store, model); track names.
|
||||||
|
index: dict[str, tuple[str, Any, Any]] = {}
|
||||||
|
existing_names: dict[str, set[str]] = {}
|
||||||
|
for kind in _DUPLICABLE_KINDS:
|
||||||
|
try:
|
||||||
|
store = _KIND_STORES[kind]()
|
||||||
|
models = store.get_all()
|
||||||
|
except Exception as exc: # noqa: BLE001 — a failing store must not 500 the request
|
||||||
|
logger.warning("graph.duplicate: store for %s unavailable: %s", kind, exc)
|
||||||
|
continue
|
||||||
|
names = existing_names.setdefault(kind, set())
|
||||||
|
for m in models:
|
||||||
|
mid = getattr(m, "id", None)
|
||||||
|
mname = getattr(m, "name", None)
|
||||||
|
if isinstance(mname, str):
|
||||||
|
names.add(mname)
|
||||||
|
if isinstance(mid, str) and mid:
|
||||||
|
index[mid] = (kind, store, m)
|
||||||
|
|
||||||
|
selected: list[str] = []
|
||||||
|
skipped: list[dict[str, str]] = []
|
||||||
|
for nid in dict.fromkeys(node_ids): # de-dupe, preserve order
|
||||||
|
if nid in index:
|
||||||
|
selected.append(nid)
|
||||||
|
else:
|
||||||
|
skipped.append(
|
||||||
|
{"id": nid, "reason": "only value and colour-strip sources can be duplicated"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pass 1 — create clones; their refs still point at the originals (valid).
|
||||||
|
id_map: dict[str, str] = {}
|
||||||
|
created: list[dict[str, str]] = []
|
||||||
|
clones: list[tuple[str, Any, str]] = []
|
||||||
|
for old_id in selected:
|
||||||
|
kind, store, model = index[old_id]
|
||||||
|
base = (getattr(model, "name", None) or old_id) + name_suffix
|
||||||
|
name = _unique_name(existing_names[kind], base)
|
||||||
|
existing_names[kind].add(name)
|
||||||
|
try:
|
||||||
|
new = store.clone(old_id, name)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("graph.duplicate: clone of %s %s failed: %s", kind, old_id, exc)
|
||||||
|
skipped.append({"id": old_id, "reason": f"clone failed: {exc}"})
|
||||||
|
continue
|
||||||
|
id_map[old_id] = new.id
|
||||||
|
created.append({"id": new.id, "kind": kind, "name": new.name})
|
||||||
|
clones.append((kind, store, new.id))
|
||||||
|
|
||||||
|
# Pass 2 — rewrite references that point within the cloned set.
|
||||||
|
warnings: list[dict[str, str]] = []
|
||||||
|
for kind, store, new_id in clones:
|
||||||
|
clone = serialize_entity(store.get(new_id))
|
||||||
|
changed_roots: set[str] = set()
|
||||||
|
for cf in schema_for_kind(kind):
|
||||||
|
if remap_refs(clone, cf.field, id_map):
|
||||||
|
changed_roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
|
||||||
|
if not changed_roots:
|
||||||
|
continue
|
||||||
|
# `clone` is the FULL serialized entity, so each changed root carries a
|
||||||
|
# complete, structurally-intact value (the whole `layers` list / bindable
|
||||||
|
# dict) that ``update_source`` replaces or merges wholesale. (Within the
|
||||||
|
# duplicable set the only roots that change are scalar ids, `layers` and
|
||||||
|
# bindable slots — never a partially-built nested object.)
|
||||||
|
updates = {root: clone[root] for root in changed_roots if root in clone}
|
||||||
|
try:
|
||||||
|
store.update_source(new_id, **updates)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("graph.duplicate: ref remap of %s failed: %s", new_id, exc)
|
||||||
|
warnings.append({"id": new_id, "reason": f"reference remap failed: {exc}"})
|
||||||
|
|
||||||
|
# Safety net — a clone must never still reference an OLD (in-selection) id.
|
||||||
|
for kind, store, new_id in clones:
|
||||||
|
clone = serialize_entity(store.get(new_id))
|
||||||
|
for cf in schema_for_kind(kind):
|
||||||
|
if any(ref in id_map for ref in extract_refs(clone, cf.field)):
|
||||||
|
warnings.append({"id": new_id, "reason": f"unremapped reference at {cf.field}"})
|
||||||
|
|
||||||
|
return {"id_map": id_map, "created": created, "skipped": skipped, "warnings": warnings}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/graph/duplicate", tags=["Graph"])
|
||||||
|
async def duplicate_subgraph(body: DuplicateRequest, _auth: AuthRequired) -> dict[str, Any]:
|
||||||
|
"""Deep-clone the selected value/colour-strip sources (new ids, wiring remapped).
|
||||||
|
|
||||||
|
References that point *within* the selection are rewired to the new clones;
|
||||||
|
references to entities outside it (devices, HA sources, …) stay shared with
|
||||||
|
the originals. Only value and colour-strip sources are cloned — they carry no
|
||||||
|
inline secrets — so any other kind in the selection is reported in ``skipped``.
|
||||||
|
"""
|
||||||
|
return await run_in_threadpool(_duplicate_subgraph, body.node_ids, body.name_suffix)
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
loadGraphEditor,
|
loadGraphEditor,
|
||||||
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
|
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
|
||||||
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology,
|
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology, graphDuplicateSelection,
|
||||||
graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow,
|
graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow,
|
||||||
} from './features/graph-editor.ts';
|
} from './features/graph-editor.ts';
|
||||||
|
|
||||||
@@ -627,6 +627,7 @@ Object.assign(window, {
|
|||||||
graphRelayout,
|
graphRelayout,
|
||||||
graphShowIssues,
|
graphShowIssues,
|
||||||
graphExportTopology,
|
graphExportTopology,
|
||||||
|
graphDuplicateSelection,
|
||||||
graphToggleFullscreen,
|
graphToggleFullscreen,
|
||||||
graphAddEntity,
|
graphAddEntity,
|
||||||
toggleToolbarOverflow,
|
toggleToolbarOverflow,
|
||||||
|
|||||||
@@ -422,4 +422,36 @@ export async function updateListSlotConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Subgraph duplication (D6) ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Result of `POST /graph/duplicate` (server-side clone of a selected subgraph). */
|
||||||
|
export interface DuplicateResult {
|
||||||
|
id_map: Record<string, string>;
|
||||||
|
created: Array<{ id: string; kind: string; name: string }>;
|
||||||
|
skipped: Array<{ id: string; reason: string }>;
|
||||||
|
warnings: Array<{ id: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side duplicate of a selected subgraph: the backend deep-clones the
|
||||||
|
* value / colour-strip sources among `nodeIds` with fresh ids and rewires
|
||||||
|
* references that point *within* the selection (shared deps stay shared).
|
||||||
|
* Returns the result, or `null` on failure. Invalidates the affected caches so
|
||||||
|
* a subsequent graph reload shows the clones.
|
||||||
|
*/
|
||||||
|
export async function duplicateSubgraph(
|
||||||
|
nodeIds: string[], nameSuffix?: string,
|
||||||
|
): Promise<DuplicateResult | null> {
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = { node_ids: nodeIds };
|
||||||
|
if (nameSuffix) body.name_suffix = nameSuffix;
|
||||||
|
const res = await apiPost<DuplicateResult>('/graph/duplicate', body);
|
||||||
|
valueSourcesCache.invalidate();
|
||||||
|
colorStripSourcesCache.invalidate();
|
||||||
|
return res;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { CONNECTION_MAP };
|
export { CONNECTION_MAP };
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { apiGet } from '../core/api-client.ts';
|
|||||||
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
|
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
|
||||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, updateListSlotConnection, isEditableEdge, validateConnection, getDependents, checkSchemaDrift } from '../core/graph-connections.ts';
|
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, updateListSlotConnection, duplicateSubgraph, isEditableEdge, validateConnection, getDependents, checkSchemaDrift } from '../core/graph-connections.ts';
|
||||||
import { showTypePicker } from '../core/icon-select.ts';
|
import { showTypePicker } from '../core/icon-select.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.ts';
|
import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.ts';
|
||||||
@@ -809,6 +809,40 @@ export async function graphExportTopology(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate the current node selection server-side. The backend clones the
|
||||||
|
* value / colour-strip sources in the selection (full config preserved, no
|
||||||
|
* secrets crossing the wire), rewires references that point *within* the
|
||||||
|
* selection, and shares everything else. The graph then reloads with the new
|
||||||
|
* cluster selected.
|
||||||
|
*/
|
||||||
|
export async function graphDuplicateSelection(): Promise<void> {
|
||||||
|
const ids = [..._selectedIds];
|
||||||
|
if (ids.length === 0) {
|
||||||
|
showToast(t('graph.duplicate_none'), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await duplicateSubgraph(ids);
|
||||||
|
if (!res) {
|
||||||
|
showToast(t('graph.duplicate_failed'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newIds = Object.values(res.id_map || {});
|
||||||
|
if (newIds.length === 0) {
|
||||||
|
showToast(t('graph.duplicate_none_eligible'), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadGraphEditor();
|
||||||
|
_selectedIds = new Set(newIds);
|
||||||
|
const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null;
|
||||||
|
if (nodeGroup) updateSelection(nodeGroup, _selectedIds);
|
||||||
|
const hasWarn = !!res.warnings?.length;
|
||||||
|
showToast(
|
||||||
|
t(hasWarn ? 'graph.duplicate_done_warn' : 'graph.duplicate_done', { count: newIds.length }),
|
||||||
|
hasWarn ? 'info' : 'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Frame and highlight all nodes flagged with configuration issues. */
|
/** Frame and highlight all nodes flagged with configuration issues. */
|
||||||
export function graphShowIssues(): void {
|
export function graphShowIssues(): void {
|
||||||
if (_issueIds.size === 0 || !_nodeMap || !_canvas) {
|
if (_issueIds.size === 0 || !_nodeMap || !_canvas) {
|
||||||
@@ -1357,6 +1391,10 @@ function _graphHTML(): string {
|
|||||||
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
<span>${t('graph.export')}</span>
|
<span>${t('graph.export')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="graphDuplicateSelection(); closeToolbarOverflow()">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
<span>${t('graph.duplicate')}</span>
|
||||||
|
</button>
|
||||||
<div class="graph-overflow-sep"></div>
|
<div class="graph-overflow-sep"></div>
|
||||||
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()">
|
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()">
|
||||||
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
||||||
|
|||||||
+1
@@ -379,6 +379,7 @@ startTargetOverlay: (...args: any[]) => any;
|
|||||||
graphRelayout: (...args: any[]) => any;
|
graphRelayout: (...args: any[]) => any;
|
||||||
graphShowIssues: (...args: any[]) => any;
|
graphShowIssues: (...args: any[]) => any;
|
||||||
graphExportTopology: (...args: any[]) => any;
|
graphExportTopology: (...args: any[]) => any;
|
||||||
|
graphDuplicateSelection: (...args: any[]) => any;
|
||||||
graphToggleFullscreen: (...args: any[]) => any;
|
graphToggleFullscreen: (...args: any[]) => any;
|
||||||
graphAddEntity: (...args: any[]) => any;
|
graphAddEntity: (...args: any[]) => any;
|
||||||
|
|
||||||
|
|||||||
@@ -2604,6 +2604,12 @@
|
|||||||
"graph.export": "Export graph (JSON)",
|
"graph.export": "Export graph (JSON)",
|
||||||
"graph.export_done": "Graph exported",
|
"graph.export_done": "Graph exported",
|
||||||
"graph.export_failed": "Failed to export graph",
|
"graph.export_failed": "Failed to export graph",
|
||||||
|
"graph.duplicate": "Duplicate selection",
|
||||||
|
"graph.duplicate_none": "Select one or more nodes to duplicate",
|
||||||
|
"graph.duplicate_none_eligible": "Nothing duplicable in the selection (only value & colour-strip sources)",
|
||||||
|
"graph.duplicate_done": "Duplicated {count} source(s)",
|
||||||
|
"graph.duplicate_done_warn": "Duplicated {count} source(s) — some references could not be remapped",
|
||||||
|
"graph.duplicate_failed": "Failed to duplicate selection",
|
||||||
"graph.delete_with_dependents_confirm": "This entity is used by {count} other(s): {names}. Delete it and break those connections?",
|
"graph.delete_with_dependents_confirm": "This entity is used by {count} other(s): {names}. Delete it and break those connections?",
|
||||||
"automation.enabled": "Automation enabled",
|
"automation.enabled": "Automation enabled",
|
||||||
"automation.disabled": "Automation disabled",
|
"automation.disabled": "Automation disabled",
|
||||||
|
|||||||
@@ -2286,6 +2286,12 @@
|
|||||||
"graph.export": "Экспорт графа (JSON)",
|
"graph.export": "Экспорт графа (JSON)",
|
||||||
"graph.export_done": "Граф экспортирован",
|
"graph.export_done": "Граф экспортирован",
|
||||||
"graph.export_failed": "Не удалось экспортировать граф",
|
"graph.export_failed": "Не удалось экспортировать граф",
|
||||||
|
"graph.duplicate": "Дублировать выбранное",
|
||||||
|
"graph.duplicate_none": "Выберите один или несколько узлов для дублирования",
|
||||||
|
"graph.duplicate_none_eligible": "В выборе нечего дублировать (только источники значений и цветовых лент)",
|
||||||
|
"graph.duplicate_done": "Продублировано источников: {count}",
|
||||||
|
"graph.duplicate_done_warn": "Продублировано источников: {count} — часть ссылок не удалось перепривязать",
|
||||||
|
"graph.duplicate_failed": "Не удалось дублировать выбранное",
|
||||||
"graph.delete_with_dependents_confirm": "Этот объект используется {count} другими: {names}. Удалить и разорвать эти связи?",
|
"graph.delete_with_dependents_confirm": "Этот объект используется {count} другими: {names}. Удалить и разорвать эти связи?",
|
||||||
"automation.enabled": "Автоматизация включена",
|
"automation.enabled": "Автоматизация включена",
|
||||||
"automation.disabled": "Автоматизация выключена",
|
"automation.disabled": "Автоматизация выключена",
|
||||||
|
|||||||
@@ -2282,6 +2282,12 @@
|
|||||||
"graph.export": "导出图谱 (JSON)",
|
"graph.export": "导出图谱 (JSON)",
|
||||||
"graph.export_done": "图谱已导出",
|
"graph.export_done": "图谱已导出",
|
||||||
"graph.export_failed": "导出图谱失败",
|
"graph.export_failed": "导出图谱失败",
|
||||||
|
"graph.duplicate": "复制所选",
|
||||||
|
"graph.duplicate_none": "请选择一个或多个节点以复制",
|
||||||
|
"graph.duplicate_none_eligible": "所选内容中没有可复制的项(仅值源和色带源)",
|
||||||
|
"graph.duplicate_done": "已复制 {count} 个源",
|
||||||
|
"graph.duplicate_done_warn": "已复制 {count} 个源 — 部分引用无法重新映射",
|
||||||
|
"graph.duplicate_failed": "复制所选失败",
|
||||||
"graph.delete_with_dependents_confirm": "此实体被 {count} 个其他实体引用:{names}。删除并断开这些连接?",
|
"graph.delete_with_dependents_confirm": "此实体被 {count} 个其他实体引用:{names}。删除并断开这些连接?",
|
||||||
"automation.enabled": "自动化已启用",
|
"automation.enabled": "自动化已启用",
|
||||||
"automation.disabled": "自动化已禁用",
|
"automation.disabled": "自动化已禁用",
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ writes go through to SQLite immediately (write-through cache).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import threading
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Callable, Dict, Generic, List, TypeVar
|
from typing import Callable, Dict, Generic, List, TypeVar
|
||||||
|
|
||||||
from ledgrab.storage.database import Database
|
from ledgrab.storage.database import Database
|
||||||
@@ -136,6 +139,41 @@ class BaseSqliteStore(Generic[T]):
|
|||||||
await asyncio.to_thread(self._delete_item, item_id)
|
await asyncio.to_thread(self._delete_item, item_id)
|
||||||
logger.info(f"Deleted {self._entity_name}: {item_id}")
|
logger.info(f"Deleted {self._entity_name}: {item_id}")
|
||||||
|
|
||||||
|
def clone(self, item_id: str, new_name: str) -> T:
|
||||||
|
"""Faithfully duplicate an entity under a new id and name.
|
||||||
|
|
||||||
|
Deep-copies every field of the original (no serialize/deserialize
|
||||||
|
round-trip, so no field can be silently lost to a schema/dataclass name
|
||||||
|
mismatch), mints a fresh id that preserves the original's id prefix,
|
||||||
|
applies ``new_name`` and resets timestamps. References *inside* the clone
|
||||||
|
still point at whatever the original referenced — callers that want to
|
||||||
|
rewire intra-set references must do so after cloning.
|
||||||
|
|
||||||
|
SECURITY: this copies *every* field verbatim, including any secret a
|
||||||
|
model might hold. Callers must restrict cloning to non-secret-bearing
|
||||||
|
kinds (see ``_DUPLICABLE_KINDS`` in ``api/routes/graph.py``).
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if item_id not in self._items:
|
||||||
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
|
raise EntityNotFoundError(f"{self._entity_name} not found: {item_id}")
|
||||||
|
self._check_name_unique(new_name)
|
||||||
|
new = copy.deepcopy(self._items[item_id])
|
||||||
|
prefix = item_id.rsplit("_", 1)[0] if "_" in item_id else item_id
|
||||||
|
new_id = f"{prefix}_{uuid.uuid4().hex[:8]}"
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
new.id = new_id
|
||||||
|
new.name = new_name
|
||||||
|
if hasattr(new, "created_at"):
|
||||||
|
new.created_at = now
|
||||||
|
if hasattr(new, "updated_at"):
|
||||||
|
new.updated_at = now
|
||||||
|
self._items[new_id] = new
|
||||||
|
self._save_item(new_id, new)
|
||||||
|
logger.info("Cloned %s %s -> %s (%s)", self._entity_name, item_id, new_id, new_name)
|
||||||
|
return new
|
||||||
|
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return len(self._items)
|
return len(self._items)
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Integration tests for server-side subgraph duplication.
|
||||||
|
|
||||||
|
Exercises ``_duplicate_subgraph`` against the *real* value-source and
|
||||||
|
colour-strip stores (temp DB), asserting that references *within* the selection
|
||||||
|
are remapped to the clones while references to entities *outside* the selection
|
||||||
|
stay shared with the originals — and that the deep-copy clone preserves config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ledgrab.api import dependencies as deps
|
||||||
|
from ledgrab.api.graph_schema import serialize_entity
|
||||||
|
from ledgrab.api.routes.graph import _duplicate_subgraph
|
||||||
|
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||||
|
from ledgrab.storage.database import Database
|
||||||
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stores(tmp_path, monkeypatch):
|
||||||
|
db = Database(tmp_path / "dup.db")
|
||||||
|
css = ColorStripStore(db)
|
||||||
|
vss = ValueSourceStore(db)
|
||||||
|
monkeypatch.setattr(deps, "_deps", {"color_strip_store": css, "value_source_store": vss})
|
||||||
|
yield css, vss
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _layer(sid: str) -> dict:
|
||||||
|
return {"source_id": sid, "blend_mode": "normal", "opacity": 1.0, "enabled": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_composite_remaps_only_in_selection_refs(stores):
|
||||||
|
css, _vss = stores
|
||||||
|
b = css.create_source(name="B", source_type="single_color", colors=[[255, 0, 0]])
|
||||||
|
d = css.create_source(name="D", source_type="single_color", colors=[[0, 255, 0]])
|
||||||
|
a = css.create_source(name="A", source_type="composite", layers=[_layer(b.id), _layer(d.id)])
|
||||||
|
|
||||||
|
res = _duplicate_subgraph([a.id, b.id], " (copy)")
|
||||||
|
|
||||||
|
assert set(res["id_map"]) == {a.id, b.id}
|
||||||
|
assert res["skipped"] == []
|
||||||
|
assert res["warnings"] == []
|
||||||
|
|
||||||
|
a_new = serialize_entity(css.get(res["id_map"][a.id]))
|
||||||
|
layer_ids = [ly["source_id"] for ly in a_new["layers"]]
|
||||||
|
# B was in the selection -> remapped to the clone; D was not -> stays shared.
|
||||||
|
assert layer_ids == [res["id_map"][b.id], d.id]
|
||||||
|
assert a_new["name"] == "A (copy)"
|
||||||
|
# Original is untouched.
|
||||||
|
assert [ly["source_id"] for ly in serialize_entity(css.get(a.id))["layers"]] == [b.id, d.id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_preserves_clone_config(stores):
|
||||||
|
"""The deep-copy clone keeps every field except identity/name/timestamps —
|
||||||
|
guarding against the storage-vs-create-schema name mismatch (e.g. single
|
||||||
|
color's ``colors``) that a create-schema round-trip would silently drop."""
|
||||||
|
css, _vss = stores
|
||||||
|
src = css.create_source(name="Solid", source_type="single_color", colors=[[12, 34, 56]])
|
||||||
|
res = _duplicate_subgraph([src.id], " (copy)")
|
||||||
|
orig = serialize_entity(css.get(src.id))
|
||||||
|
clone = serialize_entity(css.get(res["id_map"][src.id]))
|
||||||
|
ignore = {"id", "name", "created_at", "updated_at"}
|
||||||
|
assert {k: v for k, v in clone.items() if k not in ignore} == {
|
||||||
|
k: v for k, v in orig.items() if k not in ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_partial_selection_keeps_layer_refs_shared(stores):
|
||||||
|
css, _vss = stores
|
||||||
|
b = css.create_source(name="B", source_type="single_color", colors=[[255, 0, 0]])
|
||||||
|
a = css.create_source(name="A", source_type="composite", layers=[_layer(b.id)])
|
||||||
|
|
||||||
|
res = _duplicate_subgraph([a.id], " (copy)") # only the composite, not its layer
|
||||||
|
|
||||||
|
a_new = serialize_entity(css.get(res["id_map"][a.id]))
|
||||||
|
assert [ly["source_id"] for ly in a_new["layers"]] == [b.id] # shared with original
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_remaps_bindable_slot_to_cloned_value_source(stores):
|
||||||
|
"""Pass-2's dict round-trip path: a colour-strip bindable slot bound to a
|
||||||
|
value source that is *also* in the selection must point at the value clone
|
||||||
|
(not the original) after duplication."""
|
||||||
|
css, vss = stores
|
||||||
|
vs = vss.create_source(name="Pulse", source_type="static", value=0.5)
|
||||||
|
c = css.create_source(name="Candle", source_type="candlelight")
|
||||||
|
css.update_source(c.id, intensity={"source_id": vs.id}) # bind intensity -> vs
|
||||||
|
assert serialize_entity(css.get(c.id))["intensity"]["source_id"] == vs.id # binding took
|
||||||
|
|
||||||
|
res = _duplicate_subgraph([c.id, vs.id], " (copy)")
|
||||||
|
|
||||||
|
assert res["warnings"] == []
|
||||||
|
c_new = serialize_entity(css.get(res["id_map"][c.id]))
|
||||||
|
assert c_new["intensity"]["source_id"] == res["id_map"][vs.id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_value_source(stores):
|
||||||
|
_css, vss = stores
|
||||||
|
v = vss.create_source(name="V", source_type="static", value=0.5)
|
||||||
|
res = _duplicate_subgraph([v.id], " (copy)")
|
||||||
|
assert list(res["id_map"]) == [v.id]
|
||||||
|
new = vss.get(res["id_map"][v.id])
|
||||||
|
assert new.id != v.id
|
||||||
|
assert new.name == "V (copy)"
|
||||||
|
assert getattr(new, "value", None) == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_skips_non_duplicable_ids(stores):
|
||||||
|
_css, vss = stores
|
||||||
|
v = vss.create_source(name="V", source_type="static", value=0.5)
|
||||||
|
res = _duplicate_subgraph([v.id, "dev_external", "bogus"], " (copy)")
|
||||||
|
assert list(res["id_map"]) == [v.id]
|
||||||
|
assert {s["id"] for s in res["skipped"]} == {"dev_external", "bogus"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_name_collision_is_suffixed(stores):
|
||||||
|
_css, vss = stores
|
||||||
|
v = vss.create_source(name="V", source_type="static", value=0.5)
|
||||||
|
vss.create_source(name="V (copy)", source_type="static", value=0.1) # occupy the obvious name
|
||||||
|
res = _duplicate_subgraph([v.id], " (copy)")
|
||||||
|
new = vss.get(res["id_map"][v.id])
|
||||||
|
assert new.name == "V (copy) 2"
|
||||||
@@ -17,6 +17,7 @@ from ledgrab.api.graph_schema import (
|
|||||||
find_dependents,
|
find_dependents,
|
||||||
graph_field_roots,
|
graph_field_roots,
|
||||||
is_editable,
|
is_editable,
|
||||||
|
remap_refs,
|
||||||
schema_for_kind,
|
schema_for_kind,
|
||||||
serialize_entity_for_graph,
|
serialize_entity_for_graph,
|
||||||
validate_connection,
|
validate_connection,
|
||||||
@@ -67,6 +68,56 @@ def test_extract_refs_nested_object_none_is_safe():
|
|||||||
) == ["pt_1"]
|
) == ["pt_1"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── remap_refs (write-twin of extract_refs) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_refs_top_level():
|
||||||
|
e = {"device_id": "a"}
|
||||||
|
assert remap_refs(e, "device_id", {"a": "b"}) == 1
|
||||||
|
assert e["device_id"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_refs_leaves_unmapped_ids_untouched():
|
||||||
|
e = {"device_id": "external"}
|
||||||
|
assert remap_refs(e, "device_id", {"a": "b"}) == 0
|
||||||
|
assert e["device_id"] == "external" # shared dependency outside the set
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_refs_bindable_bound():
|
||||||
|
e = {"brightness": {"value": 1.0, "source_id": "a"}}
|
||||||
|
assert remap_refs(e, "brightness.source_id", {"a": "b"}) == 1
|
||||||
|
assert e["brightness"] == {"value": 1.0, "source_id": "b"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_refs_unbound_bindable_is_safe():
|
||||||
|
e = {"brightness": 0.5} # plain number, no binding
|
||||||
|
assert remap_refs(e, "brightness.source_id", {"a": "b"}) == 0
|
||||||
|
assert e["brightness"] == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_refs_list_field_only_mapped_elements():
|
||||||
|
e = {"layers": [{"source_id": "a"}, {"source_id": "external"}, {"source_id": "c"}]}
|
||||||
|
assert remap_refs(e, "layers[].source_id", {"a": "b", "c": "d"}) == 2
|
||||||
|
assert [layer["source_id"] for layer in e["layers"]] == ["b", "external", "d"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_refs_deep_object_then_list():
|
||||||
|
e = {"calibration": {"lines": [{"picture_source_id": "p1"}, {"picture_source_id": "p2"}]}}
|
||||||
|
assert remap_refs(e, "calibration.lines[].picture_source_id", {"p1": "q1"}) == 1
|
||||||
|
assert [ln["picture_source_id"] for ln in e["calibration"]["lines"]] == ["q1", "p2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_refs_missing_keys_are_safe():
|
||||||
|
assert remap_refs({}, "layers[].source_id", {"a": "b"}) == 0
|
||||||
|
assert remap_refs({"layers": None}, "layers[].source_id", {"a": "b"}) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_refs_round_trips_with_extract_refs():
|
||||||
|
e = {"layers": [{"source_id": "a"}, {"source_id": "a"}]}
|
||||||
|
remap_refs(e, "layers[].source_id", {"a": "b"})
|
||||||
|
assert extract_refs(e, "layers[].source_id") == ["b", "b"]
|
||||||
|
|
||||||
|
|
||||||
# ── registry consistency ─────────────────────────────────────────────────────
|
# ── registry consistency ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user