@@ -63,6 +63,11 @@ _LIVE_TEXT_TOKEN = re.compile(r"\{([^{}\r\n]+)\}")
|
||||
_LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
_PLAN_ONLY_DASH = "—"
|
||||
_IMPORT_ID_NAMESPACES = {
|
||||
"space", "room", "marker", "partition", "opening", "decor", "draft", "column",
|
||||
}
|
||||
_MAX_IMPORT_LINEAGE_DEPTH = 16
|
||||
_REPORT_EXAMPLE_LIMIT = 24
|
||||
|
||||
_SPACE_PLAN_FIELDS = (
|
||||
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
|
||||
@@ -801,8 +806,31 @@ def _unique_title(base: str, existing: list[dict[str, Any]]) -> str:
|
||||
return f"{base} ({n})"
|
||||
|
||||
|
||||
def canonical_import_root(prefix: str, value: str) -> tuple[str, int, bool]:
|
||||
"""Return the bounded root of ids generated by a previous space import.
|
||||
|
||||
Only the exact ``<same namespace>_<stem>_<8 lowercase hex>`` envelope is
|
||||
reversible. Similar user ids, other namespaces and uppercase/short hashes
|
||||
remain literal. The layer count and bounded flag are shared with the
|
||||
TypeScript helper through one conformance fixture. The flag reports that
|
||||
another valid layer remained after the safety bound.
|
||||
"""
|
||||
root = str(value)
|
||||
pattern = re.compile(rf"^{re.escape(prefix)}_(.+)_([0-9a-f]{{8}})$")
|
||||
layers = 0
|
||||
for _depth in range(_MAX_IMPORT_LINEAGE_DEPTH):
|
||||
match = pattern.fullmatch(root)
|
||||
if match is None:
|
||||
return root, layers, False
|
||||
root = match.group(1)
|
||||
layers += 1
|
||||
return root, layers, pattern.fullmatch(root) is not None
|
||||
|
||||
|
||||
def _fresh(prefix: str, old: str, used: set[str]) -> str:
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]+", "_", old).strip("_")[:35] or prefix
|
||||
root, _layers, _bounded = canonical_import_root(prefix, old) \
|
||||
if prefix in _IMPORT_ID_NAMESPACES else (old, 0, False)
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]+", "_", root).strip("_")[:35] or prefix
|
||||
while True:
|
||||
value = f"{prefix}_{stem}_{secrets.token_hex(4)}"
|
||||
if value not in used:
|
||||
@@ -822,53 +850,281 @@ def _orphan_marker(key: str, manifest: dict[str, Any] | None, space_id: str, use
|
||||
}
|
||||
|
||||
|
||||
def _empty_reference_report() -> dict[str, Any]:
|
||||
return {
|
||||
"remapped": {"incoming": {}, "target": {}},
|
||||
"collisions": {},
|
||||
"preservedUnresolved": {},
|
||||
"droppedIncomingLinks": {},
|
||||
"boundedLineages": 0,
|
||||
"examples": [],
|
||||
}
|
||||
|
||||
|
||||
def _report_reference(
|
||||
report: dict[str, Any], bucket: str, category: str, owner: str, reference: str,
|
||||
) -> None:
|
||||
section = report.setdefault(bucket, {})
|
||||
section[category] = int(section.get(category, 0)) + 1
|
||||
examples = report.setdefault("examples", [])
|
||||
if len(examples) < _REPORT_EXAMPLE_LIMIT:
|
||||
examples.append({
|
||||
"bucket": bucket, "category": category,
|
||||
"owner": str(owner)[:160], "reference": str(reference)[:160],
|
||||
})
|
||||
|
||||
|
||||
def _report_remap(
|
||||
report: dict[str, Any], side: str, category: str, owner: str, reference: str,
|
||||
) -> None:
|
||||
section = report.setdefault("remapped", {}).setdefault(side, {})
|
||||
section[category] = int(section.get(category, 0)) + 1
|
||||
examples = report.setdefault("examples", [])
|
||||
if len(examples) < _REPORT_EXAMPLE_LIMIT:
|
||||
examples.append({
|
||||
"bucket": f"remapped.{side}", "category": category,
|
||||
"owner": str(owner)[:160], "reference": str(reference)[:160],
|
||||
})
|
||||
|
||||
|
||||
def _record_bounded_lineage(
|
||||
report: dict[str, Any], seen: set[tuple[str, str]], prefix: str, value: str,
|
||||
) -> None:
|
||||
_root, _layers, bounded = canonical_import_root(prefix, value)
|
||||
if bounded:
|
||||
seen.add((prefix, value))
|
||||
report["boundedLineages"] = len(seen)
|
||||
|
||||
|
||||
def _lineage_resolver(
|
||||
prefix: str,
|
||||
exact_map: dict[str, str],
|
||||
live_ids: set[str],
|
||||
report: dict[str, Any],
|
||||
bounded_seen: set[tuple[str, str]],
|
||||
) -> Callable[[str], tuple[str | None, str]]:
|
||||
"""Resolve only dead exact or uniquely provable cross-generation refs."""
|
||||
imported: dict[str, set[str]] = {}
|
||||
live: dict[str, set[str]] = {}
|
||||
for old, new in exact_map.items():
|
||||
root, _layers, bounded = canonical_import_root(prefix, old)
|
||||
imported.setdefault(root, set()).add(new)
|
||||
if bounded:
|
||||
_record_bounded_lineage(report, bounded_seen, prefix, old)
|
||||
for value in live_ids:
|
||||
root, _layers, bounded = canonical_import_root(prefix, value)
|
||||
live.setdefault(root, set()).add(value)
|
||||
if bounded:
|
||||
_record_bounded_lineage(report, bounded_seen, prefix, value)
|
||||
|
||||
def resolve(reference: str) -> tuple[str | None, str]:
|
||||
if reference in live_ids:
|
||||
return None, "live"
|
||||
if reference in exact_map:
|
||||
return exact_map[reference], "exact"
|
||||
root, _layers, bounded = canonical_import_root(prefix, reference)
|
||||
if bounded:
|
||||
_record_bounded_lineage(report, bounded_seen, prefix, reference)
|
||||
candidates = imported.get(root, set())
|
||||
if not candidates:
|
||||
return None, "unrelated"
|
||||
if len(candidates) == 1 and not live.get(root):
|
||||
return next(iter(candidates)), "lineage"
|
||||
return None, "ambiguous"
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def _repair_target_space_refs(
|
||||
current_config: dict[str, Any],
|
||||
current_layout: dict[str, Any],
|
||||
old_space_id: str,
|
||||
new_space_id: str,
|
||||
old_room_ids: dict[str, str],
|
||||
id_maps: dict[str, dict[str, str]],
|
||||
marker_link_map: dict[str, str],
|
||||
report: dict[str, Any],
|
||||
bounded_seen: set[tuple[str, str]],
|
||||
) -> tuple[dict[str, Any], dict[str, Any], int]:
|
||||
"""Apply the exact map of this import to pre-existing orphan target refs."""
|
||||
"""Repair target refs by exact map, then by one safe lineage candidate."""
|
||||
config = _json_copy(current_config)
|
||||
layout = _json_copy(current_layout)
|
||||
if any(str(space.get("id")) == old_space_id for space in config.get("spaces") or []):
|
||||
return config, layout, 0
|
||||
|
||||
spaces = config.get("spaces") or []
|
||||
markers = config.get("markers") or []
|
||||
live_spaces = {str(item.get("id")) for item in spaces if item.get("id") is not None}
|
||||
live_rooms = {
|
||||
str(room.get("id")) for space in spaces for room in space.get("rooms") or []
|
||||
if room.get("id") is not None
|
||||
}
|
||||
live_partitions = {
|
||||
str(item.get("id")) for space in spaces for item in space.get("partitions") or []
|
||||
if item.get("id") is not None
|
||||
}
|
||||
live_markers = {
|
||||
str(marker.get("id")) for marker in markers if marker.get("id") is not None
|
||||
}
|
||||
resolve_space = _lineage_resolver(
|
||||
"space", id_maps.get("space", {}), live_spaces, report, bounded_seen,
|
||||
)
|
||||
resolve_room = _lineage_resolver(
|
||||
"room", id_maps.get("room", {}), live_rooms, report, bounded_seen,
|
||||
)
|
||||
resolve_partition = _lineage_resolver(
|
||||
"partition", id_maps.get("partition", {}), live_partitions, report, bounded_seen,
|
||||
)
|
||||
resolve_marker = _lineage_resolver(
|
||||
"marker", id_maps.get("marker", {}), live_markers, report, bounded_seen,
|
||||
)
|
||||
resolve_marker_link = _lineage_resolver(
|
||||
"marker", marker_link_map, live_markers, report, bounded_seen,
|
||||
)
|
||||
imported_space_ids = set(id_maps.get("space", {}).values())
|
||||
repaired = 0
|
||||
for marker in config.get("markers") or []:
|
||||
if marker.get("space") == old_space_id:
|
||||
marker["space"] = new_space_id
|
||||
repaired += 1
|
||||
room_id = marker.get("room_id")
|
||||
if room_id is not None and str(room_id) in old_room_ids:
|
||||
marker["room_id"] = old_room_ids[str(room_id)]
|
||||
unresolved: set[tuple[str, str, str]] = set()
|
||||
|
||||
def replace(
|
||||
owner: str, category: str, reference: Any,
|
||||
resolver: Callable[[str], tuple[str | None, str]],
|
||||
) -> str | None:
|
||||
nonlocal repaired
|
||||
if not isinstance(reference, str) or not reference:
|
||||
return None
|
||||
mapped, reason = resolver(reference)
|
||||
if mapped is not None:
|
||||
repaired += 1
|
||||
_report_remap(report, "target", category, owner, reference)
|
||||
return mapped
|
||||
if reason == "ambiguous":
|
||||
preserve_once(owner, category, reference)
|
||||
return None
|
||||
|
||||
def preserve_once(owner: str, category: str, reference: str) -> None:
|
||||
key = (owner, category, reference)
|
||||
if key in unresolved:
|
||||
return
|
||||
unresolved.add(key)
|
||||
_report_reference(
|
||||
report, "preservedUnresolved", category, owner, reference,
|
||||
)
|
||||
|
||||
def preserve_related(
|
||||
owner: str, category: str, reference: Any,
|
||||
resolver: Callable[[str], tuple[str | None, str]],
|
||||
) -> None:
|
||||
if not isinstance(reference, str) or not reference:
|
||||
return
|
||||
mapped, reason = resolver(reference)
|
||||
if mapped is not None or reason == "ambiguous":
|
||||
preserve_once(owner, category, reference)
|
||||
|
||||
for space in spaces:
|
||||
space_id = str(space.get("id", "?"))
|
||||
for room in space.get("rooms") or []:
|
||||
room_id = str(room.get("id", "?"))
|
||||
values = room.get("open_to")
|
||||
if isinstance(values, list):
|
||||
for value in values:
|
||||
preserve_related(
|
||||
f"{space_id}:{room_id}", "room.open_to", value, resolve_room,
|
||||
)
|
||||
for opening in space.get("openings") or []:
|
||||
host = opening.get("host") if isinstance(opening, dict) else None
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
# A partition host is space-local. An imported partition lives
|
||||
# in the new independent copy, so a pre-existing opening in a
|
||||
# different target space cannot be rebound to it safely.
|
||||
preserve_related(
|
||||
f"{space_id}:{opening.get('id', '?')}", "opening.host",
|
||||
host.get("id"), resolve_partition,
|
||||
)
|
||||
|
||||
for marker in markers:
|
||||
marker_id = str(marker.get("id", "?"))
|
||||
mapped = replace(marker_id, "marker.space", marker.get("space"), resolve_space)
|
||||
if mapped is not None:
|
||||
marker["space"] = mapped
|
||||
may_rebind_room = marker.get("space") in imported_space_ids
|
||||
if may_rebind_room:
|
||||
mapped = replace(marker_id, "marker.room_id", marker.get("room_id"), resolve_room)
|
||||
if mapped is not None:
|
||||
marker["room_id"] = mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.room_id", marker.get("room_id"), resolve_room,
|
||||
)
|
||||
vacuum = marker.get("vacuum")
|
||||
segment_map = vacuum.get("segment_map") if isinstance(vacuum, dict) else None
|
||||
if isinstance(segment_map, dict):
|
||||
for key, room_id in list(segment_map.items()):
|
||||
mapped = old_room_ids.get(str(room_id))
|
||||
if may_rebind_room:
|
||||
mapped = replace(
|
||||
marker_id, "marker.vacuum.segment_map", room_id, resolve_room,
|
||||
)
|
||||
if mapped is not None:
|
||||
segment_map[key] = mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.vacuum.segment_map", room_id, resolve_room,
|
||||
)
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
for index, ref in enumerate(controls):
|
||||
if not isinstance(ref, str) or not ref.startswith("marker:"):
|
||||
continue
|
||||
old_target = ref[len("marker:"):]
|
||||
mapped = replace(
|
||||
marker_id, "marker.controls", old_target, resolve_marker_link,
|
||||
)
|
||||
if mapped is not None:
|
||||
segment_map[key] = mapped
|
||||
repaired += 1
|
||||
controls[index] = "marker:" + mapped
|
||||
else:
|
||||
# A duplicate may have been virtualised or otherwise lost
|
||||
# light-source semantics. Such a marker remains a valid
|
||||
# layout owner, but it is not a valid controls target.
|
||||
preserve_related(
|
||||
marker_id, "marker.controls", old_target, resolve_marker,
|
||||
)
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
old_target = ref[len("marker:"):] \
|
||||
if isinstance(ref, str) and ref.startswith("marker:") else None
|
||||
mapped = replace(
|
||||
marker_id, "marker.value_badge", old_target, resolve_marker_link,
|
||||
)
|
||||
if mapped is not None:
|
||||
source["ref"] = "marker:" + mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.value_badge", old_target, resolve_marker,
|
||||
)
|
||||
|
||||
# Rewrite ownership before rekeying labels. A valid destination wins a
|
||||
# collision inside the target; the imported document itself is overlaid
|
||||
# later and has final priority for its own label position.
|
||||
for position in layout.values():
|
||||
if isinstance(position, dict) and position.get("s") == old_space_id:
|
||||
position["s"] = new_space_id
|
||||
repaired += 1
|
||||
for old_room_id, new_room_id in old_room_ids.items():
|
||||
old_key = "rl_" + old_room_id
|
||||
if old_key not in layout:
|
||||
# Destination wins collisions. Rekey only proven dead plan-owned keys;
|
||||
# opaque HA owners remain literal.
|
||||
for key in list(layout):
|
||||
position = layout[key]
|
||||
if isinstance(position, dict):
|
||||
mapped_space = replace(key, "layout.space", position.get("s"), resolve_space)
|
||||
if mapped_space is not None:
|
||||
position["s"] = mapped_space
|
||||
new_key: str | None = None
|
||||
category = ""
|
||||
if key.startswith("rl_"):
|
||||
if isinstance(position, dict) and position.get("s") in imported_space_ids:
|
||||
mapped_room = replace(key, "layout.room_label", key[3:], resolve_room)
|
||||
if mapped_room is not None:
|
||||
new_key, category = "rl_" + mapped_room, "layout.room_label"
|
||||
else:
|
||||
preserve_related(key, "layout.room_label", key[3:], resolve_room)
|
||||
elif key not in live_markers:
|
||||
mapped_marker = replace(key, "layout.marker", key, resolve_marker)
|
||||
if mapped_marker is not None:
|
||||
new_key, category = mapped_marker, "layout.marker"
|
||||
if new_key is None or new_key == key:
|
||||
continue
|
||||
new_key = "rl_" + new_room_id
|
||||
if new_key not in layout:
|
||||
layout[new_key] = layout[old_key]
|
||||
del layout[old_key]
|
||||
repaired += 1
|
||||
if new_key in layout:
|
||||
_report_reference(report, "collisions", category, key, new_key)
|
||||
else:
|
||||
layout[new_key] = position
|
||||
del layout[key]
|
||||
return config, layout, repaired
|
||||
|
||||
|
||||
@@ -889,14 +1145,53 @@ def build_space_merge(
|
||||
raise ImportFailure("invalid_config", "A space export must contain exactly one space")
|
||||
space = spaces[0]
|
||||
_materialize_space_background(space, "static")
|
||||
reference_report = _empty_reference_report()
|
||||
bounded_seen: set[tuple[str, str]] = set()
|
||||
target_config = _json_copy(current_config)
|
||||
target_layout = _json_copy(current_layout)
|
||||
used = {
|
||||
str(value)
|
||||
for sp in current_config.get("spaces") or []
|
||||
for value in [sp.get("id")]
|
||||
} | {str(m.get("id")) for m in current_config.get("markers") or []}
|
||||
for collection in (
|
||||
(sp,), sp.get("rooms") or [], sp.get("room_drafts") or [],
|
||||
sp.get("partitions") or [], sp.get("wall_columns") or [],
|
||||
sp.get("openings") or [], sp.get("decor") or [],
|
||||
)
|
||||
for item in collection
|
||||
if isinstance(item, dict)
|
||||
for value in [item.get("id")]
|
||||
if value is not None
|
||||
} | {
|
||||
str(m.get("id")) for m in current_config.get("markers") or []
|
||||
if m.get("id") is not None
|
||||
} | {
|
||||
str(value)
|
||||
for incoming_space in spaces
|
||||
for collection in (
|
||||
(incoming_space,), incoming_space.get("rooms") or [],
|
||||
incoming_space.get("room_drafts") or [],
|
||||
incoming_space.get("partitions") or [],
|
||||
incoming_space.get("wall_columns") or [],
|
||||
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
|
||||
)
|
||||
for item in collection
|
||||
if isinstance(item, dict)
|
||||
for value in [item.get("id")]
|
||||
if value is not None
|
||||
} | {
|
||||
str(m.get("id")) for m in incoming.get("markers") or []
|
||||
if m.get("id") is not None
|
||||
}
|
||||
old_space_id = str(space.get("id"))
|
||||
_record_bounded_lineage(
|
||||
reference_report, bounded_seen, "space", old_space_id,
|
||||
)
|
||||
new_space_id = _fresh("space", old_space_id, used)
|
||||
id_map: dict[str, str] = {old_space_id: new_space_id}
|
||||
id_maps: dict[str, dict[str, str]] = {
|
||||
prefix: {} for prefix in _IMPORT_ID_NAMESPACES
|
||||
}
|
||||
id_maps["space"][old_space_id] = new_space_id
|
||||
for collection, prefix in (
|
||||
("rooms", "room"), ("room_drafts", "draft"), ("partitions", "partition"),
|
||||
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
|
||||
@@ -904,17 +1199,26 @@ def build_space_merge(
|
||||
for item in space.get(collection) or []:
|
||||
if isinstance(item, dict) and item.get("id") is not None:
|
||||
old = str(item["id"])
|
||||
_record_bounded_lineage(
|
||||
reference_report, bounded_seen, prefix, old,
|
||||
)
|
||||
id_map[old] = _fresh(prefix, old, used)
|
||||
id_maps[prefix][old] = id_map[old]
|
||||
item["id"] = id_map[old]
|
||||
old_room_ids = {
|
||||
old: new for old, new in id_map.items() if old != old_space_id and new.startswith("room_")
|
||||
}
|
||||
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
|
||||
current_config, current_layout, old_space_id, new_space_id, old_room_ids,
|
||||
)
|
||||
old_room_ids = id_maps["room"]
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("open_to"):
|
||||
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
|
||||
remapped_open_to = []
|
||||
for value in room["open_to"]:
|
||||
old_value = str(value)
|
||||
mapped_value = old_room_ids.get(old_value, old_value)
|
||||
remapped_open_to.append(mapped_value)
|
||||
if mapped_value != old_value:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "room.open_to",
|
||||
str(room.get("id", "?")), old_value,
|
||||
)
|
||||
room["open_to"] = remapped_open_to
|
||||
# Opening ownership is part of the same space-local id graph. Remap the
|
||||
# nested reference together with the partition itself; otherwise the
|
||||
# invariant validator correctly rejects a copied space whose host.id still
|
||||
@@ -925,6 +1229,10 @@ def build_space_merge(
|
||||
old_host_id = str(host.get("id"))
|
||||
if old_host_id in id_map:
|
||||
host["id"] = id_map[old_host_id]
|
||||
_report_remap(
|
||||
reference_report, "incoming", "opening.host",
|
||||
str(opening.get("id", "?")), old_host_id,
|
||||
)
|
||||
space["id"] = new_space_id
|
||||
space["title"] = _unique_title(
|
||||
str(space.get("title") or old_space_id), current_config.get("spaces") or []
|
||||
@@ -947,22 +1255,40 @@ def build_space_merge(
|
||||
dropped_marker_links = _transfer_dropped_marker_links(document)
|
||||
for marker in incoming.get("markers") or []:
|
||||
old_id = str(marker.get("id"))
|
||||
_record_bounded_lineage(
|
||||
reference_report, bounded_seen, "marker", old_id,
|
||||
)
|
||||
binding = _binding_key(marker)
|
||||
if binding in duplicate and duplicate_policy == "skip":
|
||||
skipped.add(old_id)
|
||||
continue
|
||||
new_id = _fresh("marker", old_id, used)
|
||||
marker_map[old_id] = new_id
|
||||
id_maps["marker"][old_id] = new_id
|
||||
marker["id"] = new_id
|
||||
marker["space"] = new_space_id
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.space", new_id, old_space_id,
|
||||
)
|
||||
if marker.get("room_id") is not None:
|
||||
marker["room_id"] = old_room_ids.get(str(marker["room_id"]), marker["room_id"])
|
||||
old_room_id = str(marker["room_id"])
|
||||
marker["room_id"] = old_room_ids.get(old_room_id, marker["room_id"])
|
||||
if marker["room_id"] != old_room_id:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.room_id", new_id, old_room_id,
|
||||
)
|
||||
vacuum = marker.get("vacuum")
|
||||
if isinstance(vacuum, dict) and isinstance(vacuum.get("segment_map"), dict):
|
||||
vacuum["segment_map"] = {
|
||||
key: old_room_ids.get(str(value), value)
|
||||
for key, value in vacuum["segment_map"].items()
|
||||
}
|
||||
remapped_segments = {}
|
||||
for key, value in vacuum["segment_map"].items():
|
||||
old_room_id = str(value)
|
||||
remapped_segments[key] = old_room_ids.get(old_room_id, value)
|
||||
if remapped_segments[key] != old_room_id:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.vacuum.segment_map",
|
||||
new_id, old_room_id,
|
||||
)
|
||||
vacuum["segment_map"] = remapped_segments
|
||||
if binding in duplicate and duplicate_policy == "virtual":
|
||||
virtualized += 1
|
||||
virtualized_targets.add(old_id)
|
||||
@@ -1003,6 +1329,10 @@ def build_space_merge(
|
||||
dropped_marker_links += 1
|
||||
continue
|
||||
remapped.append("marker:" + target)
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.controls",
|
||||
str(marker.get("id", "?")), old_target,
|
||||
)
|
||||
marker["controls"] = remapped or None
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
@@ -1012,11 +1342,30 @@ def build_space_merge(
|
||||
target = None if old_target in virtualized_targets else marker_map.get(old_target)
|
||||
if target:
|
||||
source["ref"] = "marker:" + target
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.value_badge",
|
||||
str(marker.get("id", "?")), old_target,
|
||||
)
|
||||
else:
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
|
||||
output_markers_by_id = {
|
||||
str(marker.get("id")): marker for marker in output_markers
|
||||
if marker.get("id") is not None
|
||||
}
|
||||
marker_link_map = {
|
||||
old_id: new_id for old_id, new_id in marker_map.items()
|
||||
if (target := output_markers_by_id.get(new_id)) is not None
|
||||
and target.get("removed") is not True
|
||||
and target.get("is_light") is True
|
||||
}
|
||||
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
|
||||
current_config, current_layout, id_maps, marker_link_map,
|
||||
reference_report, bounded_seen,
|
||||
)
|
||||
|
||||
output_layout: dict[str, Any] = {}
|
||||
for key, pos in (document["payload"].get("layout") or {}).items():
|
||||
if key in skipped:
|
||||
@@ -1042,6 +1391,16 @@ def build_space_merge(
|
||||
new_key = orphan["id"]
|
||||
if layout_binding in duplicate and duplicate_policy == "virtual":
|
||||
virtualized += 1
|
||||
if new_key != key:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "layout.owner", new_key, key,
|
||||
)
|
||||
old_layout_space = str(pos.get("s", "")) if isinstance(pos, dict) else ""
|
||||
if old_layout_space != new_space_id:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "layout.space", new_key,
|
||||
old_layout_space,
|
||||
)
|
||||
output_layout[new_key] = {**pos, "s": new_space_id}
|
||||
|
||||
merged_config = target_config
|
||||
@@ -1079,6 +1438,8 @@ def build_space_merge(
|
||||
merged_config, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
||||
).encode("utf-8")) > MAX_CONFIG_BYTES:
|
||||
raise ImportFailure("capacity_exceeded", "Merged configuration exceeds the store limit")
|
||||
if dropped_marker_links:
|
||||
reference_report["droppedIncomingLinks"]["marker.links"] = dropped_marker_links
|
||||
return merged_config, merged_layout, {
|
||||
"space_id": new_space_id,
|
||||
"space_title": space["title"],
|
||||
@@ -1088,6 +1449,10 @@ def build_space_merge(
|
||||
"orphan_markers": len(output_markers) - len(marker_map),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
"repaired_target_refs": repaired_target_refs,
|
||||
"preserved_unresolved_refs": sum(
|
||||
int(value) for value in reference_report["preservedUnresolved"].values()
|
||||
),
|
||||
"reference_report": reference_report,
|
||||
}
|
||||
|
||||
|
||||
@@ -1211,6 +1576,70 @@ def _missing_areas(
|
||||
return sorted(incoming - available)
|
||||
|
||||
|
||||
def _materialize_import_candidate(
|
||||
document: dict[str, Any],
|
||||
current_config: dict[str, Any],
|
||||
current_layout: dict[str, Any],
|
||||
*,
|
||||
duplicate_policy: str,
|
||||
same_source: bool,
|
||||
content: list[dict[str, Any]],
|
||||
confirmation_required: bool,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
"""Build once the normalized config/layout that the preview may apply."""
|
||||
prepared = _json_copy(document)
|
||||
imported_config = prepared["payload"]["config"]
|
||||
if confirmation_required:
|
||||
_detach_missing(imported_config, content)
|
||||
if prepared["kind"] == "space":
|
||||
config, layout, details = build_space_merge(
|
||||
prepared, current_config, current_layout, duplicate_policy,
|
||||
same_source=same_source,
|
||||
)
|
||||
else:
|
||||
config = imported_config
|
||||
_materialize_global_background(config)
|
||||
model_version = prepared.get("model_version", 0)
|
||||
if isinstance(model_version, int) and not isinstance(model_version, bool) \
|
||||
and model_version > 0:
|
||||
config["model_version"] = model_version
|
||||
layout = _json_copy(prepared["payload"]["layout"])
|
||||
if not same_source:
|
||||
settings = config.get("settings") or {}
|
||||
settings.pop("known_devices", None)
|
||||
settings.pop("new_device_ids", None)
|
||||
details = {
|
||||
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
|
||||
}
|
||||
try:
|
||||
config = CONFIG_SCHEMA(config)
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
try:
|
||||
layout = LAYOUT_SCHEMA(layout)
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_layout", str(err)) from err
|
||||
details = _json_copy(details)
|
||||
details["counts"] = _counts(config, layout)
|
||||
return config, layout, details
|
||||
|
||||
|
||||
def _candidate_digest(candidate: dict[str, Any]) -> str:
|
||||
"""Bind a token to both its source and the exact normalized target pair."""
|
||||
return _document_digest({
|
||||
"document": candidate.get("document"),
|
||||
"duplicate_policy": candidate.get("duplicate_policy"),
|
||||
"config_rev": candidate.get("config_rev"),
|
||||
"layout_rev": candidate.get("layout_rev"),
|
||||
"content": candidate.get("content"),
|
||||
"confirmation_required": candidate.get("confirmation_required"),
|
||||
"same_source": candidate.get("same_source"),
|
||||
"target_config": candidate.get("target_config"),
|
||||
"target_layout": candidate.get("target_layout"),
|
||||
"details": candidate.get("details"),
|
||||
})
|
||||
|
||||
|
||||
def create_preview(
|
||||
runtime: HouseplanData,
|
||||
raw: bytes,
|
||||
@@ -1236,16 +1665,10 @@ def create_preview(
|
||||
validate_opening_passages(incoming_config, validate_all=True)
|
||||
except OpeningPassageError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
if document["kind"] == "space":
|
||||
_merged_config, _merged_layout, details = build_space_merge(
|
||||
document, current_config, current_layout, duplicate_policy,
|
||||
same_source=same_source,
|
||||
)
|
||||
else:
|
||||
if document["kind"] == "full":
|
||||
dropped_marker_links = _transfer_dropped_marker_links(document)
|
||||
dropped_marker_links += _drop_invalid_import_marker_links(incoming_config)
|
||||
document["transfer"]["dropped_marker_links"] = dropped_marker_links
|
||||
details["dropped_marker_links"] = dropped_marker_links
|
||||
try:
|
||||
validate_marker_controls(incoming_config, validate_all=True)
|
||||
validate_marker_light_entities(incoming_config, validate_all=True)
|
||||
@@ -1253,13 +1676,19 @@ def create_preview(
|
||||
except MarkerControlError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
content, confirmation = _content_state(document, same_source, config_root)
|
||||
target_config, target_layout, details = _materialize_import_candidate(
|
||||
document, current_config, current_layout,
|
||||
duplicate_policy=duplicate_policy,
|
||||
same_source=same_source,
|
||||
content=content,
|
||||
confirmation_required=confirmation,
|
||||
)
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
candidate = {
|
||||
"owner_id": owner_id,
|
||||
"created": now,
|
||||
"expires": now + IMPORT_PREVIEW_TTL_S,
|
||||
"digest": _document_digest(document),
|
||||
"document": document,
|
||||
"duplicate_policy": duplicate_policy,
|
||||
"config_rev": int(current_config_data.get("rev", 0)),
|
||||
@@ -1267,7 +1696,11 @@ def create_preview(
|
||||
"content": content,
|
||||
"confirmation_required": confirmation,
|
||||
"same_source": same_source,
|
||||
"target_config": target_config,
|
||||
"target_layout": target_layout,
|
||||
"details": details,
|
||||
}
|
||||
candidate["digest"] = _candidate_digest(candidate)
|
||||
# Opportunistic expiry and per-user oldest-first eviction.
|
||||
runtime.import_previews = {
|
||||
key: value for key, value in runtime.import_previews.items()
|
||||
@@ -1319,7 +1752,7 @@ def get_candidate(runtime: HouseplanData, token: str, owner_id: str, *, consume:
|
||||
raise ImportFailure("preview_expired", "Import preview expired")
|
||||
if candidate.get("owner_id") != owner_id:
|
||||
raise ImportFailure("preview_owner_mismatch", "Preview belongs to another user")
|
||||
if candidate.get("digest") != _document_digest(candidate.get("document") or {}):
|
||||
if candidate.get("digest") != _candidate_digest(candidate):
|
||||
runtime.import_previews.pop(token, None)
|
||||
raise ImportFailure("invalid_format", "Import preview candidate changed")
|
||||
if consume:
|
||||
@@ -1342,21 +1775,26 @@ def revalidate_candidate(
|
||||
"spaces": [], "markers": [], "settings": {},
|
||||
}
|
||||
current_layout = current_layout_data.get("layout") or {}
|
||||
details: dict[str, Any] = {}
|
||||
if document["kind"] == "space":
|
||||
_config, _layout, details = build_space_merge(
|
||||
document, current_config, current_layout, duplicate_policy,
|
||||
same_source=bool(candidate.get("same_source")),
|
||||
)
|
||||
if config_root is not None:
|
||||
content, confirmation = _content_state(
|
||||
document, bool(candidate.get("same_source")), config_root
|
||||
)
|
||||
candidate["content"] = content
|
||||
candidate["confirmation_required"] = confirmation
|
||||
target_config, target_layout, details = _materialize_import_candidate(
|
||||
document, current_config, current_layout,
|
||||
duplicate_policy=duplicate_policy,
|
||||
same_source=bool(candidate.get("same_source")),
|
||||
content=candidate.get("content") or [],
|
||||
confirmation_required=bool(candidate.get("confirmation_required")),
|
||||
)
|
||||
candidate["duplicate_policy"] = duplicate_policy
|
||||
candidate["config_rev"] = int(current_config_data.get("rev", 0))
|
||||
candidate["layout_rev"] = int(current_layout_data.get("rev", 0))
|
||||
candidate["target_config"] = target_config
|
||||
candidate["target_layout"] = target_layout
|
||||
candidate["details"] = details
|
||||
candidate["digest"] = _candidate_digest(candidate)
|
||||
incoming = document["payload"]
|
||||
return {
|
||||
"preview": {
|
||||
@@ -1388,35 +1826,17 @@ def prepare_apply(
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
if candidate.get("confirmation_required") and not confirm_missing_content:
|
||||
raise ImportFailure("content_confirmation_required", "Missing content must be confirmed")
|
||||
document = candidate["document"]
|
||||
# Detachment is defined in the exported identity space. Do it before a
|
||||
# space import remaps owners to fresh ids.
|
||||
imported_config = _json_copy(document["payload"]["config"])
|
||||
if candidate.get("confirmation_required"):
|
||||
_detach_missing(imported_config, candidate.get("content") or [])
|
||||
if document["kind"] == "full":
|
||||
config = imported_config
|
||||
_materialize_global_background(config)
|
||||
# Full exports keep the data-model version in the portable envelope so
|
||||
# the payload can be validated independently. Restore it before the
|
||||
# configuration is persisted; otherwise every full round-trip silently
|
||||
# downgrades the stored plan to an unversioned document.
|
||||
model_version = document.get("model_version", 0)
|
||||
if isinstance(model_version, int) and not isinstance(model_version, bool) \
|
||||
and model_version > 0:
|
||||
config["model_version"] = model_version
|
||||
layout = _json_copy(document["payload"]["layout"])
|
||||
if not candidate.get("same_source"):
|
||||
settings = config.get("settings") or {}
|
||||
settings.pop("known_devices", None)
|
||||
settings.pop("new_device_ids", None)
|
||||
details = {"counts": _counts(config, layout)}
|
||||
else:
|
||||
local_document = {**document, "payload": {**document["payload"], "config": imported_config}}
|
||||
config, layout, details = build_space_merge(
|
||||
local_document, current_config, current_layout,
|
||||
duplicate_policy or candidate.get("duplicate_policy", "skip"),
|
||||
same_source=bool(candidate.get("same_source")),
|
||||
)
|
||||
details["counts"] = _counts(config, layout)
|
||||
return CONFIG_SCHEMA(config), LAYOUT_SCHEMA(layout), details
|
||||
if duplicate_policy is not None and duplicate_policy != candidate.get("duplicate_policy"):
|
||||
raise ImportFailure("conflict", "Duplicate policy changed after the preview")
|
||||
if candidate.get("digest") != _candidate_digest(candidate):
|
||||
raise ImportFailure("invalid_format", "Import preview candidate changed")
|
||||
config = candidate.get("target_config")
|
||||
layout = candidate.get("target_layout")
|
||||
details = candidate.get("details")
|
||||
if not isinstance(config, dict) or not isinstance(layout, dict) \
|
||||
or not isinstance(details, dict):
|
||||
raise ImportFailure("invalid_format", "Import preview has no materialized candidate")
|
||||
# The current stores are intentionally unused: revisions are checked by
|
||||
# the websocket handler under the write lock before this function runs.
|
||||
_ = current_config, current_layout
|
||||
return _json_copy(config), _json_copy(layout), _json_copy(details)
|
||||
|
||||
@@ -511,6 +511,8 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"counts": details.get("counts", {}),
|
||||
"space_id": details.get("space_id"),
|
||||
"repaired_target_refs": details.get("repaired_target_refs", 0),
|
||||
"preserved_unresolved_refs": details.get("preserved_unresolved_refs", 0),
|
||||
"reference_report": details.get("reference_report", {}),
|
||||
"can_undo": kind == "full",
|
||||
})
|
||||
|
||||
|
||||
@@ -1193,6 +1193,22 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
|
||||
counts: { spaces: 1, rooms: 4, markers: full ? 12 : 0, layout: full ? 15 : 4 },
|
||||
duplicates: 0,
|
||||
repaired_target_refs: full ? 0 : 3,
|
||||
preserved_unresolved_refs: full ? 0 : 1,
|
||||
reference_report: full ? {} : {
|
||||
remapped: {
|
||||
incoming: { 'layout.space': 4, 'room.open_to': 1 },
|
||||
target: { 'marker.space': 3 },
|
||||
},
|
||||
collisions: {},
|
||||
preservedUnresolved: { 'marker.room_id': 1 },
|
||||
droppedIncomingLinks: {},
|
||||
boundedLineages: 0,
|
||||
examples: [{
|
||||
bucket: 'preservedUnresolved', category: 'marker.room_id',
|
||||
owner: 'legacy-thermostat', reference: 'room_old-generation_deadbeef',
|
||||
}],
|
||||
},
|
||||
confirmation_required: full,
|
||||
content: full
|
||||
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
|
||||
|
||||
@@ -82,6 +82,18 @@ const result = await page.evaluate(async () => {
|
||||
counts: { spaces: 1, rooms: 2, markers: 0, layout: 2 },
|
||||
bindings: { device: 0, entity: 0, virtual: 0, active: 0, disabled: 0, missing: 0 },
|
||||
duplicates: 0, confirmation_required: false, content: [],
|
||||
repaired_target_refs: 2, preserved_unresolved_refs: 1,
|
||||
reference_report: {
|
||||
remapped: {
|
||||
incoming: { 'layout.space': 2 }, target: { 'marker.space': 2 },
|
||||
},
|
||||
collisions: {}, preservedUnresolved: { 'marker.room_id': 1 },
|
||||
droppedIncomingLinks: {}, boundedLineages: 0,
|
||||
examples: [{
|
||||
bucket: 'preservedUnresolved', category: 'marker.room_id',
|
||||
owner: 'legacy-marker', reference: 'room_old_deadbeef',
|
||||
}],
|
||||
},
|
||||
},
|
||||
expectedConfigRev: 1, expectedLayoutRev: 2, duplicatePolicy: 'skip',
|
||||
confirmMissing: false, busy: false, error: '',
|
||||
@@ -93,6 +105,11 @@ const result = await page.evaluate(async () => {
|
||||
noDuplicatePolicy: !root().querySelector('.backupchoices'),
|
||||
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
|
||||
<= root().querySelector('hp-dialog').clientWidth,
|
||||
hasReferenceReport: !!root().querySelector('.backupdetails')
|
||||
&& root().querySelector('.backupdetails')?.textContent
|
||||
.includes(card._t('backup.import_details')),
|
||||
warnsWithoutDiscarding: root().querySelector('.backupwarn')?.textContent
|
||||
.includes(card._t('backup.preserved_unresolved_hint')) === true,
|
||||
};
|
||||
return {
|
||||
group, actions, keyboardImport, exportRadios, exportWarning,
|
||||
@@ -112,6 +129,9 @@ checkAll(result, {
|
||||
planOnlyResetForFull: true,
|
||||
planOnlyRequest: { kind: 'space', planOnly: true, space: 'f1' },
|
||||
importSafe: { danger: true, disabledUntilConfirmed: true, noHorizontalOverflow: true },
|
||||
planOnlyPreview: { visible: true, noDuplicatePolicy: true, noHorizontalOverflow: true },
|
||||
planOnlyPreview: {
|
||||
visible: true, noDuplicatePolicy: true, noHorizontalOverflow: true,
|
||||
hasReferenceReport: true, warnsWithoutDiscarding: true,
|
||||
},
|
||||
});
|
||||
await finish(browser, result);
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Re-importing an already imported space no longer grows nested internal-ID
|
||||
prefixes. Preview and Apply now share one immutable candidate, safely repair
|
||||
only uniquely proven references to earlier copies, and preserve ambiguous
|
||||
references without guessing or deletion; the import dialog exposes the
|
||||
reference details ([#265](https://github.com/Matysh/houseplan-card/issues/265)).
|
||||
- The wall-thickness tool now highlights a real wall at its actual physical
|
||||
width instead of inflating thin walls according to the plan's grid size.
|
||||
Zero-thickness walls keep a visible, scale-independent highlight and the
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Повторный импорт уже импортированного пространства больше не наращивает
|
||||
служебные префиксы внутренних ID. Предпросмотр и применение теперь используют
|
||||
один и тот же неизменяемый кандидат, безопасно восстанавливают единственные
|
||||
доказанные ссылки на прошлые копии и сохраняют неоднозначные ссылки без
|
||||
догадок или удаления; подробности доступны прямо в диалоге импорта
|
||||
([#265](https://github.com/Matysh/houseplan-card/issues/265)).
|
||||
- Инструмент толщины стен теперь подсвечивает реальную стену по её фактической
|
||||
физической ширине, не раздувая тонкие стены из-за размера клетки плана.
|
||||
Стены без толщины сохраняют видимую, независимую от масштаба подсветку, а
|
||||
|
||||
@@ -754,6 +754,12 @@ assigns new internal IDs and adds the copy without replacing global settings.
|
||||
When that exact import map matches orphan references already present in the
|
||||
target, the preview reports them and Apply restores them together with the
|
||||
space.
|
||||
Re-importing a copy still creates an independent space, but no longer grows
|
||||
nested service prefixes in internal IDs. Preview and **Add space** use the same
|
||||
prepared candidate. **Import reference details** reports links updated inside
|
||||
the copy and in the existing plan. If more than one target is possible, House
|
||||
Plan preserves the reference instead of guessing and recommends running
|
||||
**Optimize plans** after the import.
|
||||
Internal uploaded files are not embedded in JSON; an import to another HA
|
||||
instance must explicitly detach those links.
|
||||
|
||||
|
||||
@@ -1524,6 +1524,14 @@ show_signal: true
|
||||
предпросмотр показывает их количество, а применение восстанавливает их
|
||||
вместе с пространством.
|
||||
|
||||
Повторный импорт копии остаётся независимым, но не наращивает служебные
|
||||
префиксы внутренних ID. Предпросмотр и кнопка «Добавить» работают с одним и тем
|
||||
же подготовленным вариантом. В блоке **Подробности восстановления ссылок**
|
||||
видно, сколько ссылок обновлено внутри копии и в существующем плане. Если
|
||||
подходят несколько возможных целей, House Plan ничего не угадывает: ссылка
|
||||
сохраняется без изменений, диалог сообщает об этом и рекомендует после импорта
|
||||
запустить **Оптимизировать планы**.
|
||||
|
||||
Для текущего пространства можно включить **Только планировка**. Такой файл
|
||||
сохраняет комнаты, стены, проёмы, декор, трансформации подложки, позиции и
|
||||
выбранный масштаб подписей комнат, но удаляет реальные и виртуальные маркеры,
|
||||
|
||||
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 134 KiB |
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"sourceFingerprint": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"sourceFingerprint": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -14,8 +14,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"imageSha256": "c9e2203fe2df5c2627c861f92e9c4dfad585efb101df19e4b6388a6fbc27d01a"
|
||||
"sourceSha256": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"imageSha256": "5ba555c1f46416ce17e40ef2b6ecd9eb6e1d0ce6b8d467acd0682af95bd36307"
|
||||
},
|
||||
"view-touch": {
|
||||
"file": "02-view-touch.png",
|
||||
@@ -25,8 +25,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"imageSha256": "a544bd821460e5b9b7a83b1636ce6b6a3fc50be68fe4f74779d23ec9e12abe6a"
|
||||
"sourceSha256": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"imageSha256": "deaae14893e66337b42e45ccfe87cf16f571cfc10f98da4250a6a6589a092a43"
|
||||
},
|
||||
"space-create": {
|
||||
"file": "03-space-create.png",
|
||||
@@ -36,8 +36,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"imageSha256": "6df7753f00b7d87cd35fd18fb8fbac0a2a7fa2e549902b3afee38f38c93a2ac0"
|
||||
"sourceSha256": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
|
||||
},
|
||||
"room-contour-close": {
|
||||
"file": "04-room-contour-close.png",
|
||||
@@ -47,8 +47,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"imageSha256": "0129b6a5f1f97fa5d29db0a30bfd899bcfe53308a8ba3872d2a794132fccc9cc"
|
||||
"sourceSha256": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"imageSha256": "fd47ca017785dc96a7bc63158038d1ba55869345062cb5c131dc055ce83a03b0"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
"file": "05-plan-context-tray.png",
|
||||
@@ -58,8 +58,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"imageSha256": "7f243f578d2d286ae7911363571762e3910fe2117abc5253292d9ef2d578c8dc"
|
||||
"sourceSha256": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"imageSha256": "0878ed05b499884997ab1f6cd9ecbb255e3483e3a3b58287381f6aef4fcfa893"
|
||||
},
|
||||
"device-editor": {
|
||||
"file": "06-device-editor.png",
|
||||
@@ -69,8 +69,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"imageSha256": "24710a46be38418a09c4bdb3db8750611affea593d80c91cfe74c105ef8ed201"
|
||||
"sourceSha256": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"imageSha256": "c3704489b36fd4177e761124fac032d8d09d6a94e3110f9a77d40ec110c1590b"
|
||||
},
|
||||
"device-display-preview": {
|
||||
"file": "06-device-display-preview.png",
|
||||
@@ -80,8 +80,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"imageSha256": "1eaedd95180175f3db2c2e052bad70db63f6c70b296ec19c3d152ccbb81520ed"
|
||||
"sourceSha256": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"imageSha256": "7241478b3fae2a5b1895f241bc50a65f1aaacce907d311cc87796a251c389a8b"
|
||||
},
|
||||
"background-editor": {
|
||||
"file": "07-background-editor.png",
|
||||
@@ -91,8 +91,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"imageSha256": "460f098f7761a41c6b30e42940cb5c9d6f96966ba34e4df04ea273575c293e93"
|
||||
"sourceSha256": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"imageSha256": "8241b09b0d821247a86eaadd6043a37dde29623c870d8a142bcd381c638a306d"
|
||||
},
|
||||
"room-card": {
|
||||
"file": "08-room-card.png",
|
||||
@@ -102,8 +102,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
|
||||
"sourceSha256": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
|
||||
},
|
||||
"device-info": {
|
||||
"file": "09-device-info.png",
|
||||
@@ -113,8 +113,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "216d69b92af0549db58e417ad4b90fe154be0e88644e17fab859daf8d050160b",
|
||||
"imageSha256": "29b23ec03b320c35b3b6d725b3e69dd7a2cfcd1607cfbbe01dceb37c316964c3"
|
||||
"sourceSha256": "230a52eae7889a0b9262449284eebb26e08705610d241e30799215e893f70ec7",
|
||||
"imageSha256": "8e65ff2191ad1f1172848418c8381967ecff2cc831653a8f9c3ae11352a0e84b"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,15 @@ plan-id поля нельзя добавить без обновления ма
|
||||
| layout key `rl_<room-id>` | room | remap | exact/unique lineage |
|
||||
| layout `position.s` | space | remap | exact/unique lineage |
|
||||
|
||||
`exact/unique lineage` во второй колонке означает не только совпадение типа, но
|
||||
и сохранение локальной области владения. Ссылка на room или partition
|
||||
переписывается только если её владелец после того же remap находится в том же
|
||||
пространстве, что и новая цель. В частности, `rooms[].open_to[]` и
|
||||
`openings[].host.id` существующего независимого пространства нельзя направлять
|
||||
в новую импортированную копию: такая доказанно связанная, но пространственно
|
||||
несовместимая ссылка сохраняется без изменений и попадает в
|
||||
`preservedUnresolved`.
|
||||
|
||||
Следующие значения сохраняются буквально и не входят в plan-id lineage:
|
||||
|
||||
- HA entity/device/area ids, включая entity controls, `lg_<entity>` и
|
||||
|
||||
@@ -121,6 +121,12 @@ export function checkReferences({ config, layout = {} } = {}, { notes = [] } = {
|
||||
const spaceIds = new Set(spaces.map((space) => String(space?.id ?? '')).filter(Boolean));
|
||||
const markerIds = new Set((config?.markers || [])
|
||||
.map((marker) => String(marker?.id ?? '')).filter(Boolean));
|
||||
const activeMarkerIds = new Set((config?.markers || [])
|
||||
.filter((marker) => marker?.removed !== true)
|
||||
.map((marker) => String(marker?.id ?? '')).filter(Boolean));
|
||||
const activeLightMarkerIds = new Set((config?.markers || [])
|
||||
.filter((marker) => marker?.removed !== true && marker?.is_light === true)
|
||||
.map((marker) => String(marker?.id ?? '')).filter(Boolean));
|
||||
const roomIdsBySpace = new Map(spaces.map((space) => [String(space?.id ?? ''),
|
||||
new Set((space?.rooms || []).map((room) => String(room?.id ?? '')).filter(Boolean))]));
|
||||
const areasBySpace = new Map(spaces.map((space) => [String(space?.id ?? ''),
|
||||
@@ -130,9 +136,45 @@ export function checkReferences({ config, layout = {} } = {}, { notes = [] } = {
|
||||
|
||||
for (const marker of config?.markers || []) {
|
||||
if (marker?.removed) continue;
|
||||
const markerId = String(marker.id ?? '?');
|
||||
const space = marker?.space == null ? '' : String(marker.space);
|
||||
if (space && !spaceIds.has(space)) {
|
||||
add('marker_space', String(marker.id ?? '?'), space, 'пространства не существует');
|
||||
add('marker_space', markerId, space, 'пространства не существует');
|
||||
}
|
||||
const room = marker?.room_id == null ? '' : String(marker.room_id);
|
||||
if (room && spaceIds.has(space) && !roomIdsBySpace.get(space)?.has(room)) {
|
||||
add('marker_room', markerId, room, 'комнаты не существует в пространстве маркера');
|
||||
}
|
||||
const segments = marker?.vacuum?.segment_map;
|
||||
if (segments && typeof segments === 'object' && !Array.isArray(segments)) {
|
||||
for (const [segment, value] of Object.entries(segments)) {
|
||||
const roomId = String(value ?? '');
|
||||
if (roomId && spaceIds.has(space) && !roomIdsBySpace.get(space)?.has(roomId)) {
|
||||
add('vacuum_room', `${markerId}:${segment}`, roomId,
|
||||
'комнаты сегмента не существует в пространстве маркера');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const value of Array.isArray(marker?.controls) ? marker.controls : []) {
|
||||
if (typeof value !== 'string' || !value.startsWith('marker:')) continue;
|
||||
const target = value.slice('marker:'.length);
|
||||
if (!activeLightMarkerIds.has(target)) {
|
||||
add('marker_control', markerId, target,
|
||||
activeMarkerIds.has(target)
|
||||
? 'маркер-цель не является источником света'
|
||||
: 'активного маркера-цели не существует');
|
||||
}
|
||||
}
|
||||
const badgeSource = marker?.value_badge?.source;
|
||||
if (badgeSource?.kind === 'derived_marker_state') {
|
||||
const ref = String(badgeSource.ref ?? '');
|
||||
const target = ref.startsWith('marker:') ? ref.slice('marker:'.length) : '';
|
||||
if (!target || !activeLightMarkerIds.has(target)) {
|
||||
add('marker_badge', markerId, ref || '?',
|
||||
target && activeMarkerIds.has(target)
|
||||
? 'маркер-источник не является источником света'
|
||||
: 'активного маркера-источника не существует');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +213,28 @@ export function checkReferences({ config, layout = {} } = {}, { notes = [] } = {
|
||||
|
||||
for (const space of spaces) {
|
||||
const spaceId = String(space?.id ?? '?');
|
||||
const roomIds = roomIdsBySpace.get(spaceId) || new Set();
|
||||
for (const room of space?.rooms || []) {
|
||||
const roomId = String(room?.id ?? '?');
|
||||
for (const target of Array.isArray(room?.open_to) ? room.open_to : []) {
|
||||
const targetId = String(target ?? '');
|
||||
if (targetId && !roomIds.has(targetId)) {
|
||||
add('room_open_to', `${spaceId}:${roomId}`, targetId,
|
||||
'комнаты назначения не существует в том же пространстве');
|
||||
}
|
||||
}
|
||||
}
|
||||
const partitionIds = new Set((space?.partitions || [])
|
||||
.map((partition) => String(partition?.id ?? '')).filter(Boolean));
|
||||
for (const opening of space?.openings || []) {
|
||||
const host = opening?.host;
|
||||
if (host?.kind !== 'partition') continue;
|
||||
const target = String(host.id ?? '');
|
||||
if (!target || !partitionIds.has(target)) {
|
||||
add('opening_host', `${spaceId}:${opening?.id ?? '?'}`, target || '?',
|
||||
'перегородки-хоста не существует в том же пространстве');
|
||||
}
|
||||
}
|
||||
const list = carriers(space);
|
||||
const tolerance = EDGE_TOLERANCE;
|
||||
for (const wall of space?.walls || []) {
|
||||
@@ -721,6 +785,12 @@ function report(violations, notes = []) {
|
||||
}
|
||||
const titles = {
|
||||
marker_space: 'Маркеры ссылаются на несуществующие пространства',
|
||||
marker_room: 'Маркеры ссылаются на несуществующие комнаты',
|
||||
vacuum_room: 'Сегменты пылесоса ссылаются на несуществующие комнаты',
|
||||
marker_control: 'Управление светом ссылается на несовместимый маркер',
|
||||
marker_badge: 'Бейдж значения ссылается на несовместимый маркер',
|
||||
room_open_to: 'Связи комнат ссылаются на несуществующие комнаты',
|
||||
opening_host: 'Проёмы ссылаются на несуществующие перегородки',
|
||||
layout_space: 'Позиции ссылаются на несуществующие пространства',
|
||||
layout_owner: 'Позиции без владельца',
|
||||
wall_carrier: 'Записи толщины вне рёбер и перегородок',
|
||||
|
||||
@@ -232,8 +232,10 @@ export const MUTANTS = [
|
||||
+ 'by array order; exact signature repair is safe only when the candidate is unique (#244)',
|
||||
patches: [{
|
||||
file: 'src/space-reference-repair.ts',
|
||||
find: ': (spaceSignatures.get(oldId)?.length === 1 ? spaceSignatures.get(oldId)![0] : null)',
|
||||
replace: ': (spaceSignatures.get(oldId)?.[0] || null)',
|
||||
find: ' const candidates = spaceSignatures.get(root) || [];\n'
|
||||
+ ' return candidates.length === 1 ? candidates[0] : null;',
|
||||
replace: ' const candidates = spaceSignatures.get(root) || [];\n'
|
||||
+ ' return candidates[0] || null;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15856,6 +15856,23 @@ class HouseplanCard extends LitElement {
|
||||
const d = this._backupImportDialog!;
|
||||
const p = d.preview;
|
||||
const counts = p?.counts || {};
|
||||
const report = p?.reference_report || {};
|
||||
const sum = (values: any): number => Object.values(values || {}).reduce(
|
||||
(total: number, value: any) => total + (Number(value) || 0), 0,
|
||||
);
|
||||
const allReportRows: Array<[
|
||||
'incoming_remapped' | 'target_repaired' | 'preserved_unresolved'
|
||||
| 'collisions' | 'dropped_links' | 'bounded_lineages',
|
||||
number,
|
||||
]> = [
|
||||
['incoming_remapped', sum(report.remapped?.incoming)],
|
||||
['target_repaired', sum(report.remapped?.target)],
|
||||
['preserved_unresolved', sum(report.preservedUnresolved)],
|
||||
['collisions', sum(report.collisions)],
|
||||
['dropped_links', sum(report.droppedIncomingLinks)],
|
||||
['bounded_lineages', Number(report.boundedLineages) || 0],
|
||||
];
|
||||
const reportRows = allReportRows.filter(([, value]) => value > 0);
|
||||
return html`<hp-dialog .hass=${this.hass} .title=${this._t('backup.import_title')}
|
||||
icon="mdi:upload" wide dismiss-on-scrim @hp-close=${() => (this._backupImportDialog = null)}>
|
||||
<div class="body backupbody" aria-busy=${d.busy ? 'true' : 'false'}>
|
||||
@@ -15905,6 +15922,22 @@ class HouseplanCard extends LitElement {
|
||||
${p.repaired_target_refs ? html`<div class="rhint">${this._t('backup.repaired_target_refs', {
|
||||
n: String(p.repaired_target_refs),
|
||||
})}</div>` : nothing}
|
||||
${p.preserved_unresolved_refs ? html`
|
||||
<div class="backupwarn">${this._t('backup.preserved_unresolved_refs', {
|
||||
n: String(p.preserved_unresolved_refs),
|
||||
})}<br />${this._t('backup.preserved_unresolved_hint')}</div>
|
||||
` : nothing}
|
||||
${reportRows.length ? html`<details class="backupdetails">
|
||||
<summary>${this._t('backup.import_details')}</summary>
|
||||
<div>
|
||||
${reportRows.map(([key, value]) => html`<span>${this._t(
|
||||
`backup.import_detail.${key}`, { n: String(value) },
|
||||
)}</span>`)}
|
||||
${(report.examples || []).slice(0, 8).map((item: any) => html`
|
||||
<code>${item.owner} → ${item.reference}</code>
|
||||
`)}
|
||||
</div>
|
||||
</details>` : nothing}
|
||||
${p.kind === 'full' ? html`
|
||||
<div class="backupwarn">${this._t('backup.replace_warning')}</div>
|
||||
${p.source === 'foreign' ? html`<div class="rhint">${this._t('backup.foreign_bookkeeping')}</div>` : nothing}` : html`
|
||||
|
||||
@@ -994,6 +994,15 @@
|
||||
"backup.missing_areas": "Areas missing on the target: {areas}",
|
||||
"backup.dropped_marker_links": "Plan-light links outside this transferred space were omitted: {n}.",
|
||||
"backup.repaired_target_refs": "Existing references restored by this space import: {n}.",
|
||||
"backup.preserved_unresolved_refs": "References that could not be restored unambiguously were preserved: {n}.",
|
||||
"backup.preserved_unresolved_hint": "No data was guessed or deleted. After the import, run Optimize plans to inspect the remaining references.",
|
||||
"backup.import_details": "Import reference details",
|
||||
"backup.import_detail.incoming_remapped": "References updated inside the imported copy: {n}",
|
||||
"backup.import_detail.target_repaired": "Existing references restored: {n}",
|
||||
"backup.import_detail.preserved_unresolved": "Unresolved references preserved: {n}",
|
||||
"backup.import_detail.collisions": "Destination conflicts preserved safely: {n}",
|
||||
"backup.import_detail.dropped_links": "Incoming links omitted by transfer rules: {n}",
|
||||
"backup.import_detail.bounded_lineages": "Overly nested identifiers left bounded: {n}",
|
||||
"backup.replace_warning": "This replaces the current configuration and layout. Uploaded files are never deleted. One undo remains available until the next plan edit.",
|
||||
"backup.foreign_bookkeeping": "Instance-specific known/new-device bookkeeping will not be imported.",
|
||||
"backup.final_name": "New space name",
|
||||
|
||||
@@ -994,6 +994,15 @@
|
||||
"backup.missing_areas": "На целевом экземпляре отсутствуют зоны: {areas}",
|
||||
"backup.dropped_marker_links": "Связи с источниками света вне переносимого пространства пропущены: {n}.",
|
||||
"backup.repaired_target_refs": "Существующих ссылок восстановлено этим импортом: {n}.",
|
||||
"backup.preserved_unresolved_refs": "Неоднозначные ссылки сохранены без изменений: {n}.",
|
||||
"backup.preserved_unresolved_hint": "House Plan ничего не угадывал и не удалял. После импорта запустите «Оптимизировать планы», чтобы проверить оставшиеся ссылки.",
|
||||
"backup.import_details": "Подробности восстановления ссылок",
|
||||
"backup.import_detail.incoming_remapped": "Ссылок обновлено внутри импортируемой копии: {n}",
|
||||
"backup.import_detail.target_repaired": "Существующих ссылок восстановлено: {n}",
|
||||
"backup.import_detail.preserved_unresolved": "Неразрешимых ссылок сохранено: {n}",
|
||||
"backup.import_detail.collisions": "Конфликтов назначения безопасно сохранено: {n}",
|
||||
"backup.import_detail.dropped_links": "Входящих связей пропущено по правилам переноса: {n}",
|
||||
"backup.import_detail.bounded_lineages": "Чрезмерно вложенных идентификаторов ограничено: {n}",
|
||||
"backup.replace_warning": "Текущая конфигурация и расположение будут заменены. Загруженные файлы не удаляются. Одну отмену можно выполнить до следующего изменения плана.",
|
||||
"backup.foreign_bookkeeping": "Служебные списки известных и новых устройств другого экземпляра импортированы не будут.",
|
||||
"backup.final_name": "Название нового пространства",
|
||||
|
||||
@@ -65,6 +65,26 @@ const reversibleStem = (prefix: 'space' | 'room', value: string): boolean => (
|
||||
value.length > 0 && value.length <= 35
|
||||
&& (prefix === 'space' ? /^[a-z0-9_-]+$/.test(value) : /^[A-Za-z0-9_-]+$/.test(value))
|
||||
);
|
||||
|
||||
export interface ImportLineageRoot {
|
||||
root: string;
|
||||
layers: number;
|
||||
bounded: boolean;
|
||||
}
|
||||
|
||||
/** Same strict, bounded import-id envelope as the backend import seam. */
|
||||
export function canonicalImportRoot(prefix: string, value: string): ImportLineageRoot {
|
||||
let root = String(value ?? '');
|
||||
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`^${escaped}_(.+)_([0-9a-f]{8})$`);
|
||||
let layers = 0;
|
||||
for (; layers < 16; layers++) {
|
||||
const match = pattern.exec(root);
|
||||
if (!match) return { root, layers, bounded: false };
|
||||
root = match[1];
|
||||
}
|
||||
return { root, layers, bounded: pattern.test(root) };
|
||||
}
|
||||
const addCandidate = (map: Map<string, string[]>, oldId: string, candidate: string): void => {
|
||||
const values = map.get(oldId) || [];
|
||||
values.push(candidate);
|
||||
@@ -94,8 +114,10 @@ export function repairSpaceReferences(
|
||||
const existingSpaceIds = new Set(spaceIds);
|
||||
const spaceSignatures = new Map<string, string[]>();
|
||||
for (const spaceId of spaceIds) {
|
||||
const signature = /^space_(.+)_([0-9a-f]{8})$/.exec(spaceId);
|
||||
if (signature) addCandidate(spaceSignatures, signature[1], spaceId);
|
||||
const signature = canonicalImportRoot('space', spaceId);
|
||||
if (signature.layers > 0 && reversibleStem('space', signature.root)) {
|
||||
addCandidate(spaceSignatures, signature.root, spaceId);
|
||||
}
|
||||
}
|
||||
const roomSignaturesBySpace = new Map<string, Map<string, string[]>>();
|
||||
const roomOwner = new Map<string, string>();
|
||||
@@ -108,8 +130,10 @@ export function repairSpaceReferences(
|
||||
for (const room of Array.isArray(space.rooms) ? space.rooms : []) {
|
||||
const roomId = typeof room?.id === 'string' ? room.id : '';
|
||||
if (!roomId) continue;
|
||||
const signature = /^room_(.+)_([0-9a-f]{8})$/.exec(roomId);
|
||||
if (signature) addCandidate(roomSignatures, signature[1], roomId);
|
||||
const signature = canonicalImportRoot('room', roomId);
|
||||
if (signature.layers > 0 && reversibleStem('room', signature.root)) {
|
||||
addCandidate(roomSignatures, signature.root, roomId);
|
||||
}
|
||||
if (!roomOwner.has(roomId)) roomOwner.set(roomId, spaceId);
|
||||
if (!roomNames.has(roomId)) roomNames.set(roomId, String(room.name || ''));
|
||||
const area = typeof room.area === 'string' ? room.area : '';
|
||||
@@ -152,13 +176,19 @@ export function repairSpaceReferences(
|
||||
const handledLayout = new Set<string>();
|
||||
|
||||
const signatureSpace = (oldId: string): string | null => (
|
||||
existingSpaceIds.has(oldId) || !reversibleStem('space', oldId)
|
||||
existingSpaceIds.has(oldId)
|
||||
? null
|
||||
: (spaceSignatures.get(oldId)?.length === 1 ? spaceSignatures.get(oldId)![0] : null)
|
||||
: (() => {
|
||||
const root = canonicalImportRoot('space', oldId).root;
|
||||
if (!reversibleStem('space', root)) return null;
|
||||
const candidates = spaceSignatures.get(root) || [];
|
||||
return candidates.length === 1 ? candidates[0] : null;
|
||||
})()
|
||||
);
|
||||
const exactRoom = (oldId: string, targetSpace: string): string | null => {
|
||||
if (!reversibleStem('room', oldId)) return null;
|
||||
const candidates = roomSignaturesBySpace.get(targetSpace)?.get(oldId) || [];
|
||||
const root = canonicalImportRoot('room', oldId).root;
|
||||
if (!reversibleStem('room', root)) return null;
|
||||
const candidates = roomSignaturesBySpace.get(targetSpace)?.get(root) || [];
|
||||
return candidates.length === 1 ? candidates[0] : null;
|
||||
};
|
||||
const uniqueAreaRoom = (markerId: string): { spaceId: string; roomId: string } | null => {
|
||||
|
||||
@@ -3329,6 +3329,15 @@ export const cardStyles = css`
|
||||
}
|
||||
.backupwarn { background: color-mix(in srgb, var(--hp-accent) 12%, transparent); }
|
||||
.backuperror { background: rgba(179, 64, 42, .16); color: #ff7a5c; }
|
||||
.backupdetails {
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: var(--rad-s);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: var(--fs-s);
|
||||
}
|
||||
.backupdetails summary { cursor: pointer; font-weight: 700; }
|
||||
.backupdetails > div { display: grid; gap: var(--sp-1); padding-block-start: var(--sp-2); }
|
||||
.backupdetails code { overflow-wrap: anywhere; color: var(--hp-muted); }
|
||||
.backupchoices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"cases": [
|
||||
{ "prefix": "space", "value": "f1", "root": "f1", "layers": 0, "bounded": false },
|
||||
{ "prefix": "space", "value": "space_f1_deadbeef", "root": "f1", "layers": 1, "bounded": false },
|
||||
{ "prefix": "space", "value": "space_space_f1_deadbeef_cafebabe", "root": "f1", "layers": 2, "bounded": false },
|
||||
{ "prefix": "room", "value": "room_room_living_0123abcd_deadbeef", "root": "living", "layers": 2, "bounded": false },
|
||||
{ "prefix": "space", "value": "space_f1_DEADBEEF", "root": "space_f1_DEADBEEF", "layers": 0, "bounded": false },
|
||||
{ "prefix": "space", "value": "room_f1_deadbeef", "root": "room_f1_deadbeef", "layers": 0, "bounded": false },
|
||||
{ "prefix": "space", "value": "space_дом_deadbeef", "root": "дом", "layers": 1, "bounded": false }
|
||||
],
|
||||
"generated": [
|
||||
{ "prefix": "space", "seed": "f1", "wraps": 17, "root": "space_f1_deadbeef", "layers": 16, "bounded": true }
|
||||
]
|
||||
}
|
||||
@@ -61,6 +61,43 @@ test('#244: удалённый маркер не считается наруше
|
||||
assert.deepEqual(checkReferences(m), []);
|
||||
});
|
||||
|
||||
test('#265: полный внутренний ссылочный граф проверяется независимо от импорта', () => {
|
||||
const m = model();
|
||||
m.config.spaces[0].rooms[0].open_to = ['missing-room'];
|
||||
m.config.spaces[0].openings = [{
|
||||
id: 'door', type: 'door', x: 0.2, y: 0.1, angle: 0, length: 0.1,
|
||||
host: { kind: 'partition', id: 'missing-partition', t: 0.5 },
|
||||
}];
|
||||
Object.assign(m.config.markers[0], {
|
||||
room_id: 'missing-room',
|
||||
vacuum: { segment_map: { 12: 'missing-room' } },
|
||||
controls: ['marker:missing-light'],
|
||||
value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'derived_marker_state', ref: 'marker:missing-source' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(checkReferences(m).map((item) => item.kind).sort(), [
|
||||
'marker_badge', 'marker_control', 'marker_room', 'opening_host',
|
||||
'room_open_to', 'vacuum_room',
|
||||
]);
|
||||
});
|
||||
|
||||
test('#265: marker-ссылки принимают только активную цель-источник света', () => {
|
||||
const m = model();
|
||||
m.config.markers.push({ id: 'plain', binding: 'virtual', space: 'sp1' });
|
||||
m.config.markers[0].controls = ['marker:plain'];
|
||||
m.config.markers[0].value_badge = {
|
||||
enabled: true,
|
||||
source: { kind: 'derived_marker_state', ref: 'marker:plain' },
|
||||
};
|
||||
|
||||
assert.deepEqual(checkReferences(m).map((item) => item.kind).sort(), [
|
||||
'marker_badge', 'marker_control',
|
||||
]);
|
||||
});
|
||||
|
||||
test('#252: позиция на удалённое пространство и позиция без владельца', () => {
|
||||
const m = model({ layout: {
|
||||
m1: { s: 'sp1', x: 0.2, y: 0.2 },
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { repairSpaceReferences } from '../test-build/space-reference-repair.js';
|
||||
import {
|
||||
canonicalImportRoot, repairSpaceReferences,
|
||||
} from '../test-build/space-reference-repair.js';
|
||||
|
||||
const lineageFixture = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/import-id-lineage.json', import.meta.url), 'utf8',
|
||||
));
|
||||
|
||||
const space = (id, rooms = []) => ({
|
||||
id, title: id, cell_cm: 5, view_box: [0, 0, 1, 1], rooms,
|
||||
@@ -17,6 +24,42 @@ const emptyOwnerReport = {
|
||||
unverifiedPositions: [],
|
||||
};
|
||||
|
||||
test('issue 265 Python/TypeScript lineage fixture stays strict and bounded', () => {
|
||||
for (const item of lineageFixture.cases) {
|
||||
assert.deepEqual(canonicalImportRoot(item.prefix, item.value), {
|
||||
root: item.root, layers: item.layers, bounded: item.bounded,
|
||||
});
|
||||
}
|
||||
for (const item of lineageFixture.generated) {
|
||||
let value = item.seed;
|
||||
for (let index = 0; index < item.wraps; index++) {
|
||||
value = `${item.prefix}_${value}_deadbeef`;
|
||||
}
|
||||
assert.deepEqual(canonicalImportRoot(item.prefix, value), {
|
||||
root: item.root, layers: item.layers, bounded: item.bounded,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('issue 265 Optimize resolves an import-of-import lineage without guessing', () => {
|
||||
const importedSpace = 'space_space_f1_deadbeef_cafebabe';
|
||||
const importedRoom = 'room_room_living_0123abcd_deadbeef';
|
||||
const result = repairSpaceReferences({
|
||||
spaces: [space(importedSpace, [room(importedRoom)])],
|
||||
markers: [{
|
||||
id: 'vac', binding: 'device:vac', space: 'space_f1_11111111',
|
||||
room_id: 'room_living_22222222',
|
||||
vacuum: { segment_map: { 12: 'room_living_33333333' } },
|
||||
}],
|
||||
settings: {},
|
||||
}, { vac: { s: 'space_f1_11111111', x: 0.2, y: 0.3 } });
|
||||
|
||||
assert.equal(result.config.markers[0].space, importedSpace);
|
||||
assert.equal(result.config.markers[0].room_id, importedRoom);
|
||||
assert.equal(result.config.markers[0].vacuum.segment_map[12], importedRoom);
|
||||
assert.equal(result.layout.vac.s, importedSpace);
|
||||
});
|
||||
|
||||
test('issue 252 detaches a live marker but preserves its stale coordinates until explicit cleanup', () => {
|
||||
const marker = {
|
||||
id: 'washer', binding: 'entity:sensor.washer', space: 'gone', room_id: 'gone_room',
|
||||
|
||||
@@ -20,6 +20,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from custom_components.houseplan.import_export import (
|
||||
ImportFailure,
|
||||
build_space_merge,
|
||||
canonical_import_root,
|
||||
create_export,
|
||||
create_preview,
|
||||
get_candidate,
|
||||
@@ -88,6 +89,25 @@ def _document(tmp_path: Path, kind: str = "full") -> dict:
|
||||
return document
|
||||
|
||||
|
||||
def test_issue_265_python_lineage_matches_shared_fixture() -> None:
|
||||
fixture = json.loads(
|
||||
(Path(__file__).parents[1] / "test" / "fixtures" / "import-id-lineage.json").read_text()
|
||||
)
|
||||
for item in fixture["cases"]:
|
||||
root, layers, bounded = canonical_import_root(item["prefix"], item["value"])
|
||||
assert root == item["root"]
|
||||
assert layers == item["layers"]
|
||||
assert bounded is item["bounded"]
|
||||
for item in fixture["generated"]:
|
||||
value = item["seed"]
|
||||
for _index in range(item["wraps"]):
|
||||
value = f"{item['prefix']}_{value}_deadbeef"
|
||||
root, layers, bounded = canonical_import_root(item["prefix"], value)
|
||||
assert root == item["root"]
|
||||
assert layers == item["layers"]
|
||||
assert bounded is item["bounded"]
|
||||
|
||||
|
||||
def test_import_document_canonicalizes_external_coordinates(tmp_path: Path) -> None:
|
||||
document = _document(tmp_path)
|
||||
room = document["payload"]["config"]["spaces"][0]["rooms"][0]
|
||||
@@ -1111,6 +1131,133 @@ def test_issue_244_space_import_does_not_repair_target_while_source_exists(tmp_p
|
||||
assert details["repaired_target_refs"] == 0
|
||||
|
||||
|
||||
def test_issue_265_import_of_import_flattens_every_owned_namespace(tmp_path: Path) -> None:
|
||||
document = _document(tmp_path, "space")
|
||||
space = document["payload"]["config"]["spaces"][0]
|
||||
space["partitions"] = [{
|
||||
"id": "part", "a": [0, 0.5], "b": [1, 0.5], "cm": 15,
|
||||
}]
|
||||
space["openings"] = [{
|
||||
"id": "door", "type": "door", "x": 0.5, "y": 0.5,
|
||||
"angle": 0, "length": 0.2,
|
||||
"host": {"kind": "partition", "id": "part", "t": 0.5},
|
||||
}]
|
||||
first, _layout, first_details = build_space_merge(
|
||||
document, {"spaces": [], "markers": [], "settings": {}}, {}, "skip",
|
||||
)
|
||||
imported = first["spaces"][0]
|
||||
document["payload"]["config"]["spaces"] = [imported]
|
||||
document["payload"]["config"]["markers"] = []
|
||||
document["payload"]["layout"] = {}
|
||||
document["placement_manifest"] = []
|
||||
second, _layout, second_details = build_space_merge(
|
||||
document, {"spaces": [], "markers": [], "settings": {}}, {}, "skip",
|
||||
)
|
||||
copied = second["spaces"][0]
|
||||
|
||||
assert first_details["space_id"].startswith("space_ground_")
|
||||
assert second_details["space_id"].startswith("space_ground_")
|
||||
assert "space_space_" not in second_details["space_id"]
|
||||
for collection, nested_prefix in (
|
||||
("rooms", "room_room_"),
|
||||
("partitions", "partition_partition_"),
|
||||
("openings", "opening_opening_"),
|
||||
):
|
||||
assert all(nested_prefix not in item["id"] for item in copied[collection])
|
||||
|
||||
|
||||
def test_issue_265_cross_generation_target_repair_fails_closed_when_ambiguous(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
document = _document(tmp_path, "space")
|
||||
current = {
|
||||
"spaces": [{
|
||||
"id": "space_ground_aaaaaaaa", "title": "Existing",
|
||||
"view_box": [0, 0, 1, 1], "rooms": [],
|
||||
}],
|
||||
"markers": [{
|
||||
"id": "orphan", "binding": "virtual", "space": "space_ground_bbbbbbbb",
|
||||
}],
|
||||
"settings": {},
|
||||
}
|
||||
merged, _layout, details = build_space_merge(document, current, {}, "skip")
|
||||
marker = next(item for item in merged["markers"] if item["id"] == "orphan")
|
||||
|
||||
assert marker["space"] == "space_ground_bbbbbbbb"
|
||||
assert details["repaired_target_refs"] == 0
|
||||
assert details["preserved_unresolved_refs"] == 1
|
||||
assert details["reference_report"]["preservedUnresolved"] == {
|
||||
"marker.space": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_issue_265_target_marker_links_require_an_imported_light_target(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
document = _document(tmp_path, "space")
|
||||
# The copied owner is still a marker/layout target, but cannot become a
|
||||
# marker:* light target after the transfer policy removes light semantics.
|
||||
document["payload"]["config"]["markers"][0]["is_light"] = False
|
||||
current = {
|
||||
"spaces": [],
|
||||
"markers": [{
|
||||
"id": "controller", "binding": "virtual", "space": "ground",
|
||||
"controls": ["marker:lamp"],
|
||||
}],
|
||||
"settings": {},
|
||||
}
|
||||
|
||||
merged, _layout, details = build_space_merge(document, current, {}, "skip")
|
||||
controller = next(item for item in merged["markers"] if item["id"] == "controller")
|
||||
|
||||
assert controller["controls"] == ["marker:lamp"]
|
||||
assert details["reference_report"]["preservedUnresolved"] == {
|
||||
"marker.controls": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_issue_265_bounded_lineage_report_counts_unique_ids_once(tmp_path: Path) -> None:
|
||||
document = _document(tmp_path, "space")
|
||||
nested = "ground"
|
||||
for _index in range(17):
|
||||
nested = f"space_{nested}_deadbeef"
|
||||
document["payload"]["config"]["spaces"][0]["id"] = nested
|
||||
document["payload"]["config"]["markers"][0]["space"] = nested
|
||||
document["payload"]["layout"]["lamp"]["s"] = nested
|
||||
|
||||
_merged, _layout, details = build_space_merge(
|
||||
document, {"spaces": [], "markers": [], "settings": {}}, {}, "skip",
|
||||
)
|
||||
|
||||
assert details["reference_report"]["boundedLineages"] == 1
|
||||
|
||||
|
||||
def test_issue_265_apply_uses_the_exact_materialized_preview_candidate(
|
||||
tmp_path: Path, monkeypatch,
|
||||
) -> None:
|
||||
document = _document(tmp_path, "space")
|
||||
runtime = SimpleNamespace(instance_id="instance-a", import_previews={})
|
||||
response = create_preview(
|
||||
runtime, json.dumps(document).encode(), owner_id="alice", duplicate_policy="skip",
|
||||
current_config_data={"config": {"spaces": [], "markers": []}, "rev": 2},
|
||||
current_layout_data={"layout": {}, "rev": 3}, config_root=tmp_path,
|
||||
)
|
||||
candidate = get_candidate(runtime, response["token"], "alice")
|
||||
preview_space = candidate["details"]["space_id"]
|
||||
|
||||
def fail_if_rebuilt(*_args, **_kwargs):
|
||||
raise AssertionError("Apply must not allocate another import id")
|
||||
|
||||
monkeypatch.setattr(import_export_api, "_fresh", fail_if_rebuilt)
|
||||
config, layout, details = prepare_apply(
|
||||
candidate, {"spaces": [], "markers": []}, {}, confirm_missing_content=False,
|
||||
)
|
||||
assert config == candidate["target_config"]
|
||||
assert layout == candidate["target_layout"]
|
||||
assert details == candidate["details"]
|
||||
assert details["space_id"] == preview_space
|
||||
|
||||
|
||||
def test_space_merge_remaps_every_space_owned_id_and_room_link(tmp_path: Path) -> None:
|
||||
document = _document(tmp_path, "space")
|
||||
space = document["payload"]["config"]["spaces"][0]
|
||||
|
||||