From eef7ce129b110be45c63ef212cdb7cacec320aec Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Thu, 3 Sep 2026 10:36:01 +0300 Subject: [PATCH] fix(import): preserve missing decor asset round-trips Issue: #428 User-Visible: yes --- custom_components/houseplan/import_export.py | 39 +++- docs/CHANGELOG.md | 4 + docs/CHANGELOG.ru.md | 5 + docs/CONFIG-COMPATIBILITY.md | 5 + docs/USER-GUIDE.md | 3 + docs/USER-GUIDE.ru.md | 4 + .../428-missing-decor-asset-roundtrip.md | 5 +- tests_backend/test_ha_import_export.py | 184 ++++++++++++++++++ 8 files changed, 238 insertions(+), 11 deletions(-) diff --git a/custom_components/houseplan/import_export.py b/custom_components/houseplan/import_export.py index 10e54fb0..f8e8cbc8 100644 --- a/custom_components/houseplan/import_export.py +++ b/custom_components/houseplan/import_export.py @@ -74,6 +74,13 @@ _IMPORT_ID_NAMESPACES = { } _MAX_IMPORT_LINEAGE_DEPTH = 16 _REPORT_EXAMPLE_LIMIT = 24 +_DECOR_ASSET_MIME_BY_SUFFIX = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".webp": "image/webp", + ".svg": "image/svg+xml", +} +_DECOR_ASSET_MIME_TYPES = frozenset(_DECOR_ASSET_MIME_BY_SUFFIX.values()) _SPACE_PLAN_FIELDS = ( "id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y", @@ -98,6 +105,7 @@ _DECOR_KIND_FIELDS = { "ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"), "text": ("x", "y", "text", "size", "size_cm", "scale", "angle"), "furniture": ("symbol", "x", "y", "w", "h", "angle", "flip_h", "flip_v"), + "image": ("asset_id", "x", "y", "w", "h", "angle", "flip_h", "flip_v"), } @@ -470,10 +478,9 @@ def content_manifest(config: dict[str, Any], config_root: Path) -> list[dict[str # Identity is extension-neutral: a missing target must compute # exactly the same expected manifest as the source. "url": aid, "asset_id": aid, "storage": "internal", - "mime": metadata.get("mime") or { - ".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp", - ".svg": "image/svg+xml", - }.get(blob.suffix if blob else ""), + "mime": metadata.get("mime") or _DECOR_ASSET_MIME_BY_SUFFIX.get( + blob.suffix if blob else "" + ), "hash": aid, "exists_at_export": exists, }) return out @@ -802,7 +809,11 @@ def _validate_plan_only_document( raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical") content = document.get("content_manifest") if not isinstance(content, list) or any( - not isinstance(item, dict) or item.get("owner") != "space" + not isinstance(item, dict) + or not ( + item.get("owner") == "space" + or item.get("owner") == "decor" and item.get("kind") == "decor_asset" + ) for item in content ): raise ImportFailure("invalid_format", "Plan-only export contains private content") @@ -1639,14 +1650,24 @@ def _content_state(document: dict[str, Any], same_source: bool, config_root: Pat for item in expected: row = dict(item) declared = supplied_by_id[identity(item)] - row["exists_at_export"] = declared.get("exists_at_export") + declared_exists = declared.get("exists_at_export") + row["exists_at_export"] = declared_exists if item.get("kind") == "decor_asset": aid = str(item.get("asset_id") or item.get("url") or "") + declared_mime = declared.get("mime") + valid_mime = ( + isinstance(declared_mime, str) + and declared_mime in _DECOR_ASSET_MIME_TYPES + ) + missing_mime = declared_exists is False and declared_mime is None if (declared.get("asset_id") != aid or declared.get("hash") != aid - or declared.get("mime") not in { - "image/png", "image/jpeg", "image/webp", "image/svg+xml", - }): + or type(declared_exists) is not bool + or not (valid_mime or missing_mime)): raise ImportFailure("invalid_content", "Invalid decor asset manifest row") + # Preserve the source description for preview/digest purposes. It + # never selects or serves target bytes; the exact local hash below + # remains authoritative for availability. + row["mime"] = declared_mime candidates = sorted((config_root / ASSETS_DIR).glob(f"{aid}.*")) blob = next((path for path in candidates if path.suffix in {".png", ".jpg", ".webp", ".svg"}), None) exists = False diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 738b4366..57e97883 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Exports made while a reusable decor-image file is missing can now be imported + with the existing confirmation instead of rejecting the whole plan; full, + single-space and plan-only exports preserve the repairable image object + ([#428](https://github.com/Matysh/houseplan-card/issues/428)). - Oversized decor-image sources can now be added through the safe reduced-copy action; House Plan hides only the forbidden original instead of hiding both choices ([#427](https://github.com/Matysh/houseplan-card/issues/427)). diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index f31a249b..e70972a0 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,11 @@ ## Не выпущено +- Экспорт, созданный при отсутствующем файле пользовательской картинки декора, + теперь можно импортировать с обычным подтверждением вместо ошибки всего + плана; полный экспорт, одно пространство и режим «Только планировка» + сохраняют восстанавливаемый объект + ([#428](https://github.com/Matysh/houseplan-card/issues/428)). - Слишком большой исходник изображения декора теперь можно добавить через безопасную уменьшенную копию: House Plan скрывает только запрещённый оригинал, а не оба действия diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index f8d0d974..3b90971c 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -249,6 +249,11 @@ It records content hash and source availability but never embeds file bytes or signed URLs. The importer continues to accept v1. A matching verified local hash is reused; otherwise import requires confirmation and preserves the image record as an editor repair placeholder instead of removing its geometry. +When the source blob and metadata are already absent, the canonical row has +`exists_at_export:false` and may have `mime:null`; that exact missing state is +importable in full, single-space and plan-only documents. Missing MIME is not a +general validation bypass: the availability flag must be a literal boolean, +identity/hash remain exact, and every supplied non-null MIME must be supported. Before a permanent downgrade, remove all image objects with a current card and then explicitly delete their now-unused files from the palette. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index a834b091..1f834823 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -782,6 +782,9 @@ Deleting or replacing a placed image leaves the reusable file in the palette. The palette deletes a file only after all placed copies in all spaces are gone. If a file is missing or fails its integrity check, View hides it; Background shows a crossed placeholder that can be selected and repaired with Replace. +Exports still keep that image object without embedding the absent file. A later +import shows the existing missing-content confirmation and, once confirmed, +keeps the same repairable placeholder instead of rejecting the whole plan. ![Selected line in the Background editor](images/07-background-editor.png) diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index afdaccb8..624c9f98 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1412,6 +1412,10 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со Если файл исчез или не проходит проверку целостности, в просмотре он скрыт, а в редакторе подложки остаётся перечёркнутая рамка. Её можно выбрать и указать другой файл без потери позиции, размера, угла, отражения и порядка слоя. +Экспорт всё равно сохраняет такой объект, не встраивая отсутствующий файл. +При последующем импорте House Plan показывает обычное подтверждение +недостающего содержимого и после согласия оставляет ту же восстанавливаемую +рамку, а не отклоняет весь план. ## 15. Солнце: фон и оконные лучи diff --git a/docs/specs/428-missing-decor-asset-roundtrip.md b/docs/specs/428-missing-decor-asset-roundtrip.md index 9271c0a8..5018539e 100644 --- a/docs/specs/428-missing-decor-asset-roundtrip.md +++ b/docs/specs/428-missing-decor-asset-roundtrip.md @@ -100,7 +100,7 @@ Exporter не восстанавливает MIME эвристикой и не | literal `true` | отсутствует, `null` или неподдерживаемая строка | `ImportFailure("invalid_content")` | | literal `false` | поддерживаемая строка | допустимо | | literal `false` | отсутствует или `null` | допустимо: это исправляемый missing asset | -| literal `false` | неподдерживаемая непустая строка либо значение другого типа | `ImportFailure("invalid_content")` | +| literal `false` | любая неподдерживаемая строка, включая `""`, либо значение другого типа | `ImportFailure("invalid_content")` | | поле отсутствует, `null`, `0`, `1`, строка, объект или массив | любое | `ImportFailure("invalid_content")` | Во всех допустимых строках остаются обязательными: @@ -162,7 +162,8 @@ MIME связано одновременно с exact image identity и literal ## Затронутые файлы и модули - `custom_components/houseplan/import_export.py` — bounded validation - `decor_asset` в `_content_state()`; + `decor_asset` в `_content_state()`, полная image-проекция plan-only и её + ограниченный manifest allowlist; - `tests_backend/test_ha_import_export.py` — положительный round-trip и отрицательная матрица; - `docs/CONFIG-COMPATIBILITY.md` — точное значение missing MIME; diff --git a/tests_backend/test_ha_import_export.py b/tests_backend/test_ha_import_export.py index b7b0d312..1495a161 100644 --- a/tests_backend/test_ha_import_export.py +++ b/tests_backend/test_ha_import_export.py @@ -107,6 +107,190 @@ def _config() -> dict: return commit_wall_segment_model(legacy)[0] +def _config_with_decor_image(asset_id: str) -> tuple[dict, dict]: + config = _config() + shape = { + "id": "custom-picture", "kind": "image", "asset_id": asset_id, + "x": 0.12, "y": 0.23, "w": 0.34, "h": 0.45, + "angle": 12.5, "opacity": 0.6, "flip_h": True, "flip_v": False, + } + config["spaces"][0]["decor"] = [shape] + return config, shape + + +def _missing_decor_export( + source: Path, *, kind: str = "full", plan_only: bool = False, + asset_id: str = "a" * 64, +) -> tuple[dict, dict]: + config, shape = _config_with_decor_image(asset_id) + document, _filename = create_export( + SimpleNamespace(instance_id="source-instance"), + {"config": config, "rev": 2}, + {"layout": {}, "rev": 3}, + kind=kind, + space_id="ground" if kind == "space" else None, + plan_only=plan_only, + card_version="review", + config_root=source, + ) + return document, shape + + +@pytest.mark.parametrize( + ("kind", "plan_only"), + [("full", False), ("space", False), ("space", True)], + ids=["full", "space", "plan-only"], +) +def test_issue_428_missing_decor_asset_round_trips_in_every_export_mode( + tmp_path: Path, kind: str, plan_only: bool, +) -> None: + document, shape = _missing_decor_export( + tmp_path / "source", kind=kind, plan_only=plan_only, + ) + assert document["content_manifest"] == [{ + "kind": "decor_asset", + "owner": "decor", + "owner_id": "ground:custom-picture", + "field": "asset_id", + "url": shape["asset_id"], + "asset_id": shape["asset_id"], + "storage": "internal", + "mime": None, + "hash": shape["asset_id"], + "exists_at_export": False, + }] + + runtime = SimpleNamespace(instance_id="target-instance", import_previews={}) + response = create_preview( + runtime, + json.dumps(document).encode(), + owner_id="alice", + duplicate_policy="skip", + current_config_data={"config": {"spaces": [], "markers": []}, "rev": 0}, + current_layout_data={"layout": {}, "rev": 0}, + config_root=tmp_path / "target", + ) + assert response["preview"]["confirmation_required"] is True + assert response["preview"]["content"][0]["state"] == "missing_preserved" + candidate = get_candidate(runtime, response["token"], "alice") + with pytest.raises(ImportFailure) as unconfirmed: + prepare_apply( + candidate, {"spaces": [], "markers": []}, {}, + confirm_missing_content=False, + ) + assert unconfirmed.value.code == "content_confirmation_required" + + imported, _layout, _details = prepare_apply( + candidate, {"spaces": [], "markers": []}, {}, + confirm_missing_content=True, + ) + imported_images = [ + item + for space in imported["spaces"] + for item in space.get("decor") or [] + if item.get("kind") == "image" + ] + assert imported_images == [shape] + + +@pytest.mark.parametrize( + ("mime", "omit_mime"), + [(None, False), (None, True), ("image/png", False)], + ids=["null", "omitted", "supported"], +) +def test_issue_428_explicitly_missing_asset_accepts_bounded_mime( + tmp_path: Path, mime: str | None, omit_mime: bool, +) -> None: + document, _shape = _missing_decor_export(tmp_path / "source") + if omit_mime: + document["content_manifest"][0].pop("mime") + else: + document["content_manifest"][0]["mime"] = mime + rows, confirmation = import_export_api._content_state( + document, False, tmp_path / "target", + ) + assert rows[0]["mime"] == mime + assert rows[0]["state"] == "missing_preserved" + assert confirmation is True + + +@pytest.mark.parametrize( + ("exists_at_export", "mime", "remove_exists"), + [ + (True, None, False), + (False, "", False), + (False, "text/plain", False), + (False, 0, False), + (False, [], False), + (False, {}, False), + (None, "image/png", True), + (None, "image/png", False), + (0, "image/png", False), + (1, "image/png", False), + ("false", "image/png", False), + ([], "image/png", False), + ({}, "image/png", False), + ], + ids=[ + "present-null-mime", "empty-mime", "unsupported-mime", "numeric-mime", + "list-mime", "object-mime", "missing-flag", "null-flag", "zero-flag", + "one-flag", "string-flag", "list-flag", "object-flag", + ], +) +def test_issue_428_missing_mime_exception_remains_fail_closed( + tmp_path: Path, exists_at_export: Any, mime: Any, remove_exists: bool, +) -> None: + document, _shape = _missing_decor_export(tmp_path / "source") + row = document["content_manifest"][0] + row["exists_at_export"] = exists_at_export + row["mime"] = mime + if remove_exists: + row.pop("exists_at_export") + with pytest.raises(ImportFailure) as invalid: + import_export_api._content_state(document, False, tmp_path / "target") + assert invalid.value.code == "invalid_content" + + +@pytest.mark.parametrize("field", ["asset_id", "hash"]) +def test_issue_428_missing_asset_keeps_hash_identity_strict( + tmp_path: Path, field: str, +) -> None: + document, _shape = _missing_decor_export(tmp_path / "source") + document["content_manifest"][0][field] = "b" * 64 + with pytest.raises(ImportFailure) as invalid: + import_export_api._content_state(document, False, tmp_path / "target") + assert invalid.value.code == "invalid_content" + + +def test_issue_428_missing_source_reuses_only_exact_target_blob(tmp_path: Path) -> None: + data = b"target already has the canonical custom image" + asset_id = hashlib.sha256(data).hexdigest() + document, _shape = _missing_decor_export( + tmp_path / "source", asset_id=asset_id, + ) + target_assets = tmp_path / "target" / "houseplan" / "assets" + target_assets.mkdir(parents=True) + blob = target_assets / f"{asset_id}.png" + blob.write_bytes(data) + + rows, confirmation = import_export_api._content_state( + document, False, tmp_path / "target", + ) + assert rows[0]["mime"] is None + assert rows[0]["exists_at_export"] is False + assert rows[0]["exists_on_target"] is True + assert rows[0]["state"] == "available" + assert confirmation is False + + blob.write_bytes(b"different bytes") + rows, confirmation = import_export_api._content_state( + document, False, tmp_path / "target", + ) + assert rows[0]["exists_on_target"] is False + assert rows[0]["state"] == "missing_preserved" + assert confirmation is True + + def _document(tmp_path: Path, kind: str = "full") -> dict: runtime = SimpleNamespace(instance_id="instance-a") document, _filename = create_export(