diff --git a/custom_components/houseplan/__init__.py b/custom_components/houseplan/__init__.py index 7c0578d8..255b2cc5 100755 --- a/custom_components/houseplan/__init__.py +++ b/custom_components/houseplan/__init__.py @@ -13,6 +13,7 @@ from homeassistant.helpers.event import async_track_time_interval from . import websocket_api as hp_ws from .const import ( + ASSETS_DIR, DOMAIN, FILES_DIR, FRONTEND_URL, @@ -39,11 +40,13 @@ async def async_setup(hass: HomeAssistant, config) -> bool: from .frontend_assets import HouseplanFrontendAssetView from .http_api import ( HouseplanContentView, + HouseplanDecorAssetUploadView, HouseplanImportPreviewView, HouseplanUploadView, ) hass.http.register_view(HouseplanUploadView()) + hass.http.register_view(HouseplanDecorAssetUploadView()) hass.http.register_view(HouseplanContentView()) hass.http.register_view(HouseplanImportPreviewView()) hass.http.register_view(HouseplanFrontendAssetView()) @@ -87,8 +90,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> card_path = Path(__file__).parent / "frontend" / "houseplan-card.js" plans_path = Path(hass.config.path(PLANS_DIR)) files_path = Path(hass.config.path(FILES_DIR)) + assets_path = Path(hass.config.path(ASSETS_DIR)) await hass.async_add_executor_job( - lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True)) + lambda: ( + plans_path.mkdir(parents=True, exist_ok=True), + files_path.mkdir(parents=True, exist_ok=True), + assets_path.mkdir(parents=True, exist_ok=True), + ) ) # Static paths cannot be unregistered — register once per HA run. diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index 5b519191..c9ab063f 100755 --- a/custom_components/houseplan/const.py +++ b/custom_components/houseplan/const.py @@ -29,6 +29,9 @@ MAX_PLANS_FILES = 200 MAX_PLANS_LISTED = 60 MAX_FILES_BYTES = 1024 * 1024 * 1024 MAX_FILES_COUNT = 1000 +MAX_DECOR_ASSET_BYTES = 2 * 1024 * 1024 +MAX_DECOR_ASSETS_BYTES = 256 * 1024 * 1024 +MAX_DECOR_ASSETS_COUNT = 200 # Refuse to write when the disk is nearly full: filling the config partition # breaks .storage, the recorder and backups, not just this card. MIN_FREE_BYTES = 512 * 1024 * 1024 @@ -46,8 +49,10 @@ PLAN_ORPHAN_TTL_S = 3600 # action says so, plus staging folders after PLAN_ORPHAN_TTL_S. SCHEDULED_GRACE_S = 30 * 24 * 3600 FILES_DIR = "houseplan/files" +ASSETS_DIR = "houseplan/assets" CONF_ADMIN_ONLY = "admin_only" VERSION = "1.70.0" +DECOR_ASSETS_API_VERSION = 1 # #43: the support transport is deliberately not configurable. A user supplied # URL would turn the integration into an SSRF proxy and make the privacy notice @@ -66,7 +71,7 @@ MAX_SUPPORT_CONTACT_CODEPOINTS = 320 # Assistant Store version above: storage migrations and files exported by a # user have different compatibility lifecycles. PLAN_MODEL_VERSION = 9 -EXPORT_VERSION = 1 +EXPORT_VERSION = 2 MAX_EXPORT_BYTES = 8 * 1024 * 1024 IMPORT_PREVIEW_TTL_S = 10 * 60 MAX_IMPORT_PREVIEWS_PER_USER = 3 @@ -92,7 +97,7 @@ ERROR_CODES: frozenset[str] = frozenset({ "capacity_exceeded", "commit_failed", "conflict", "content_confirmation_required", "duplicate_marker_control", "future_model", "in_use", "invalid_config", "invalid_content", - "invalid_data", "invalid_format", "invalid_json", "invalid_layout", + "invalid_data", "invalid_format", "invalid_image", "invalid_json", "invalid_layout", "invalid_light_entity", "invalid_marker_control", "invalid_name", "invalid_partition_opening_host", "invalid_partition_opening_jamb_margin", "invalid_passage_fields", @@ -107,7 +112,7 @@ ERROR_CODES: frozenset[str] = frozenset({ "support_invalid_message", "support_package_too_large", "support_preview_expired", "support_rate_limited", "support_rejected", "support_unavailable", - "too_large", "unauthorized", "unsupported_export_version", + "too_large", "unauthorized", "unsupported_export_version", "unsupported_image", "value_badge_source_required", "wall_model_client_outdated", "wall_model_migration_blocked", }) diff --git a/custom_components/houseplan/decor_assets.py b/custom_components/houseplan/decor_assets.py new file mode 100644 index 00000000..7c7191e5 --- /dev/null +++ b/custom_components/houseplan/decor_assets.py @@ -0,0 +1,370 @@ +"""Content-addressed custom images for the decorative layer (#51). + +Upload plumbing hands this module untrusted bytes. It either returns one fully +validated canonical asset or rejects the whole file; SVG is never repaired or +partially stripped. +""" +from __future__ import annotations + +import hashlib +import json +import math +import re +import struct +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .const import CONTENT_URL, MAX_DECOR_ASSET_BYTES + +ASSET_ID_RE = re.compile(r"^[0-9a-f]{64}$") +ASSET_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".svg"}) +MIME_BY_EXT = { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".webp": "image/webp", ".svg": "image/svg+xml", +} +EXT_BY_MIME = { + "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", + "image/svg+xml": ".svg", +} +MAX_RASTER_DIMENSION = 16_384 +MAX_RASTER_PIXELS = (128 * 1024 * 1024) // 4 +MAX_SVG_ELEMENTS = 5_000 +MAX_SVG_DEPTH = 64 +MAX_SVG_ATTR_CHARS = 512_000 +_SVG_TAGS = frozenset({ + "svg", "g", "defs", "title", "desc", "path", "rect", "circle", "ellipse", + "line", "polyline", "polygon", "clipPath", "mask", "linearGradient", + "radialGradient", "stop", +}) +_SVG_ATTRS = frozenset({ + "xmlns", "viewBox", "width", "height", "x", "y", "x1", "y1", "x2", "y2", + "cx", "cy", "r", "rx", "ry", "d", "points", "transform", "fill", "fill-rule", + "fill-opacity", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", + "stroke-dasharray", "stroke-dashoffset", "stroke-opacity", "opacity", "offset", + "stop-color", "stop-opacity", "gradientUnits", "gradientTransform", "id", + "clip-path", "mask", "preserveAspectRatio", "href", +}) +_LOCAL_REF = re.compile(r"^url\(#[A-Za-z_][A-Za-z0-9_.:-]*\)$") +_LENGTH = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*(?:px)?\s*$", re.I) +_SVG_NAMESPACE = "http://www.w3.org/2000/svg" + + +class DecorAssetError(ValueError): + """Stable validation failure suitable for an API error response.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True) +class ValidatedAsset: + data: bytes + mime: str + ext: str + width: int + height: int + + @property + def asset_id(self) -> str: + return hashlib.sha256(self.data).hexdigest() + + +def _check_size(data: bytes) -> None: + if not data: + raise DecorAssetError("invalid_image", "The image is empty") + if len(data) > MAX_DECOR_ASSET_BYTES: + raise DecorAssetError("too_large", "The image exceeds the 2 MiB limit") + + +def _raster_dimensions(data: bytes, ext: str) -> tuple[int, int]: + try: + if ext == ".png": + if (len(data) < 45 or data[:8] != b"\x89PNG\r\n\x1a\n" + or data[12:16] != b"IHDR" or b"IDAT" not in data + or data[-8:-4] != b"IEND"): + raise ValueError + return struct.unpack(">II", data[16:24]) + if ext in (".jpg", ".jpeg"): + if len(data) < 4 or data[:2] != b"\xff\xd8": + raise ValueError + pos = 2 + while pos + 4 <= len(data): + if data[pos] != 0xFF: + pos += 1 + continue + marker = data[pos + 1] + pos += 2 + if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7: + continue + length = int.from_bytes(data[pos:pos + 2], "big") + if length < 2 or pos + length > len(data): + raise ValueError + if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}: + if length < 7: + raise ValueError + return ( + int.from_bytes(data[pos + 5:pos + 7], "big"), + int.from_bytes(data[pos + 3:pos + 5], "big"), + ) + pos += length + raise ValueError + if ext == ".webp": + if len(data) < 30 or data[:4] != b"RIFF" or data[8:12] != b"WEBP": + raise ValueError + kind = data[12:16] + if kind == b"VP8X": + return ( + 1 + int.from_bytes(data[24:27], "little"), + 1 + int.from_bytes(data[27:30], "little"), + ) + if kind == b"VP8 ": + idx = data.find(b"\x9d\x01\x2a", 20) + if idx < 0 or idx + 7 > len(data): + raise ValueError + return ( + int.from_bytes(data[idx + 3:idx + 5], "little") & 0x3FFF, + int.from_bytes(data[idx + 5:idx + 7], "little") & 0x3FFF, + ) + if kind == b"VP8L" and data[20] == 0x2F: + bits = int.from_bytes(data[21:25], "little") + return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1 + raise ValueError + except (IndexError, struct.error, ValueError) as err: + raise DecorAssetError( + "invalid_image", "The raster image is corrupt or has the wrong type", + ) from err + raise DecorAssetError("unsupported_image", "Unsupported image type") + + +def _validate_dimensions(width: float, height: float) -> tuple[int, int]: + if not math.isfinite(width) or not math.isfinite(height) or width <= 0 or height <= 0: + raise DecorAssetError("invalid_image", "The image has no usable dimensions") + w, h = int(round(width)), int(round(height)) + if w <= 0 or h <= 0: + raise DecorAssetError("invalid_image", "The image has no usable dimensions") + if w > MAX_RASTER_DIMENSION or h > MAX_RASTER_DIMENSION or w * h > MAX_RASTER_PIXELS: + raise DecorAssetError("too_large", "Decoded image dimensions exceed the safety limit") + return w, h + + +def _svg_number(value: str | None) -> float | None: + if value is None: + return None + match = _LENGTH.fullmatch(value) + return float(match.group(1)) if match else None + + +def _validate_svg(data: bytes) -> ValidatedAsset: + lowered = data.lower() + if any(token in lowered for token in (b"", b"", data, count=1, flags=re.I) + if b"" in without_declaration: + raise DecorAssetError("invalid_image", "XML processing instructions are forbidden") + try: + root = ET.fromstring(data) + except (ET.ParseError, UnicodeError) as err: + raise DecorAssetError("invalid_image", "The SVG is not valid XML") from err + if root.tag.rsplit("}", 1)[-1] != "svg": + raise DecorAssetError("invalid_image", "Only an SVG root is accepted") + count = attr_chars = 0 + ids: set[str] = set() + refs: list[str] = [] + ref_graph: dict[str, set[str]] = {} + stack: list[tuple[ET.Element, int, str | None]] = [(root, 1, None)] + while stack: + node, depth, inherited_owner = stack.pop() + count += 1 + if count > MAX_SVG_ELEMENTS or depth > MAX_SVG_DEPTH: + raise DecorAssetError("too_large", "The SVG structure exceeds the safety limit") + if node.tag.startswith("{"): + namespace, tag = node.tag[1:].split("}", 1) + if namespace != _SVG_NAMESPACE: + raise DecorAssetError("unsupported_image", "Unknown SVG namespace") + else: + raise DecorAssetError("unsupported_image", "The SVG namespace is required") + if tag not in _SVG_TAGS: + raise DecorAssetError("unsupported_image", f"Unsupported SVG element: {tag}") + node_id = node.attrib.get("id") + owner = node_id or inherited_owner + if node.text and node.text.strip(): + if tag not in {"title", "desc"} or len(node.text) > 4096: + raise DecorAssetError("unsupported_image", "Unsupported or oversized SVG text") + for raw_name, value in node.attrib.items(): + if raw_name.startswith("{"): + raise DecorAssetError("unsupported_image", "Namespaced SVG attributes are unsupported") + name = raw_name + attr_chars += len(raw_name) + len(value) + if attr_chars > MAX_SVG_ATTR_CHARS: + raise DecorAssetError("too_large", "The SVG attributes exceed the safety limit") + if name.lower().startswith("on") or name not in _SVG_ATTRS: + raise DecorAssetError("unsupported_image", f"Unsupported SVG attribute: {name}") + low = value.strip().lower() + if any(token in low for token in ("javascript:", "data:", "http:", "https:", "//")): + raise DecorAssetError("invalid_image", "External SVG resources are forbidden") + if "url(" in low: + if not _LOCAL_REF.fullmatch(value.strip()): + raise DecorAssetError("invalid_image", "Only local SVG references are allowed") + ref = value.strip()[5:-1] + refs.append(ref) + if owner: + ref_graph.setdefault(owner, set()).add(ref) + if name == "href": + if not re.fullmatch(r"#[A-Za-z_][A-Za-z0-9_.:-]*", value.strip()): + raise DecorAssetError("invalid_image", "Only local SVG references are allowed") + ref = value.strip()[1:] + refs.append(ref) + if owner: + ref_graph.setdefault(owner, set()).add(ref) + if name == "id": + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.:-]*", value) or value in ids: + raise DecorAssetError("invalid_image", "SVG ids must be unique and well formed") + ids.add(value) + stack.extend((child, depth + 1, owner) for child in node) + if any(ref not in ids for ref in refs): + raise DecorAssetError("invalid_image", "The SVG contains an unresolved local reference") + + visiting: set[str] = set() + visited: set[str] = set() + + def _visit(node_id: str) -> None: + if node_id in visiting: + raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference") + if node_id in visited: + return + visiting.add(node_id) + for ref in ref_graph.get(node_id, ()): + _visit(ref) + visiting.remove(node_id) + visited.add(node_id) + + for node_id in ids: + _visit(node_id) + view_box = root.attrib.get("viewBox") + width = _svg_number(root.attrib.get("width")) + height = _svg_number(root.attrib.get("height")) + if view_box: + try: + parts = [float(part) for part in re.split(r"[\s,]+", view_box.strip())] + if len(parts) != 4: + raise ValueError + width, height = parts[2], parts[3] + except ValueError as err: + raise DecorAssetError("invalid_image", "The SVG viewBox is invalid") from err + w, h = _validate_dimensions(width or 0, height or 0) + ET.register_namespace("", _SVG_NAMESPACE) + canonical = ET.tostring( + root, encoding="utf-8", xml_declaration=False, short_empty_elements=True, + ) + _check_size(canonical) + try: + ET.fromstring(canonical) + except ET.ParseError as err: # pragma: no cover - serializer invariant + raise DecorAssetError("invalid_image", "Canonical SVG could not be reparsed") from err + return ValidatedAsset(canonical, "image/svg+xml", ".svg", w, h) + + +def validate_asset( + data: bytes, filename: str, declared_mime: str | None = None, +) -> ValidatedAsset: + """Validate and canonicalise one complete upload.""" + _check_size(data) + ext = Path(filename).suffix.lower() + if ext not in ASSET_EXTENSIONS: + raise DecorAssetError("unsupported_image", "Use PNG, JPEG, WebP or SVG") + expected_mime = MIME_BY_EXT[ext] + claimed = str(declared_mime or "").split(";", 1)[0].strip().lower() + if claimed and claimed != "application/octet-stream" and claimed != expected_mime: + raise DecorAssetError("invalid_format", "The filename, MIME type and image bytes disagree") + if ext == ".svg": + return _validate_svg(data) + width, height = _validate_dimensions(*_raster_dimensions(data, ext)) + # Home Assistant ships Pillow. Header parsing above keeps this helper + # independently testable, while a full decode here catches valid-looking + # but truncated/corrupt payloads before they enter the authenticated store. + try: + from io import BytesIO + from PIL import Image + + with Image.open(BytesIO(data)) as image: + image.load() + if image.size != (width, height): + raise DecorAssetError("invalid_image", "Image dimensions are inconsistent") + if getattr(image, "is_animated", False): + raise DecorAssetError("unsupported_image", "Animated images are unsupported") + except ImportError: + # The Home Assistant runtime includes Pillow. Keeping the pure parser + # usable without the full HA dependency set lets repository-local + # security tests still exercise signatures, dimensions and SVG. + pass + except DecorAssetError: + raise + except Exception as err: # noqa: BLE001 - decoder failures are one public error + raise DecorAssetError("invalid_image", "The raster image cannot be decoded") from err + mime = expected_mime + return ValidatedAsset(data, mime, EXT_BY_MIME[mime], width, height) + + +def asset_refs(config: dict[str, Any] | None) -> dict[str, list[dict[str, str]]]: + """Return server-authoritative references grouped by asset id.""" + out: dict[str, list[dict[str, str]]] = {} + for space in (config or {}).get("spaces") or []: + if not isinstance(space, dict): + continue + sid = str(space.get("id") or "") + for shape in space.get("decor") or []: + if not isinstance(shape, dict) or shape.get("kind") != "image": + continue + aid = shape.get("asset_id") + if isinstance(aid, str) and ASSET_ID_RE.fullmatch(aid): + out.setdefault(aid, []).append({ + "space_id": sid, "decor_id": str(shape.get("id") or ""), + }) + return out + + +def asset_meta_path(root: Path, asset_id: str) -> Path: + return root / f"{asset_id}.json" + + +def read_catalog(root: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + if not root.is_dir(): + return rows + for path in root.glob("*.json"): + try: + row = json.loads(path.read_text(encoding="utf-8")) + aid = str(row.get("asset_id") or "") + ext = row.get("ext") + blob = root / f"{aid}{ext}" + if ASSET_ID_RE.fullmatch(aid) and ext in ASSET_EXTENSIONS and blob.is_file(): + rows.append(row) + except (OSError, ValueError, TypeError): + continue + return sorted( + rows, + key=lambda row: (str(row.get("created_at", "")), str(row["asset_id"])), + reverse=True, + ) + + +def public_asset( + row: dict[str, Any], used_by: list[dict[str, str]] | None = None, +) -> dict[str, Any]: + aid = str(row["asset_id"]) + ext = str(row["ext"]) + return { + "asset_id": aid, + "name": row.get("name", "image"), + "mime": row.get("mime"), + "width": row.get("width"), + "height": row.get("height"), + "bytes": row.get("bytes"), + "url": f"{CONTENT_URL}/assets/_/{aid}{ext}", + "used_by": used_by or [], + } diff --git a/custom_components/houseplan/frontend/houseplan-assets.json b/custom_components/houseplan/frontend/houseplan-assets.json index 617f4292..ac648cea 100644 --- a/custom_components/houseplan/frontend/houseplan-assets.json +++ b/custom_components/houseplan/frontend/houseplan-assets.json @@ -1,125 +1,125 @@ { "schema": 1, - "fingerprint": "29c37ccd7b7801a57ed9ce80db1570095adaceaaf3609e325ce3f234572e3618", + "fingerprint": "91d90ad5fe8f4f44a45669312cea92fc5c5c061316d8382ea8ec58dbef1603d8", "entry": "houseplan-card.js", "initialViewFiles": [ - "houseplan-assets/houseplan-card-CFGgUmRm.js", + "houseplan-assets/houseplan-card-ruQDQ4Jw.js", "houseplan-card.js" ], - "initialViewGzipBytes": 289137, + "initialViewGzipBytes": 291026, "lazyFiles": [ - "houseplan-assets/backdrop-pick-Bc78IWEU.js", - "houseplan-assets/de-BhR84rNI.js", - "houseplan-assets/editor-BnrcFyu4.js", - "houseplan-assets/fr-Ds_yw0Yi.js", - "houseplan-assets/houseplan-editor-runtime-CPrqRPWs.js", - "houseplan-assets/houseplan-onboarding-runtime-7u8SCNWF.js" + "houseplan-assets/backdrop-pick-CsM7GqVe.js", + "houseplan-assets/de-MNYUkgT1.js", + "houseplan-assets/editor-Ih3Tgb49.js", + "houseplan-assets/fr-BB7LeC_Q.js", + "houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js", + "houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js" ], - "lazyGzipBytes": 209804, + "lazyGzipBytes": 213816, "lazyEditorFiles": [ - "houseplan-assets/backdrop-pick-Bc78IWEU.js", - "houseplan-assets/editor-BnrcFyu4.js", - "houseplan-assets/houseplan-editor-runtime-CPrqRPWs.js" + "houseplan-assets/backdrop-pick-CsM7GqVe.js", + "houseplan-assets/editor-Ih3Tgb49.js", + "houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js" ], - "lazyEditorGzipBytes": 154772, + "lazyEditorGzipBytes": 158012, "lazyOnboardingFiles": [ - "houseplan-assets/backdrop-pick-Bc78IWEU.js", - "houseplan-assets/houseplan-onboarding-runtime-7u8SCNWF.js" + "houseplan-assets/backdrop-pick-CsM7GqVe.js", + "houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js" ], - "lazyOnboardingGzipBytes": 14026, + "lazyOnboardingGzipBytes": 14130, "lazyLocaleFiles": [ - "houseplan-assets/de-BhR84rNI.js", - "houseplan-assets/fr-Ds_yw0Yi.js" + "houseplan-assets/de-MNYUkgT1.js", + "houseplan-assets/fr-BB7LeC_Q.js" ], - "lazyLocaleGzipBytes": 48076, + "lazyLocaleGzipBytes": 48848, "files": [ { - "path": "houseplan-assets/backdrop-pick-Bc78IWEU.js", - "sha256": "5646787b308b3e4754cb1211bdfc33016bd242c02f0f21344405fa8d01e272b6", - "rawBytes": 20636, - "gzipBytes": 7070, + "path": "houseplan-assets/backdrop-pick-CsM7GqVe.js", + "sha256": "136838d9a113801d3d59b5198facdf568a90e9384f62133d84ee8ccfbcf91725", + "rawBytes": 20974, + "gzipBytes": 7174, "isEntry": false, "imports": [ - "houseplan-assets/houseplan-card-CFGgUmRm.js" + "houseplan-assets/houseplan-card-ruQDQ4Jw.js" ], "dynamicImports": [] }, { - "path": "houseplan-assets/de-BhR84rNI.js", - "sha256": "77170508be11e5c7866a43c889134bc83e3d424b4fcd1888c8d26e34aeb26e4f", - "rawBytes": 80331, - "gzipBytes": 24304, + "path": "houseplan-assets/de-MNYUkgT1.js", + "sha256": "8414e28bb23e0cc2e8d777a97b217f8a971d002109ce38168c0890b0bd4a38ff", + "rawBytes": 81798, + "gzipBytes": 24696, "isEntry": false, "imports": [], "dynamicImports": [] }, { - "path": "houseplan-assets/editor-BnrcFyu4.js", - "sha256": "b4f405d49cdfa3a2a24f7164de7c2fbcea6a633e15dc95e8df8a13809eff32d1", + "path": "houseplan-assets/editor-Ih3Tgb49.js", + "sha256": "56f7be4294a42cb92bec171b3ea409425d70fb7ff5a4ca07ffae9190d9ea74d4", "rawBytes": 3826, - "gzipBytes": 1579, + "gzipBytes": 1580, "isEntry": false, "imports": [ - "houseplan-assets/houseplan-card-CFGgUmRm.js" + "houseplan-assets/houseplan-card-ruQDQ4Jw.js" ], "dynamicImports": [] }, { - "path": "houseplan-assets/fr-Ds_yw0Yi.js", - "sha256": "bb52395296f711579024d20374736fec85691ede92f978f2131ab943fdfb0033", - "rawBytes": 82478, - "gzipBytes": 23772, + "path": "houseplan-assets/fr-BB7LeC_Q.js", + "sha256": "63b7104bab6abf083d5478736b8f5fe16ed7ca09edf52fa8cdeda418aa041bf8", + "rawBytes": 83973, + "gzipBytes": 24152, "isEntry": false, "imports": [], "dynamicImports": [] }, { - "path": "houseplan-assets/houseplan-card-CFGgUmRm.js", - "sha256": "fac55ea339112046cff0f1cfa457d3ca337887d20141b763c1b5288317ed6dd1", - "rawBytes": 1026181, - "gzipBytes": 288342, + "path": "houseplan-assets/houseplan-card-ruQDQ4Jw.js", + "sha256": "99538870102416fc6844acba2719f23a26f08d9f12fb2619d2788a83ee7d9a5b", + "rawBytes": 1033593, + "gzipBytes": 290230, "isEntry": false, "imports": [], "dynamicImports": [ - "houseplan-assets/de-BhR84rNI.js", - "houseplan-assets/editor-BnrcFyu4.js", - "houseplan-assets/fr-Ds_yw0Yi.js", - "houseplan-assets/houseplan-editor-runtime-CPrqRPWs.js", - "houseplan-assets/houseplan-onboarding-runtime-7u8SCNWF.js" + "houseplan-assets/de-MNYUkgT1.js", + "houseplan-assets/editor-Ih3Tgb49.js", + "houseplan-assets/fr-BB7LeC_Q.js", + "houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js", + "houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js" ] }, { - "path": "houseplan-assets/houseplan-editor-runtime-CPrqRPWs.js", - "sha256": "e0e7dffd23bad9812f47a3824a7a8185ae285a9e5b060617f73a48b8375d9755", - "rawBytes": 559641, - "gzipBytes": 146123, + "path": "houseplan-assets/houseplan-editor-runtime-ipAn0IA6.js", + "sha256": "924d285e33b3254880331d8f944030705b1ba87f920fe4ba1f50f1d60a98207c", + "rawBytes": 572891, + "gzipBytes": 149258, "isEntry": false, "imports": [ - "houseplan-assets/backdrop-pick-Bc78IWEU.js", - "houseplan-assets/houseplan-card-CFGgUmRm.js" + "houseplan-assets/backdrop-pick-CsM7GqVe.js", + "houseplan-assets/houseplan-card-ruQDQ4Jw.js" ], "dynamicImports": [] }, { - "path": "houseplan-assets/houseplan-onboarding-runtime-7u8SCNWF.js", - "sha256": "fc0ccfc83cb6126ed708d8974fc4429d1291c9f5ee5c12ee2b1a57a40c53ffd5", + "path": "houseplan-assets/houseplan-onboarding-runtime-DB29lThG.js", + "sha256": "c59a3e4ee8c2b799568e3ed82b1f35526bf72b9e75e0f9769d033cb67aa7b716", "rawBytes": 28088, "gzipBytes": 6956, "isEntry": false, "imports": [ - "houseplan-assets/backdrop-pick-Bc78IWEU.js", - "houseplan-assets/houseplan-card-CFGgUmRm.js" + "houseplan-assets/backdrop-pick-CsM7GqVe.js", + "houseplan-assets/houseplan-card-ruQDQ4Jw.js" ], "dynamicImports": [] }, { "path": "houseplan-card.js", - "sha256": "bbee6f47dba338cb78f484c92fc2509736bc3543e12289d819b670d0519467f3", + "sha256": "f6e82e778343cf243de470eeafd5671d9d7746a730d385249fee0ca56d0a3b56", "rawBytes": 1183, - "gzipBytes": 795, + "gzipBytes": 796, "isEntry": true, "imports": [ - "houseplan-assets/houseplan-card-CFGgUmRm.js" + "houseplan-assets/houseplan-card-ruQDQ4Jw.js" ], "dynamicImports": [] } diff --git a/custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-Bc78IWEU.js b/custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-CsM7GqVe.js similarity index 66% rename from custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-Bc78IWEU.js rename to custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-CsM7GqVe.js index 06aa0ca8..d61d7308 100644 --- a/custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-Bc78IWEU.js +++ b/custom_components/houseplan/frontend/houseplan-assets/backdrop-pick-CsM7GqVe.js @@ -1,6 +1,6 @@ -globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="29c37ccd7b7801a57ed9ce80db1570095adaceaaf3609e325ce3f234572e3618";import{d6 as e,c as t,A as i,a as o,b as n,d7 as s}from"./houseplan-card-CFGgUmRm.js";function r(e){const t=e.visualViewport;return t&&Number.isFinite(t.width)&&Number.isFinite(t.height)?{left:Number(t.offsetLeft)||0,top:Number(t.offsetTop)||0,width:Math.max(0,Number(t.width)||0),height:Math.max(0,Number(t.height)||0)}:{left:0,top:0,width:e.innerWidth,height:e.innerHeight}}function a(e,t,i,o=7,n=8){const s=i.left+n,r=i.top+n,a=Math.max(s,i.left+i.width-n),l=Math.max(r,i.top+i.height-n),h=Math.max(0,a-s),c=Math.max(0,l-r),d=Math.min(Math.max(0,t.width),h),u=Math.min(Math.max(0,t.height),c);let p=e.left;p+d>a&&(p=e.right-d),p=Math.min(Math.max(s,p),Math.max(s,a-d));const _=e.bottom+o,g=e.top-o-u;let m="bottom",f=_;return _+u>l&&g>=r?(m="top",f=Math.min(Math.max(r,g),Math.max(r,l-u))):f=Math.min(Math.max(r,_),Math.max(r,l-u)),{left:Math.round(p),top:Math.round(f),side:m,maxWidth:Math.round(h),maxHeight:Math.round(c)}}class l{constructor(e,t,i){this.owner=e,this.name=t,this.keydown=i,this.host=null,this.root=null}window(){return this.owner.ownerDocument.defaultView}dialog(){return this.owner.closest("hp-dialog")}usesPopover(e=!1){const t=this.window();return!e&&!!t&&function(e){const t=e.HTMLElement?.prototype;return"function"==typeof t?.showPopover&&"function"==typeof t?.hidePopover}(t)}get hasFallback(){return!!this.host}containsPath(e){return e.includes(this.owner)||!!this.host&&e.includes(this.host)}ownsActiveElement(){let e=this.owner.ownerDocument.activeElement;for(;e?.shadowRoot?.activeElement;)e=e.shadowRoot.activeElement;return!(!e||e!==this.owner&&!this.owner.shadowRoot?.contains(e)&&!this.root?.contains(e))}renderFallback(i,o){const n=this.ensureFallback();return n?(e(t`${i}`,n),n):null}surface(e,t){return t?this.owner.shadowRoot?.querySelector(e)||null:this.root?.querySelector(e)||null}destroy(){this.root&&e(i,this.root),this.host?.remove(),this.host=null,this.root=null}ensureFallback(){if(this.root?.isConnected)return this.root;const e=this.dialog()?.overlayPortal()||this.owner.ownerDocument.body;if(!e)return null;const t=this.owner.ownerDocument.createElement("div");return t.dataset.hpOverlay=this.name,t.style.display="contents",this.keydown&&t.addEventListener("keydown",this.keydown,!0),e.append(t),this.host=t,this.root=t.attachShadow({mode:"open"}),this.root}}let h=0;class c extends n{constructor(){super(...arguments),this.text="",this.ariaLabel="",this.pointerHover=!1,this._open=!1,this._openTimer=0,this._closeTimer=0,this._positionRaf=0,this._forceFallback=!1,this._hoverOwned=!1,this._overlayDispose=null,this._scrollDialog=null,this._descriptionId="hp-help-description-"+ ++h,this._surfacePointerEnter=()=>{const e=this._window();this._closeTimer&&e?.clearTimeout(this._closeTimer),this._closeTimer=0},this._surfacePointerLeave=()=>{this._scheduleClose()},this._outsidePointerDown=e=>{if(!this._open)return;const t=e.composedPath();this._floating.containsPath(t)||this._closeHelp(!1,"outside")},this._dialogScroll=e=>{const t=this._floating.containsPath(e.composedPath()),i=e.target,o=this._dialog(),n=i instanceof Node&&(i===o||i instanceof Element&&i.contains(this));var s;this._open&&(s=n,!t&&s)&&this._closeHelp(!1,"scroll")},this._keyDown=e=>{if(!this._open||e.defaultPrevented)return;if("Escape"===e.key)return e.preventDefault(),e.stopImmediatePropagation(),void this._closeHelp(this._floating.ownsActiveElement(),"escape");const t=this.renderRoot.querySelector(".trigger");if(!t||!e.composedPath().includes(t))return;const i=this._surface();if(!i||i.scrollHeight<=i.clientHeight)return;const o=Math.max(40,.8*i.clientHeight),n={ArrowDown:40,ArrowUp:-40,PageDown:o,PageUp:-o};if("Home"===e.key)i.scrollTop=0;else if("End"===e.key)i.scrollTop=i.scrollHeight;else{if(!(e.key in n))return;i.scrollBy({top:n[e.key],behavior:"auto"})}e.preventDefault(),e.stopImmediatePropagation()},this._floating=new l(this,"help",this._keyDown),this._queuePosition=()=>{if(!this._open||this._positionRaf)return;const e=this._window();e&&(this._positionRaf=e.requestAnimationFrame(()=>{this._positionRaf=0,this._position()||this._closeHelp()}))}}connectedCallback(){super.connectedCallback(),this.addEventListener("keydown",this._keyDown,!0)}willUpdate(){this.toggleAttribute("data-has-content",this._hasContent())}disconnectedCallback(){const e=this.ownerDocument.defaultView;this._unsubscribeOpenListeners(),this.removeEventListener("keydown",this._keyDown,!0),this._clearTimers(),this._positionRaf&&e?.cancelAnimationFrame(this._positionRaf),this._positionRaf=0,this._closeHelp(!1,"disconnect"),super.disconnectedCallback()}updated(e){if(e.has("pointerHover")&&!this.pointerHover){const e=this._window();this._openTimer&&e?.clearTimeout(this._openTimer),this._openTimer=0,this._hoverOwned&&this._closeHelp(!1,"outside")}this._hasContent()||!this._open?this._open&&e.has("text")&&(this._usesPopover()||this._renderFallback(),this._queuePosition()):this._closeHelp()}_window(){return this._floating.window()}_dialog(){return this._floating.dialog()}_usesPopover(){return this._floating.usesPopover(this._forceFallback)}_hasContent(){return e=this.text,t=this.ariaLabel,"string"==typeof e&&e.trim().length>0&&"string"==typeof t&&t.trim().length>0;var e,t}_clearTimers(){const e=this._window();this._openTimer&&e?.clearTimeout(this._openTimer),this._closeTimer&&e?.clearTimeout(this._closeTimer),this._openTimer=0,this._closeTimer=0}_scheduleOpen(){const e=this._window();this.pointerHover&&e&&!this._open&&!this._openTimer&&(this._closeTimer&&e.clearTimeout(this._closeTimer),this._closeTimer=0,this._openTimer=e.setTimeout(()=>{this._openTimer=0,this.isConnected&&(this._hoverOwned=!0,this._openHelp())},300))}_scheduleClose(){const e=this._window(),t=this.renderRoot.querySelector(".trigger");e&&!t?.matches(":focus-visible")&&(this._openTimer&&e.clearTimeout(this._openTimer),this._closeTimer&&e.clearTimeout(this._closeTimer),this._openTimer=0,this._closeTimer=e.setTimeout(()=>{this._closeTimer=0,this.isConnected&&this._closeHelp()},150))}_triggerPointerEnter(e){this.pointerHover&&"mouse"===e.pointerType&&this._scheduleOpen()}_triggerPointerLeave(e){"mouse"===e.pointerType&&this._scheduleClose()}_triggerFocus(){queueMicrotask(()=>{const e=this.renderRoot.querySelector(".trigger");e?.matches(":focus-visible")&&(this._hoverOwned=!1,this._openHelp())})}_triggerBlur(){this._scheduleClose()}_triggerClick(){this._open?this._closeHelp():(this._hoverOwned=!1,this._openHelp())}_subscribeOpenListeners(){this.ownerDocument.addEventListener("pointerdown",this._outsidePointerDown,!0),this.ownerDocument.addEventListener("keydown",this._keyDown,!0);const e=this._window();e?.addEventListener("resize",this._queuePosition),e?.addEventListener("orientationchange",this._queuePosition),e?.visualViewport?.addEventListener("resize",this._queuePosition),e?.visualViewport?.addEventListener("scroll",this._queuePosition),this._scrollDialog=this._dialog(),this._scrollDialog?.addEventListener("scroll",this._dialogScroll,!0)}_unsubscribeOpenListeners(){this.ownerDocument.removeEventListener("pointerdown",this._outsidePointerDown,!0),this.ownerDocument.removeEventListener("keydown",this._keyDown,!0);const e=this._window();e?.removeEventListener("resize",this._queuePosition),e?.removeEventListener("orientationchange",this._queuePosition),e?.visualViewport?.removeEventListener("resize",this._queuePosition),e?.visualViewport?.removeEventListener("scroll",this._queuePosition),this._scrollDialog?.removeEventListener("scroll",this._dialogScroll,!0),this._scrollDialog=null}async _openHelp(){!this._open&&this._hasContent()&&(this._clearTimers(),this._forceFallback=!1,this._open=!0,this._subscribeOpenListeners(),await this.updateComplete,this._open&&(this._usesPopover()||this._renderFallback(),this._position()?this._overlayDispose=this._dialog()?.registerOverlay({owner:this,group:"transient",close:e=>this._closeHelp("escape"===e&&this._floating.ownsActiveElement(),e)})||null:this._closeHelp()))}_closeHelp(e=!1,t="exclusive"){if(!this._open&&!this._floating.hasFallback)return;this._clearTimers();const i=this._overlayDispose;this._overlayDispose=null,i?.();const o=this.renderRoot.querySelector(".tooltip");if(o?.hidePopover)try{o.matches(":popover-open")&&o.hidePopover()}catch{}this._open=!1,this._hoverOwned=!1,this._unsubscribeOpenListeners(),this._floating.destroy(),e&&this.updateComplete.then(()=>this.renderRoot.querySelector(".trigger")?.focus())}_tooltipTemplate(e){return t`