refactor: unify import reference seam

Issue: #265
User-Visible: yes
This commit is contained in:
Matysh
2026-08-25 14:47:04 +03:00
parent 9c0b276f43
commit 51810164c2
33 changed files with 1891 additions and 954 deletions
File diff suppressed because one or more lines are too long
+514 -94
View File
@@ -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",
})
+16
View File
@@ -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' }]
+21 -1
View File
@@ -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);
+434 -413
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -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
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Повторный импорт уже импортированного пространства больше не наращивает
служебные префиксы внутренних ID. Предпросмотр и применение теперь используют
один и тот же неизменяемый кандидат, безопасно восстанавливают единственные
доказанные ссылки на прошлые копии и сохраняют неоднозначные ссылки без
догадок или удаления; подробности доступны прямо в диалоге импорта
([#265](https://github.com/Matysh/houseplan-card/issues/265)).
- Инструмент толщины стен теперь подсвечивает реальную стену по её фактической
физической ширине, не раздувая тонкие стены из-за размера клетки плана.
Стены без толщины сохраняют видимую, независимую от масштаба подсветку, а
+6
View File
@@ -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.
+8
View File
@@ -1524,6 +1524,14 @@ show_signal: true
предпросмотр показывает их количество, а применение восстанавливает их
вместе с пространством.
Повторный импорт копии остаётся независимым, но не наращивает служебные
префиксы внутренних ID. Предпросмотр и кнопка «Добавить» работают с одним и тем
же подготовленным вариантом. В блоке **Подробности восстановления ссылок**
видно, сколько ссылок обновлено внутри копии и в существующем плане. Если
подходят несколько возможных целей, House Plan ничего не угадывает: ссылка
сохраняется без изменений, диалог сообщает об этом и рекомендует после импорта
запустить **Оптимизировать планы**.
Для текущего пространства можно включить **Только планировка**. Такой файл
сохраняет комнаты, стены, проёмы, декор, трансформации подложки, позиции и
выбранный масштаб подписей комнат, но удаляет реальные и виртуальные маркеры,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 134 KiB

+21 -21
View File
@@ -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"
}
}
}
+9
View File
@@ -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>` и
+71 -1
View File
@@ -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: 'Записи толщины вне рёбер и перегородок',
+4 -2
View File
@@ -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;',
}],
},
{
+33
View File
@@ -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`
+9
View File
@@ -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",
+9
View File
@@ -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": "Название нового пространства",
+38 -8
View File
@@ -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 => {
+9
View File
@@ -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;
+14
View File
@@ -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 }
]
}
+37
View File
@@ -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 },
+44 -1
View File
@@ -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',
+147
View File
@@ -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]