mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Прямые и винтовые лестницы получили отдельную модель, инструменты редактора, безопасную межэтажную навигацию, вычитание из чистой площади и плоское отображение в 2.5D. Issue: #663 User-Visible: yes
534 lines
21 KiB
Python
534 lines
21 KiB
Python
"""Strict, allowlisted House Plan support-package projection (#43).
|
|
|
|
This module deliberately has no Home Assistant imports. Besides making the
|
|
privacy boundary easy to test, that prevents an innocent serializer helper from
|
|
gaining access to registries, states, URLs or paths that the package must never
|
|
contain. The caller supplies one coherent config/layout copy and bounded,
|
|
already-classified runtime facts; this code constructs a new object field by
|
|
field. It never serializes the source and then tries to redact it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import secrets
|
|
from collections import Counter
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from .const import (
|
|
EXPORT_VERSION,
|
|
MAX_SUPPORT_ATTACHMENT_BYTES,
|
|
PLAN_MODEL_VERSION,
|
|
)
|
|
|
|
PACKAGE_FORMAT = "houseplan-support-package"
|
|
PACKAGE_VERSION = 1
|
|
_SAFE_VERSION = re.compile(r"\A[0-9A-Za-z._+-]{1,32}\Z")
|
|
_SAFE_ICON = re.compile(r"\Amdi:[a-z0-9-]{1,80}\Z")
|
|
_LANGUAGES = frozenset({"en", "ru", "de", "fr"})
|
|
_BROWSERS = frozenset({"chromium", "firefox", "webkit", "unknown"})
|
|
_REGISTRY_ACCESS = frozenset({"full", "partial", "unavailable"})
|
|
_REGISTRY_AGE = frozenset({"fresh", "stale", "unknown"})
|
|
_VALUE_BADGE_POSITIONS = frozenset({"right", "bottom", "left", "top"})
|
|
|
|
|
|
class SupportPackageError(ValueError):
|
|
"""A stable public failure without source data in its text."""
|
|
|
|
def __init__(self, code: str) -> None:
|
|
super().__init__(code)
|
|
self.code = code
|
|
|
|
|
|
def _safe_version(value: object) -> str:
|
|
text = str(value or "unknown")
|
|
return text if _SAFE_VERSION.fullmatch(text) else "unknown"
|
|
|
|
|
|
def validate_frontend_facts(value: object) -> dict[str, Any]:
|
|
"""Accept only the small runtime enum/boolean contract from the browser."""
|
|
if not isinstance(value, dict):
|
|
raise SupportPackageError("support_rejected")
|
|
browser = value.get("browser_family")
|
|
language = value.get("language")
|
|
registry = value.get("registry_access")
|
|
age = value.get("registry_age_bucket")
|
|
major = value.get("browser_major")
|
|
if browser not in _BROWSERS or language not in _LANGUAGES:
|
|
raise SupportPackageError("support_rejected")
|
|
if registry not in _REGISTRY_ACCESS or age not in _REGISTRY_AGE:
|
|
raise SupportPackageError("support_rejected")
|
|
if isinstance(major, bool) or not isinstance(major, int) or not 0 <= major <= 999:
|
|
raise SupportPackageError("support_rejected")
|
|
for key in ("coarse_pointer", "hover_capable"):
|
|
if not isinstance(value.get(key), bool):
|
|
raise SupportPackageError("support_rejected")
|
|
return {
|
|
"browser_family": browser,
|
|
"browser_major": major,
|
|
"language": language,
|
|
"coarse_pointer": value["coarse_pointer"],
|
|
"hover_capable": value["hover_capable"],
|
|
"registry_access": registry,
|
|
"registry_age_bucket": age,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class _Pseudonyms:
|
|
namespace: str
|
|
maps: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
|
|
def get(self, kind: str, raw: object) -> str | None:
|
|
if raw is None:
|
|
return None
|
|
value = str(raw)
|
|
if not value:
|
|
return None
|
|
items = self.maps.setdefault(kind, {})
|
|
if value not in items:
|
|
items[value] = f"{kind}-{self.namespace}-{len(items) + 1}"
|
|
return items[value]
|
|
|
|
|
|
def _copy_keys(source: object, keys: tuple[str, ...]) -> dict[str, Any]:
|
|
if not isinstance(source, dict):
|
|
return {}
|
|
return {key: source[key] for key in keys if key in source}
|
|
|
|
|
|
def _point(value: object) -> list[float] | None:
|
|
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
|
return None
|
|
if any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in value):
|
|
return None
|
|
return [value[0], value[1]]
|
|
|
|
|
|
def _points(value: object) -> list[list[float]]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
return [point for item in value if (point := _point(item)) is not None]
|
|
|
|
|
|
def _entity_ref(ids: _Pseudonyms, value: object) -> str | None:
|
|
return ids.get("entity", value)
|
|
|
|
|
|
def _binding(ids: _Pseudonyms, value: object) -> tuple[str, str]:
|
|
text = str(value or "")
|
|
if text == "virtual":
|
|
return "virtual", "virtual"
|
|
kind, separator, raw = text.partition(":")
|
|
if separator and kind in {"device", "entity"} and raw:
|
|
# Reuse the same per-kind namespace as controls/value sources so one
|
|
# raw entity remains one pseudonym everywhere in this package.
|
|
pseudo = ids.get(kind, raw)
|
|
return kind, f"{kind}:{pseudo}"
|
|
return "unknown", "unknown"
|
|
|
|
|
|
def _custom_fill(value: object) -> dict[str, Any] | None:
|
|
if not isinstance(value, dict):
|
|
return None
|
|
return _copy_keys(value, ("c", "a"))
|
|
|
|
|
|
# The eleven palette slots the card reads (src/logic.ts DEFAULT_FILL_COLORS).
|
|
# The config schema stays open on purpose so older or extended configs keep
|
|
# loading; a package must not carry a key the product never defined (#498).
|
|
SUPPORT_FILL_COLOR_KEYS = (
|
|
"light_on", "light_off", "light_none", "temp_cold", "temp_ok", "temp_hot",
|
|
"lqi_low", "lqi_high", "glow_base", "glow_light", "wall_fill",
|
|
)
|
|
|
|
|
|
def _global_settings(value: object) -> dict[str, Any]:
|
|
out = _copy_keys(value, ("glow_radius_cm", "bg_color", "north_deg", "bg_mode", "sun_rays"))
|
|
if not isinstance(value, dict):
|
|
return out
|
|
sun_ray_origin = value.get("sun_ray_origin")
|
|
if sun_ray_origin in ("inner", "outer"):
|
|
out["sun_ray_origin"] = sun_ray_origin
|
|
show_room_tooltip = value.get("show_room_tooltip")
|
|
if isinstance(show_room_tooltip, bool):
|
|
out["show_room_tooltip"] = show_room_tooltip
|
|
volumetric_view = value.get("volumetric_view")
|
|
if isinstance(volumetric_view, bool):
|
|
out["volumetric_view"] = volumetric_view
|
|
fill_colors = value.get("fill_colors")
|
|
if isinstance(fill_colors, dict):
|
|
palette = {
|
|
key: _copy_keys(fill_colors[key], ("c", "a"))
|
|
for key in SUPPORT_FILL_COLOR_KEYS
|
|
if isinstance(fill_colors.get(key), dict)
|
|
}
|
|
if palette:
|
|
out["fill_colors"] = palette
|
|
style = value.get("decor_default_style")
|
|
if isinstance(style, dict):
|
|
out["decor_default_style"] = _copy_keys(
|
|
style, ("color", "opacity", "width_cm", "fill", "fill_color", "fill_opacity"),
|
|
)
|
|
return out
|
|
|
|
|
|
def _space_settings(value: object) -> dict[str, Any]:
|
|
out = _copy_keys(value, (
|
|
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
|
|
"fill_mode", "glow_enabled", "temp_min", "temp_max", "show_lqi",
|
|
"hide_decor", "hide_openings", "label_temp", "label_hum", "label_lqi",
|
|
"label_light", "card_font_scale", "north_deg", "bg_mode", "sun_rays",
|
|
))
|
|
if isinstance(value, dict) and "custom_fill" in value:
|
|
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
|
|
return out
|
|
|
|
|
|
def _room_settings(ids: _Pseudonyms, value: object) -> dict[str, Any]:
|
|
out = _copy_keys(value, ("fill_mode", "glow", "name_scale", "label_scale"))
|
|
if not isinstance(value, dict):
|
|
return out
|
|
if "custom_fill" in value:
|
|
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
|
|
for key in ("temp_source", "hum_source"):
|
|
source = value.get(key)
|
|
if source:
|
|
kind, pseudo = _binding(ids, source)
|
|
out[key] = pseudo
|
|
out[f"{key}_kind"] = kind
|
|
return out
|
|
|
|
|
|
def _project_room(ids: _Pseudonyms, room: dict[str, Any], index: int) -> dict[str, Any]:
|
|
out: dict[str, Any] = {
|
|
"id": ids.get("room", room.get("id")),
|
|
"name": f"Room {index}",
|
|
}
|
|
out.update(_copy_keys(room, ("x", "y", "w", "h")))
|
|
if "poly" in room:
|
|
out["poly"] = _points(room.get("poly"))
|
|
if isinstance(room.get("wall_ids"), list):
|
|
out["wall_ids"] = [ids.get("wall", item) for item in room["wall_ids"]]
|
|
if isinstance(room.get("open_to"), list):
|
|
out["open_to"] = [ids.get("room", item) for item in room["open_to"]]
|
|
settings = _room_settings(ids, room.get("settings"))
|
|
if settings:
|
|
out["settings"] = settings
|
|
return out
|
|
|
|
|
|
def _project_decor(ids: _Pseudonyms, item: dict[str, Any]) -> dict[str, Any]:
|
|
kind = str(item.get("kind") or "unknown")
|
|
out: dict[str, Any] = {"id": ids.get("decor", item.get("id")), "kind": kind}
|
|
out.update(_copy_keys(item, (
|
|
"color", "opacity", "width_cm", "width", "x1", "y1", "x2", "y2",
|
|
"line_style", "x", "y", "w", "h", "angle", "fill", "fill_color",
|
|
"fill_opacity", "size", "size_cm", "scale", "symbol", "flip_h", "flip_v",
|
|
)))
|
|
if kind == "text":
|
|
out["text"] = "[redacted text]"
|
|
if item.get("entity"):
|
|
out["entity"] = _entity_ref(ids, item.get("entity"))
|
|
return out
|
|
|
|
|
|
def _project_value_source(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
|
|
if not isinstance(value, dict):
|
|
return None
|
|
kind = value.get("kind")
|
|
if kind in {"entity_state", "entity_attribute"}:
|
|
out: dict[str, Any] = {"kind": kind, "entity_id": _entity_ref(ids, value.get("entity_id"))}
|
|
# Attribute names can be arbitrary user strings and are not needed to
|
|
# reproduce geometry or marker placement, so they are deliberately absent.
|
|
return out
|
|
if kind == "derived_lqi":
|
|
return {"kind": kind}
|
|
if kind == "derived_marker_state":
|
|
raw = str(value.get("ref") or "")
|
|
marker = raw[7:] if raw.startswith("marker:") else raw
|
|
return {"kind": kind, "ref": f"marker:{ids.get('marker', marker)}"}
|
|
return None
|
|
|
|
|
|
def _project_value_badge(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
|
|
"""Project the compatibility-tolerant badge config into safe scalar fields."""
|
|
if not isinstance(value, dict):
|
|
return None
|
|
out: dict[str, Any] = {}
|
|
enabled = value.get("enabled")
|
|
if isinstance(enabled, bool):
|
|
out["enabled"] = enabled
|
|
position = value.get("position")
|
|
if isinstance(position, str) and position in _VALUE_BADGE_POSITIONS:
|
|
out["position"] = position
|
|
source = _project_value_source(ids, value.get("source"))
|
|
if source is not None:
|
|
out["source"] = source
|
|
return out or None
|
|
|
|
|
|
def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
|
binding_kind, binding = _binding(ids, marker.get("binding"))
|
|
out: dict[str, Any] = {
|
|
"id": ids.get("marker", marker.get("id")),
|
|
"binding": binding,
|
|
"binding_kind": binding_kind,
|
|
}
|
|
out.update(_copy_keys(marker, (
|
|
"hidden", "removed", "tap_action", "tap_confirm", "display", "ripple_color",
|
|
"ripple_size", "size", "angle", "glow_radius_cm", "glow_color", "is_light",
|
|
"use_climate_temp",
|
|
)))
|
|
icon = marker.get("icon")
|
|
if isinstance(icon, str) and _SAFE_ICON.fullmatch(icon):
|
|
out["icon"] = icon
|
|
if marker.get("space"):
|
|
out["space"] = ids.get("space", marker.get("space"))
|
|
if marker.get("room_id"):
|
|
out["room_id"] = ids.get("room", marker.get("room_id"))
|
|
for key in ("light_entity", "toggle_entity", "tap_target"):
|
|
if marker.get(key):
|
|
out[key] = _entity_ref(ids, marker.get(key))
|
|
controls = marker.get("controls")
|
|
if isinstance(controls, list):
|
|
out["controls"] = [_entity_ref(ids, value) for value in controls]
|
|
vacuum = marker.get("vacuum")
|
|
if isinstance(vacuum, dict):
|
|
out["vacuum"] = _copy_keys(vacuum, ("live", "trail", "trail_mode", "room_highlight"))
|
|
for key in ("value_source",):
|
|
projected = _project_value_source(ids, marker.get(key))
|
|
if projected:
|
|
out[key] = projected
|
|
safe_badge = _project_value_badge(ids, marker.get("value_badge"))
|
|
if safe_badge is not None:
|
|
out["value_badge"] = safe_badge
|
|
return out
|
|
|
|
|
|
def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[str, Any]:
|
|
out: dict[str, Any] = {
|
|
"id": ids.get("space", space.get("id")),
|
|
"title": f"Space {index}",
|
|
"has_plan": bool(space.get("plan_url")),
|
|
}
|
|
out.update(_copy_keys(space, (
|
|
"cell_cm", "plan_aspect", "plan_x", "plan_y", "plan_scale", "plan_scale_x",
|
|
"plan_scale_y", "plan_angle", "view_box", "zero_wall_style",
|
|
)))
|
|
settings = _space_settings(space.get("settings"))
|
|
if settings:
|
|
out["settings"] = settings
|
|
|
|
out["rooms"] = [
|
|
_project_room(ids, room, room_index)
|
|
for room_index, room in enumerate(space.get("rooms") or [], 1)
|
|
if isinstance(room, dict)
|
|
]
|
|
out["wall_segments"] = [
|
|
{
|
|
"id": ids.get("wall", item.get("id")),
|
|
"a": _point(item.get("a")), "b": _point(item.get("b")), "cm": item.get("cm"),
|
|
}
|
|
for item in space.get("wall_segments") or [] if isinstance(item, dict)
|
|
]
|
|
out["walls"] = [
|
|
{
|
|
"key": ids.get("wall", item.get("key")), "cm": item.get("cm"),
|
|
**({"a": _point(item.get("a")), "b": _point(item.get("b"))}
|
|
if "a" in item and "b" in item else {}),
|
|
}
|
|
for item in space.get("walls") or [] if isinstance(item, dict)
|
|
]
|
|
out["partitions"] = [
|
|
{"id": ids.get("partition", item.get("id")), "a": _point(item.get("a")),
|
|
"b": _point(item.get("b")), "cm": item.get("cm")}
|
|
for item in space.get("partitions") or [] if isinstance(item, dict)
|
|
]
|
|
out["wall_columns"] = [
|
|
{"id": ids.get("column", item.get("id")), **_copy_keys(item, ("shape", "center", "cm", "angle"))}
|
|
for item in space.get("wall_columns") or [] if isinstance(item, dict)
|
|
]
|
|
out["openings"] = []
|
|
for opening in space.get("openings") or []:
|
|
if not isinstance(opening, dict):
|
|
continue
|
|
projected = {"id": ids.get("opening", opening.get("id"))}
|
|
projected.update(_copy_keys(opening, (
|
|
"type", "x", "y", "angle", "length", "invert", "flip_h", "flip_v",
|
|
)))
|
|
for key in ("contact", "lock"):
|
|
if opening.get(key):
|
|
projected[key] = _entity_ref(ids, opening.get(key))
|
|
host = opening.get("host")
|
|
if isinstance(host, dict) and host.get("kind") in {"wall", "partition"}:
|
|
kind = host["kind"]
|
|
projected["host"] = {
|
|
"kind": kind,
|
|
"id": ids.get(kind, host.get("id")),
|
|
"t": host.get("t"),
|
|
}
|
|
out["openings"].append(projected)
|
|
out["decor"] = [
|
|
_project_decor(ids, item) for item in space.get("decor") or [] if isinstance(item, dict)
|
|
]
|
|
out["stairs"] = []
|
|
for item in space.get("stairs") or []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
projected = {"id": ids.get("stair", item.get("id"))}
|
|
projected.update(_copy_keys(item, (
|
|
"kind", "x", "y", "angle", "direction", "length", "width", "radius",
|
|
)))
|
|
if item.get("target_space_id"):
|
|
projected["target_space_id"] = ids.get("space", item.get("target_space_id"))
|
|
out["stairs"].append(projected)
|
|
out["open_spans"] = [
|
|
{"a": _point(item.get("a")), "b": _point(item.get("b"))}
|
|
for item in space.get("open_spans") or [] if isinstance(item, dict)
|
|
]
|
|
return out
|
|
|
|
|
|
def _project_layout(ids: _Pseudonyms, layout: object) -> dict[str, Any]:
|
|
if not isinstance(layout, dict):
|
|
return {}
|
|
marker_ids = ids.maps.get("marker", {})
|
|
room_ids = ids.maps.get("room", {})
|
|
out: dict[str, Any] = {}
|
|
for raw_key, value in layout.items():
|
|
if not isinstance(raw_key, str) or not isinstance(value, dict):
|
|
continue
|
|
if raw_key in marker_ids:
|
|
key = marker_ids[raw_key]
|
|
elif raw_key.startswith("rl_") and raw_key[3:] in room_ids:
|
|
key = "rl_" + room_ids[raw_key[3:]]
|
|
else:
|
|
# Unknown keys may be stale device ids. Omitting them is the only
|
|
# fail-closed choice; copying them would disclose the raw id.
|
|
continue
|
|
position = _copy_keys(value, ("x", "y", "k"))
|
|
if value.get("s"):
|
|
position["s"] = ids.get("space", value.get("s"))
|
|
out[key] = position
|
|
return out
|
|
|
|
|
|
def _summary(config: object, layout: object) -> dict[str, Any]:
|
|
spaces = config.get("spaces", []) if isinstance(config, dict) else []
|
|
markers = config.get("markers", []) if isinstance(config, dict) else []
|
|
kinds: Counter[str] = Counter()
|
|
bindings: Counter[str] = Counter()
|
|
lifecycles: Counter[str] = Counter()
|
|
decor_kinds: Counter[str] = Counter()
|
|
for marker in markers:
|
|
if not isinstance(marker, dict):
|
|
continue
|
|
bindings[_binding_kind(marker.get("binding"))] += 1
|
|
lifecycles[
|
|
"removed" if marker.get("removed") else "hidden" if marker.get("hidden") else "active"
|
|
] += 1
|
|
for space in spaces:
|
|
if not isinstance(space, dict):
|
|
continue
|
|
for opening in space.get("openings") or []:
|
|
if isinstance(opening, dict):
|
|
kinds[str(opening.get("type") or "unknown")] += 1
|
|
for item in space.get("decor") or []:
|
|
if isinstance(item, dict):
|
|
decor_kinds[str(item.get("kind") or "unknown")] += 1
|
|
return {
|
|
"spaces": len(spaces),
|
|
"rooms": sum(len(space.get("rooms") or []) for space in spaces if isinstance(space, dict)),
|
|
"walls": sum(len(space.get("wall_segments") or space.get("walls") or []) for space in spaces if isinstance(space, dict)),
|
|
"partitions": sum(len(space.get("partitions") or []) for space in spaces if isinstance(space, dict)),
|
|
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
|
|
"openings": dict(sorted(kinds.items())),
|
|
"decor": dict(sorted(decor_kinds.items())),
|
|
"stairs": sum(len(space.get("stairs") or []) for space in spaces if isinstance(space, dict)),
|
|
"markers": {
|
|
"total": len(markers),
|
|
"lifecycle": dict(sorted(lifecycles.items())),
|
|
"binding": dict(sorted(bindings.items())),
|
|
},
|
|
"layout_entries": len(layout) if isinstance(layout, dict) else 0,
|
|
}
|
|
|
|
|
|
def _binding_kind(value: object) -> str:
|
|
text = str(value or "")
|
|
if text == "virtual":
|
|
return "virtual"
|
|
if text.startswith("device:"):
|
|
return "device"
|
|
if text.startswith("entity:"):
|
|
return "entity"
|
|
return "unknown"
|
|
|
|
|
|
def build_support_package(
|
|
config: dict[str, Any],
|
|
layout: dict[str, Any],
|
|
*,
|
|
config_rev: int,
|
|
layout_rev: int,
|
|
card_version: str,
|
|
integration_version: str,
|
|
home_assistant_version: str,
|
|
runtime: dict[str, Any],
|
|
repairs: list[dict[str, Any]] | None = None,
|
|
namespace: str | None = None,
|
|
) -> tuple[bytes, dict[str, Any]]:
|
|
"""Build canonical bytes and a bounded summary for the preview response."""
|
|
facts = validate_frontend_facts(runtime)
|
|
ids = _Pseudonyms(namespace or secrets.token_hex(4))
|
|
spaces = [item for item in config.get("spaces", []) if isinstance(item, dict)]
|
|
markers = [item for item in config.get("markers", []) if isinstance(item, dict)]
|
|
projected_spaces = [
|
|
_project_space(ids, space, index) for index, space in enumerate(spaces, 1)
|
|
]
|
|
projected_markers = [_project_marker(ids, marker) for marker in markers]
|
|
summary = _summary(config, layout)
|
|
package = {
|
|
"format": PACKAGE_FORMAT,
|
|
"version": PACKAGE_VERSION,
|
|
"versions": {
|
|
"card": _safe_version(card_version),
|
|
"integration": _safe_version(integration_version),
|
|
"home_assistant": _safe_version(home_assistant_version),
|
|
"model": PLAN_MODEL_VERSION,
|
|
"export_schema": EXPORT_VERSION,
|
|
},
|
|
"runtime": facts,
|
|
"revisions": {"config": int(config_rev), "layout": int(layout_rev)},
|
|
"summary": summary,
|
|
"validation": {
|
|
"config": "valid", "layout": "valid", "unknown_fields": "dropped",
|
|
},
|
|
"repairs": repairs or [],
|
|
"plan_backup": {
|
|
"config": {
|
|
"model_version": int(config.get("model_version", PLAN_MODEL_VERSION)),
|
|
"settings": _global_settings(config.get("settings")),
|
|
"spaces": projected_spaces,
|
|
"markers": projected_markers,
|
|
},
|
|
"layout": _project_layout(ids, layout),
|
|
},
|
|
}
|
|
raw = (json.dumps(
|
|
package, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
|
|
) + "\n").encode("utf-8")
|
|
if len(raw) > MAX_SUPPORT_ATTACHMENT_BYTES:
|
|
raise SupportPackageError("support_package_too_large")
|
|
return raw, {
|
|
"size": len(raw),
|
|
"sha256": hashlib.sha256(raw).hexdigest(),
|
|
"spaces": summary["spaces"],
|
|
"format": PACKAGE_FORMAT,
|
|
"version": PACKAGE_VERSION,
|
|
"versions": package["versions"],
|
|
}
|