mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
753 lines
58 KiB
Markdown
753 lines
58 KiB
Markdown
# ТЗ #50 — экспорт и импорт конфигурации House Plan
|
||
|
||
- Issue: https://github.com/Matysh/houseplan-card/issues/50
|
||
- Приоритет: **P1**, feature
|
||
- Статус ТЗ: **реализовано локально** (ревизия 3; проверки запланированы на
|
||
следующий пре-релиз по правилу владельца)
|
||
- Связано: #33 (жизненный цикл схемы конфига), #51 (custom decor images), #85
|
||
(тесты должны уметь падать)
|
||
- Security scope: только пользователь, которому разрешена запись политикой
|
||
`may_write` (`custom_components/houseplan/auth.py:16`); файл намеренно
|
||
не анонимизируется
|
||
|
||
---
|
||
|
||
## 0. Что изменено ревью 2026-08-11
|
||
|
||
Ревизия 1 писалась «по памяти о модели». Ревью сверило каждое утверждение с
|
||
кодом v1.61.0; ниже — только то, что не сошлось, и принятое решение.
|
||
|
||
| # | Находка | Решение |
|
||
|---|---|---|
|
||
| R1 | **`PLAN_MODEL_VERSION` на бэкенде не существует.** ТЗ обещало «обязательный parity-test frontend/backend» и отказ при `model_version > PLAN_MODEL_VERSION`, но константа есть только во фронте (`src/plan-optimizer.ts:27`), в `custom_components/**` её нет ни одной. Сверять нечего | Ввести `PLAN_MODEL_VERSION = 6` в `const.py` и parity-тест — это входит в объём задачи (§5). Плюс: оптимизатор пишет поле только при `modelFrom < PLAN_MODEL_VERSION && meaningfulChanged` (`plan-optimizer.ts:382-384`), поэтому у большинства реальных конфигов поля **нет** — отсутствие трактуется как `0`, а не как ошибка |
|
||
| R2 | **Полный импорт по обычному пути записи стёр бы картинки планов.** `ws_config_set` после коммита зовёт `collect_plans`/`collect_attachments` (`websocket_api.py:817-819`), правило которых — «файл, на который ссылалась СТАРАЯ конфигурация и не ссылается новая, удалить» (`plans.py:273-299`). Полная замена конфига — ровно этот переход для всех планов сразу | Apply **не вызывает** collect-функции (§7). Это то, что делает one-deep Undo настоящим. Суточный sweep безопасен: он передаёт один и тот же конфиг с обеих сторон (`__init__.py:225-235`), а непривязанные планы по второму правилу `collect_plans` сохраняются |
|
||
| R3 | **Импорт с недостающим планом был бы отклонён существующим инвариантом.** `_missing_internal_plans` (`websocket_api.py:714-732`) блокирует запись кодом `missing_plan` для каждого нового внутреннего plan-url, которого нет на диске | Отсоединение файлов — не вежливость UX, а обязательное условие записи. Preview выполняет **ту же самую** функцию и показывает её результат до применения (§5.3, §6.2) |
|
||
| R4 | **Три разных способа сохранять layout-стор, служебные ключи теряются.** `layout/set|update|delete` пишут по чёрному списку (`websocket_api.py:195, 259, 657`), `optimize` — через `layout_meta` (`:906`), а `geometry/repair` — по **белому** списку `{"layout","rev"[,"repair_backup"]}` (`:314`, `:346`) и молча стирает `optimize_backup`/`optimize_pending`/`geom_pending`. Новый intent импорта умер бы там же | Нормативно: единственный helper записи layout-стора, все существующие вызовы переводятся на него в рамках этой задачи (§7). Существующая потеря `optimize_backup` в `geometry/repair` выделена в отдельный баг #87 |
|
||
| R5 | **Два независимых Undo в одном UI.** One-deep снапшот уже есть: `optimize_backup`/`optimize_pending` + флаг `can_optimize_undo` (`websocket_api.py:40-58`, `houseplan-card.ts:539`, кнопка `:12332`). ТЗ добавляло второй флаг `can_import_undo` и, значит, вторую кнопку на ту же ячейку | Один слот, одна кнопка. К снапшоту добавляется поле `kind: "optimize" \| "import"`, ответы `config/get`/`layout/get` отдают `can_optimize_undo` + новый `undo_kind`; подпись кнопки зависит от kind (§6.3). Имена ключей стора не меняются — миграция не нужна, существующий recovery продолжает работать |
|
||
| R6 | **`known_devices`/`new_device_ids` — bookkeeping инстанса, а не модель.** После импорта из чужого HA `diffNewDevices` объявит новыми все устройства цели (`houseplan-card.ts:3407-3412`) | При `source_fingerprint != target` эти два поля не импортируются и это показано в preview; при совпадении отпечатков импортируются буквально (§8) |
|
||
| R7 | **Layout-записи без `s` (legacy render-units).** `houseplan-card.ts:3514-3518`: отсутствие `s` = старые координаты, не привязанные к пространству | Space export их не видит по определению (правило `pos.s == space.id`), full export переносит буквально. Preview показывает отдельной строкой «позиции без пространства: N» (§5.2) |
|
||
| R8 | **Список ссылок для remap был неполон** — названы только `open_to`, `room_id`, room-label ключи и `pos.s` | §9.1 получает полную таблицу полей модели: `marker.controls[]`, `marker.tap_target`, `marker.vacuum.segment_map`, `room.settings.temp_source\|hum_source`, `opening.contact\|lock`, decor `entity`, layout-ключи `lg_<entity_id>` и `v_*` — с явным разделением «переименовать» / «сохранить буквально» |
|
||
| R9 | **Префикс `import.*` в i18n уже занят** мастером «создать пространства из этажей HA» (6 ключей: `import.title/hint/start/manual/progress/done`) | Все новые ключи — `backup.*`, секция настроек — `gs.backup_group`/`gs.backup_hint` (§4.1) |
|
||
| R10 | **Новая секция в «Общих настройках» валит смок с зашитым эталоном** — `demo/smoke_general_settings.mjs:53-56` фиксирует `rows: 15` и массив из девяти групп | Обновление эталона входит в объём задачи; оно же — естественный мутант по #85: если секция не отрендерилась, смок обязан покраснеть (§12) |
|
||
| R11 | **Golden для нового диалога требует ветки в harness.** `demo/golden/harness.mjs` умеет открывать ровно два диалога (`scenario.dialog === 'device'` и `'decor-color'`), плюс `GOLDEN_MATRIX_VERSION` (сейчас 10) надо поднять | §12 явно включает работу по harness и bump версии матрицы |
|
||
| R12 | **Треки пылесосов ключуются `marker_id` из конфига** и живут в отдельном сторе `houseplan.trails` без rev (`trails.py:104, 135-141`); сборщика сирот нет | После полного импорта recorder не только refresh-ится, но и удаляет прогоны, чьих маркеров больше нет (§7). Для Undo это безопасно: треки не входят в snapshot и в §3 записаны как принятая потеря |
|
||
| R13 | **`source_fingerprint` требует HA `instance_id`,** которого проект ещё не касался (`homeassistant.helpers.instance_id` — 0 вхождений в бэкенде) | Явная новая зависимость: получить в `async_setup_entry`, положить в runtime data, солить домен-сепаратором (§5) |
|
||
| R14 | **`MAX_EXPORT_BYTES` не существует, а реальные лимиты конкретны:** `MAX_CONFIG_BYTES = 2 MiB` (`validation.py:114`), `MAX_LAYOUT = 5000`, `MAX_SPACES = 50`, `MAX_MARKERS = 2000` (`validation.py:79-108`); у HTTP-вьюх `client_max_size` не задан | `MAX_EXPORT_BYTES = 8 * 1024 * 1024`, проверяется потоково до разбора JSON, ровно как в `HouseplanUploadView` (`http_api.py:185-189`) (§6.2, §9.3) |
|
||
| R15 | **`owner_path` в content manifest был JSON-pointer с индексами** (`/payload/config/spaces/0/plan_url`) — ломается от любой пересортировки | Ссылки по id: `{"owner":"space","id":…,"field":"plan_url"}` и `{"owner":"marker","id":…,"url":…}` (§5.3) |
|
||
| R16 | **Конфликт при apply был бы массовым из-за собственной вкладки.** Позиции пишутся debounce-ом 600 мс через `layout/update`, у которого нет `expected_rev` (`websocket_api.py:210-262`), — любое перетаскивание в фоне двигает layout rev, и пользователю пришлось бы заново выбирать файл | Добавлен `houseplan/import/revalidate {token}`: сервер уже держит проверенный candidate, пересчитывает preview и выдаёт свежие `expected_*_rev`, не требуя повторной загрузки файла (§6.2) |
|
||
| R18 | **`controls` перестанет быть целиком «буквальным» полем** — ревью ТЗ #84 (2026-08-11) вводит внутренние ссылки `marker:<id>` внутри того же массива. Скопированные буквально, они после remap id указывали бы в пустоту | §9.1 делит `controls` по префиксу значения: entity-ссылки буквально, `marker:`-ссылки ремапятся; ссылка за пределы импортируемого пространства удаляется и показывается в preview. Внести до коммита реализации, иначе понадобится миграция |
|
||
| R17 | **Единственная ws-команда без `may_write` — `houseplan/content/sign`** (`websocket_api.py:530`), и это сделано намеренно: read-only зритель обязан видеть фон плана, а подпись выдаётся только на собственный namespace и привязана к refresh token вызывающего | Не образец для новых endpoint-ов: export, preview, revalidate, apply и undo проверяют `_runtime` + `may_write` без исключений. Отдельный баг не заводится |
|
||
|
||
### 0.1 Правки после code review локальной реализации
|
||
|
||
Ревизия 3 уточняет не продуктовый UX, а проверяемые инварианты реализации:
|
||
|
||
- envelope сохраняет **фактический** `model_version` хранимого плана, включая
|
||
`0` и future-значение; в `payload.config` это поле не дублируется и экспорт
|
||
никогда не штампует текущую версию поверх данных;
|
||
- apply повторно проверяет внутренние plan-файлы под тем же `write_lock`, что и
|
||
revisions; исчезновение файла между preview и apply возвращает стабильный
|
||
`missing_plan`;
|
||
- при сбое paired commit handler один раз докатывает target, затем заменяет
|
||
intent на rollback и восстанавливает before-pair. После ответа об ошибке
|
||
restart не имеет права самостоятельно закончить импорт;
|
||
- token проверяет digest нормализованного candidate при каждом обращении;
|
||
одновременно в памяти хранится не более трёх parsed preview **суммарно**, а
|
||
не по три на каждого writer;
|
||
- HA-набор переименован в `test_ha_import_export.py` и расширен endpoint-
|
||
проверками detach, remap, metadata, write failure, recovery и порядка events.
|
||
|
||
---
|
||
|
||
## 1. Цель
|
||
|
||
Дать владельцу переносимую резервную копию **модели House Plan** и безопасный
|
||
способ перенести одно пространство между собственными Home Assistant.
|
||
|
||
В v1 есть две операции:
|
||
|
||
1. **Полная конфигурация** — экспорт `config + live layout`; импорт полностью
|
||
заменяет текущую модель после подробного предпросмотра.
|
||
2. **Одно пространство** — экспорт текущего пространства с принадлежащими ему
|
||
объектами и позициями; импорт всегда добавляет новое пространство и ничего
|
||
существующего не заменяет.
|
||
|
||
Это резервная копия модели, а не архив всего медиаконтента. Локальные планы и
|
||
вложения перечисляются в manifest, но их bytes в JSON не входят.
|
||
|
||
## 2. Зафиксированные решения
|
||
|
||
Ревизия 1 сняла следующие неоднозначности:
|
||
|
||
- `config` и `layout` экспортируются одним согласованным снимком под общим
|
||
`write_lock`, а не двумя независимыми frontend-запросами;
|
||
- вместо невозможной буквальной транзакции двух HA Store используется
|
||
crash-resumable paired commit с intent до первой записи;
|
||
- полный импорт получает одно безопасное Undo до следующего изменения плана;
|
||
- импортируемый файл разбирается строго на backend, до попадания недоверенного
|
||
объекта во frontend runtime;
|
||
- локальный URL с другой HA instance никогда не связывается с одноимённым
|
||
случайным файлом на target;
|
||
- правила владения layout-записями, remap внутренних id, повторного импорта и
|
||
дубликатов HA binding определены явно;
|
||
- future model не понижается молча; неизвестные поля поддерживаемой версии
|
||
сохраняются благодаря существующему `extra=vol.ALLOW_EXTRA`
|
||
(`validation.py:709`);
|
||
- определён конечный UX, разрешения, error codes, recovery и release gate.
|
||
|
||
Ревизия 2 добавила к ним: переиспользование существующего слота Undo вместо
|
||
второго (R5), запрет collect-функций на пути импорта (R2), единый helper записи
|
||
layout-стора (R4), полную таблицу remap-ссылок (R8) и `revalidate` вместо
|
||
повторного выбора файла (R16).
|
||
|
||
## 3. Не входит в v1
|
||
|
||
- ZIP с локальными plan/PDF/image bytes;
|
||
- облачная синхронизация или расписание резервных копий;
|
||
- анонимизация entity/device ids, имён, ссылок или координат;
|
||
- экспорт vacuum trails (`houseplan.trails`), runtime caches, подписанных URL,
|
||
undo/command stacks;
|
||
- merge полного backup в существующую модель;
|
||
- импорт пространства поверх существующего пространства;
|
||
- автоматический подбор «похожей» сущности на другой HA instance;
|
||
- перенос dashboard/card YAML и настроек самого Home Assistant.
|
||
|
||
Portable ZIP с assets является отдельным следующим этапом. Будущие custom
|
||
decor images из #51 подключаются к тому же content manifest, но не расширяют
|
||
scope этой версии.
|
||
|
||
## 4. Пользовательский интерфейс
|
||
|
||
### 4.1 Точка входа
|
||
|
||
В **«Общие настройки»** (`houseplan-card.ts:12236`) перед разделом
|
||
«Обслуживание планов» (`gs.grid_group`, `:12325`) появляется раздел
|
||
**«Резервная копия и перенос»** — `gs.backup_group` + `gs.backup_hint` — с двумя
|
||
кнопками в одном `div.colorrow.gsrow`, по образцу кнопки «Оптимизировать планы»:
|
||
|
||
- **«Экспортировать…»** (`mdi:download`, `backup.export_open`);
|
||
- **«Импортировать…»** (`mdi:upload`, `backup.import_open`).
|
||
|
||
Отдельные четыре кнопки для full/space не добавляются: вид импорта определяется
|
||
из файла автоматически. В настройках конкретного пространства в v1 нет
|
||
дублирующей кнопки.
|
||
|
||
Все новые i18n-ключи живут под префиксом `backup.*` — `import.*` занят мастером
|
||
этажей (R9). Оба словаря пополняются в одном коммите: `test/i18n.test.mjs:9`
|
||
падает при расхождении.
|
||
|
||
Раздел виден только при `_canEdit` (`houseplan-card.ts:652`, зеркало серверного
|
||
`can_write`). Backend независимо повторяет `may_write` для export, preview,
|
||
revalidate, apply и undo.
|
||
|
||
### 4.2 Экспорт
|
||
|
||
Диалог **«Экспорт House Plan»** содержит radio:
|
||
|
||
- **«Вся конфигурация»** — выбран по умолчанию;
|
||
- **«Текущее пространство: <title>»** — выключено, если пространства нет.
|
||
|
||
Под вариантами явно написано:
|
||
|
||
- файл содержит реальные HA ids, имена, ссылки и координаты (в отличие от
|
||
HA-диагностики, которая часть полей вырезает — `diagnostics.py:12`);
|
||
- локальные изображения и вложения перечислены, но не вложены в JSON;
|
||
- trails и текущее состояние устройств не экспортируются.
|
||
|
||
Кнопка **«Скачать JSON»** запрашивает свежий backend snapshot и только после
|
||
ответа создаёт `Blob`, временный object URL и download. URL обязательно
|
||
revoke-ится. Экспорт ничего не записывает и не создаёт undo point.
|
||
|
||
Имена файлов:
|
||
|
||
- `houseplan-full-YYYY-MM-DD_HH-mm-ss.json`;
|
||
- `houseplan-space-<safe-title>-YYYY-MM-DD_HH-mm-ss.json`.
|
||
|
||
### 4.3 Импорт и предпросмотр
|
||
|
||
Кнопка «Импортировать…» использует скрытый `input[type=file]` с
|
||
`accept="application/json,.json"`. Выбор файла **не изменяет данные**. Файл
|
||
отправляется в backend preview endpoint, после чего открывается диалог с:
|
||
|
||
- именем и размером файла;
|
||
- видом экспорта, временем, версиями card/integration/model;
|
||
- признаком «эта HA instance» / «другая HA instance»;
|
||
- количеством пространств, комнат, стен, проёмов, декора, markers и layout;
|
||
- отдельной строкой «позиции без пространства (legacy): N» (R7);
|
||
- active / disabled / missing / virtual HA bindings;
|
||
- локальными assets: доступны, отсутствуют или непереносимы;
|
||
- всеми блокирующими ошибками и предупреждениями.
|
||
|
||
Неизвестный формат, неподдерживаемая будущая версия, невалидная schema или
|
||
превышение лимита дают read-only error dialog без кнопки применения.
|
||
|
||
### 4.4 Полный импорт
|
||
|
||
Preview показывает `current → incoming` минимум для spaces, rooms, markers и
|
||
layout и отдельный danger block:
|
||
|
||
> Текущая конфигурация и позиции будут полностью заменены. Локальные файлы на
|
||
> диске не удаляются. Отменить импорт можно только до следующего изменения.
|
||
|
||
Если локальные refs надо отсоединить, появляется обязательный checkbox
|
||
**«Импортировать без N локальных файлов»** со списком затронутых объектов.
|
||
Без него Apply недоступен — и это не вежливость, а условие записи (R3).
|
||
|
||
Footer построен на существующем контракте `dialog-action-footer` /
|
||
`dialog-action-danger` / `dialog-action-commit` (`styles.ts:2754-2776`):
|
||
«Отмена» и последней в DOM danger-кнопка **«Заменить конфигурацию»**. Initial
|
||
focus всегда на «Отмена»; Enter сам по себе не подтверждает замену.
|
||
Escape/close/scrim = cancel и ноль записей.
|
||
|
||
После успеха:
|
||
|
||
- закрываются открытые редакторские диалоги и жесты;
|
||
- очищаются локальные состояния: `_geometryHistory.clear()` (как в
|
||
`_runAlignToGrid`, `houseplan-card.ts:11597`), `_dirtyPos`/`_sentPos`,
|
||
`_defPos`, кэш `ContentSigner`, `_renderDeviceSnapshot`;
|
||
- выбирается прежнее пространство, если его id осталось, иначе первое;
|
||
- показывается toast с итоговыми counts;
|
||
- в том же разделе настроек появляется **«Отменить последний импорт»** —
|
||
та же кнопка, что и у оптимизации, с текстом по `undo_kind` (R5).
|
||
|
||
Undo доступно только пока не было ни одного последующего config/layout write:
|
||
любой обычный `config/set` вызывает `_discard_optimizer_snapshot`
|
||
(`websocket_api.py:804`). Новый импорт или Optimize заменяет предыдущий
|
||
одноразовый plan-wide снимок.
|
||
|
||
### 4.5 Импорт пространства
|
||
|
||
Preview показывает итоговое название (`Гараж`, `Гараж (2)`, …), counts,
|
||
bindings и предупреждение, что глобальные цвета, icon rules, Glow defaults и
|
||
прочие общие настройки берутся с target instance.
|
||
|
||
Для binding, уже размещённого/настроенного на target, доступна одна общая
|
||
политика:
|
||
|
||
1. **«Пропустить повторяющиеся устройства»** — безопасное значение по
|
||
умолчанию;
|
||
2. **«Добавить как статичные виртуальные обозначения»** — сохраняет позицию,
|
||
имя, явную иконку, размер и угол, но удаляет управление, vacuum, controls,
|
||
light/glow role и HA actions. Такой marker не влияет на данные плана.
|
||
|
||
Изменение политики пересчитывает preview до применения (через `revalidate`, без
|
||
повторного выбора файла). Footer: **«Отмена»**, **«Добавить пространство»**.
|
||
Это не destructive replacement, поэтому danger style не используется.
|
||
|
||
После успеха новое пространство выбирается автоматически. Оно может быть
|
||
удалено обычным существующим действием; отдельное Undo импорта пространства в
|
||
v1 не добавляется.
|
||
|
||
### 4.6 Responsive и accessibility
|
||
|
||
Оба сценария используют `hp-dialog`: focus trap, initial/restore focus, Escape
|
||
через существующий реестр поверхностей (`hp-dialog.ts:345`), scrollable body и
|
||
общий responsive footer. На узком экране нет горизонтального scroll; длинные
|
||
title/file names переносятся или сокращаются с полным `title`. Статус
|
||
чтения/preview/apply объявляется через `aria-live`.
|
||
|
||
Импорт/экспорт — безопасная dialog-based maintenance operation, поэтому она
|
||
должна работать на desktop и touch. Точные редакторские операции по-прежнему
|
||
остаются desktop-first согласно `docs/TOUCH-SUPPORT.md`.
|
||
|
||
## 5. Формат export v1
|
||
|
||
Один UTF-8 JSON без BOM:
|
||
|
||
```json
|
||
{
|
||
"format": "houseplan-export",
|
||
"export_version": 1,
|
||
"kind": "full|space",
|
||
"created_at": "2026-08-11T12:34:56Z",
|
||
"source_fingerprint": "sha256:…",
|
||
"card_version": "1.61.0",
|
||
"integration_version": "1.61.0",
|
||
"model_version": 6,
|
||
"payload": {
|
||
"config": {},
|
||
"layout": {}
|
||
},
|
||
"placement_manifest": [],
|
||
"content_manifest": []
|
||
}
|
||
```
|
||
|
||
`source_fingerprint` — SHA-256 от `houseplan-export-v1|` + HA `instance_id`
|
||
(`homeassistant.helpers.instance_id.async_get`, новая зависимость — R13);
|
||
исходный instance id в файл не пишется. Значение нужно только для безопасного
|
||
отличия собственной storage от чужой и не используется как секрет.
|
||
|
||
`model_version` повторяет фактически сохранённую версию модели; **отсутствие
|
||
поля в конфиге означает `0`** и ошибкой не является (R1). Поле живёт только в
|
||
envelope и удаляется из копии `payload.config`: это исключает два источника
|
||
истины и молчаливое понижение future-модели при экспорте. Изменения envelope
|
||
требуют нового `export_version`. Импорт с `export_version > 1` или
|
||
`model_version > PLAN_MODEL_VERSION` отклоняется без downgrade.
|
||
|
||
В объём задачи входит:
|
||
|
||
- завести `PLAN_MODEL_VERSION = 6` в `custom_components/houseplan/const.py`;
|
||
- parity-тест: значение в `const.py` равно `PLAN_MODEL_VERSION` из
|
||
`src/plan-optimizer.ts:27` (сканер исходника, как это уже сделано для
|
||
`MAX_SIGN_PATHS` — `logic.ts:1829`).
|
||
|
||
### 5.1 Full payload
|
||
|
||
- `payload.config` — полный текущий config после backend schema normalization,
|
||
кроме envelope-поля `model_version`;
|
||
- `payload.layout` — результат `_live_layout(config, stored_layout)`
|
||
(`websocket_api.py:114`), без позиций удалённых маркеров и сиротских `v_*`;
|
||
функция выносится из ws-модуля в общий helper и **переиспользуется**, а не
|
||
копируется;
|
||
- `placement_manifest` может быть пустым: full import не remap-ит ids.
|
||
|
||
Служебные ключи layout-стора (`rev`, `geom_pending`, `optimize_pending`,
|
||
`optimize_backup`, `repair_backup`) в экспорт не попадают.
|
||
|
||
### 5.2 Space payload
|
||
|
||
`payload.config` содержит только:
|
||
|
||
```json
|
||
{ "spaces": [/* ровно одно */], "markers": [/* только владельцы space */] }
|
||
```
|
||
|
||
Глобальный `settings` не входит и при импорте не меняется. Пространство входит
|
||
целиком: rooms, walls, drafts, partitions, columns, open spans, openings,
|
||
decor, backdrop transforms (`plan_x/plan_y/plan_scale*/plan_angle`) и
|
||
собственные `settings`.
|
||
|
||
В space export входят live layout records с `pos.s == space.id` (включая поле
|
||
`k` — сохранённый масштаб карточки комнаты, 0.5..3, `houseplan-card.ts:14065`)
|
||
и marker без `removed`, если выполняется хотя бы одно условие:
|
||
|
||
- его id владеет одной из этих layout positions;
|
||
- `marker.space == space.id`;
|
||
- `marker.room_id` указывает на room экспортируемого пространства.
|
||
|
||
Записи layout **без поля `s`** — legacy-координаты в render-units
|
||
(`houseplan-card.ts:3514-3518`) — не принадлежат пространству, в space export не
|
||
входят и в full export переносятся буквально; их количество показано в preview
|
||
(R7).
|
||
|
||
Global removed tombstones, `known_devices`, `new_device_ids` и прочие global
|
||
settings в space export не попадают.
|
||
|
||
`placement_manifest` классифицирует каждую layout key:
|
||
|
||
```json
|
||
{
|
||
"layout_id": "…",
|
||
"owner": "marker|room_label|light_group|auto_device",
|
||
"binding": "device:…|entity:…|virtual|null",
|
||
"owner_id": "…",
|
||
"label": "…",
|
||
"icon": "…"
|
||
}
|
||
```
|
||
|
||
Это позволяет не потерять layout-only auto-discovered device (ключ = HA
|
||
`device_id`, `devices.ts:688`) и группу света (`lg_<entity_id>`,
|
||
`devices.ts:712`): на другой instance они превращаются в явный orphan marker, а
|
||
не исчезают из импорта.
|
||
|
||
### 5.3 Content manifest
|
||
|
||
Manifest содержит только ссылки, без bytes, и адресует владельца по **id**, а не
|
||
по индексу в массиве (R15):
|
||
|
||
```json
|
||
{
|
||
"kind": "plan|attachment|decor_asset",
|
||
"owner": "space|marker",
|
||
"owner_id": "loft",
|
||
"field": "plan_url",
|
||
"url": "/api/houseplan/content/plans/_/loft.ab12cd34.svg",
|
||
"storage": "internal|external",
|
||
"exists_at_export": true
|
||
}
|
||
```
|
||
|
||
Текущие обязательные owners: `space.plan_url` и `marker.pdfs[].url`
|
||
(источник истины по составу — `logic.ts:1851 referencedContentUrls`);
|
||
`decor_asset` подключается после появления соответствующего поля из #51. Signed
|
||
URL, auth token и runtime-resolved URL никогда не экспортируются: в конфиге и
|
||
так лежит сырой путь, подпись живёт только в рантайме (`signing.ts`).
|
||
|
||
Правила импорта:
|
||
|
||
| Ссылка | Результат |
|
||
|---|---|
|
||
| external schema-valid URL | сохраняется без сетевого запроса |
|
||
| internal, тот же fingerprint, файл существует | сохраняется |
|
||
| internal, тот же fingerprint, файл исчез | preview требует отсоединения |
|
||
| internal, другой fingerprint | считается непереносимой даже при совпадении имени |
|
||
|
||
Отсоединение означает `plan_url = null` либо удаление соответствующего PDF ref.
|
||
Проверка существования — та же функция, что защищает обычную запись:
|
||
`_missing_internal_plans` (`websocket_api.py:714`). Без отсоединения apply
|
||
физически не может завершиться: он вернул бы `missing_plan` (R3).
|
||
|
||
Ни preview, ни apply не удаляют файлы с диска и не выполняют внешние HTTP GET.
|
||
|
||
## 6. Backend API
|
||
|
||
### 6.1 Export
|
||
|
||
```text
|
||
houseplan/export/create
|
||
{kind: full|space, space_id?, card_version}
|
||
→ {document, filename}
|
||
```
|
||
|
||
Backend под `write_lock` (`store.py:45` — он общий для обоих сторов) одним
|
||
чтением получает config и layout, строит live snapshot, manifest и metadata.
|
||
Lock удерживается только на чтение/копирование, не на frontend download.
|
||
Направление «сервер → клиент» размерных ограничений WS не создаёт: конфиг
|
||
ограничен `MAX_CONFIG_BYTES = 2 MiB` (`validation.py:114`).
|
||
|
||
### 6.2 Import preview и revalidate
|
||
|
||
Недоверенный JSON не парсится в карточке, и WS для загрузки не используется:
|
||
входящее направление ограничено и уже занято `config/set`. Frontend отправляет
|
||
файл как raw body в новый authenticated view — по образцу
|
||
`HouseplanUploadView` (`http_api.py:115`), регистрация в `async_setup`
|
||
(`__init__.py:35-38`), `requires_auth = True`, `may_write` внутри:
|
||
|
||
```text
|
||
POST /api/houseplan/import/preview?duplicate_policy=skip|virtual
|
||
→ {token, preview, expected_config_rev, expected_layout_rev, expires_at}
|
||
```
|
||
|
||
Endpoint streaming-читает не больше `MAX_EXPORT_BYTES = 8 MiB` (новая константа,
|
||
проверка по мере чтения, ответ `413`, как в `http_api.py:185-189`), строго
|
||
разбирает JSON, валидирует envelope, `CONFIG_SCHEMA`, `LAYOUT_SCHEMA`, content
|
||
policy и итоговые collection/byte caps после merge. Никакие HA services или
|
||
store writes во время preview не выполняются.
|
||
|
||
```text
|
||
houseplan/import/revalidate
|
||
{token, duplicate_policy?}
|
||
→ {token, preview, expected_config_rev, expected_layout_rev, expires_at}
|
||
```
|
||
|
||
Пересчитывает preview на актуальных revision из уже проверенного candidate —
|
||
это ответ на смену политики дубликатов и на конфликт, вызванный собственной
|
||
фоновой записью позиций (`layout/update` без `expected_rev`, debounce 600 мс —
|
||
R16). Повторно выбирать файл пользователь не должен.
|
||
|
||
Preview token:
|
||
|
||
- криптографически случайный, одноразовый на apply, TTL 10 минут;
|
||
- связан с user id, digest файла, выбранной duplicate policy и обеими rev;
|
||
- хранит уже проверенный candidate только в памяти;
|
||
- максимум три token на пользователя и одновременно максимум три token на весь
|
||
runtime (parsed JSON занимает больше wire bytes); старейший вытесняется;
|
||
- не переживает restart и никогда не пишется в лог целиком.
|
||
|
||
### 6.3 Apply и Undo
|
||
|
||
```text
|
||
houseplan/import/apply
|
||
{token, expected_config_rev, expected_layout_rev,
|
||
confirm_missing_content: bool}
|
||
→ {ok, kind, config_rev, layout_rev, counts, can_undo}
|
||
```
|
||
|
||
Undo отдельной команды не получает: используется существующая
|
||
`houseplan/plan/optimize_undo` (`websocket_api.py:941`) — тот же слот
|
||
`optimize_backup`, та же проверка `_optimizer_backup_is_current`, те же
|
||
`expected_config_rev`/`expected_layout_rev`. К снапшоту добавляется поле
|
||
`kind: "optimize" | "import"`; `config/get` и `layout/get` рядом с существующим
|
||
`can_optimize_undo` отдают `undo_kind`, и подпись кнопки выбирается по нему
|
||
(R5). Имена ключей стора не меняются — миграции не требуется, setup-recovery
|
||
(`__init__.py:156-193`) продолжает работать как есть.
|
||
|
||
Apply повторно проверяет `may_write`, owner/TTL/single-use token и обе текущие
|
||
revision. Любой mismatch возвращает `conflict`/`preview_expired`; клиент
|
||
вызывает `revalidate`, а не присылает candidate повторно. Full import создаёт
|
||
backup; space import — нет.
|
||
|
||
## 7. Согласованная запись двух Store
|
||
|
||
Буквальная atomic transaction между `.storage/houseplan.config` и
|
||
`.storage/houseplan.layout` невозможна. Механизм уже существует дважды
|
||
(`geom_pending` — `__init__.py:112-154`, `optimize_pending` —
|
||
`websocket_api.py:900-925`); третий не заводится. Обе половины импорта проходят
|
||
через **тот же** paired commit:
|
||
|
||
1. Под `write_lock` повторно загрузить stores и сравнить revisions.
|
||
2. Сохранить в layout store intent (`optimize_pending` + `kind: "import"`) с
|
||
target config/layout и before snapshot.
|
||
3. Сохранить target config.
|
||
4. Сохранить target layout и удалить pending intent; для full import оставить
|
||
one-deep backup.
|
||
5. Только после обеих durable writes отправить `houseplan_config_updated` и
|
||
`houseplan_layout_updated` и success response.
|
||
|
||
При сбое любой записи handler один раз повторно сходится к target, учитывая
|
||
fail-after-write. Если это не удалось, он сначала заменяет durable intent на
|
||
`kind: import_rollback`, затем сходится к before-pair. Поэтому error response
|
||
никогда не оставляет intent, который при restart сам закончит импорт; при
|
||
аварии уже во время rollback setup-time recovery идемпотентно восстанавливает
|
||
before-pair до регистрации API. Events никогда не посылаются для
|
||
полуприменённой пары.
|
||
|
||
Нормативно (R4): запись layout-стора выполняется **единственным** helper-ом,
|
||
который сохраняет все служебные ключи и заменяет только `layout` и `rev`. Все
|
||
существующие вызовы (`websocket_api.py:195, 259, 314, 346, 657, 913, 985`)
|
||
переводятся на него в рамках этой задачи; белый список в `geometry/repair`
|
||
(`:314`, `:346`) сегодня стирает `optimize_backup`/`geom_pending` — это баг #87,
|
||
и он же — готовый мутант для §12.
|
||
|
||
Space import использует тот же paired commit без пользовательского Undo: это
|
||
гарантирует, что новый space и его positions не разъедутся при сбое.
|
||
|
||
Файлы (R2). Путь импорта **не вызывает** `collect_plans` и
|
||
`collect_attachments`: для них полная замена конфига выглядит как «старый
|
||
референс исчез» и они удалили бы картинки планов предыдущей конфигурации,
|
||
обесценив Undo. Суточный sweep (`__init__.py:210-244`) безопасен: он передаёт
|
||
один и тот же конфиг с обеих сторон, а непривязанные plan-файлы сохраняются по
|
||
второму правилу `collect_plans` (`plans.py:288-296`). Отсоединённые файлы
|
||
пользователь удаляет существующим менеджером планов.
|
||
|
||
После успешного full import выполняются:
|
||
|
||
- `_refresh_trail_recorder(hass)` и удаление прогонов, чьих маркеров больше нет
|
||
(`houseplan.trails` ключуется `marker_id` из конфига — `trails.py:135-141`,
|
||
собственного GC у книги нет, R12);
|
||
- `async_check_plan_files` — пересчёт repair-issue `broken_plan_<space_id>`
|
||
(`repairs.py:18`).
|
||
|
||
## 8. Правила полного импорта
|
||
|
||
- Incoming config/layout полностью заменяют current config/live layout.
|
||
- Revisions, optimizer/import pending/backup и runtime metadata из файла
|
||
игнорируются: новый target получает собственные revisions.
|
||
- Current normalized data roundtrip сохраняет config/layout эквивалентно,
|
||
включая поля, проходящие через `extra=vol.ALLOW_EXTRA`.
|
||
- Known lifecycle fields с `vol.Remove` (`aspect` — `validation.py:520`,
|
||
`segments` — `:595`) и допустимые coercions показываются в preview как
|
||
normalization; это не скрытая Optimize operation.
|
||
- `settings.known_devices` и `settings.new_device_ids` импортируются только при
|
||
совпадении `source_fingerprint`; при импорте с чужой instance они
|
||
отбрасываются, иначе все устройства target станут «новыми»
|
||
(`houseplan-card.ts:3407-3412`, R6). Это отдельная строка preview.
|
||
- Missing, disabled, removed, virtual markers и vacuum calibration сохраняются.
|
||
- Missing/disabled HA bindings не блокируют импорт и после него обрабатываются
|
||
существующим binding lifecycle.
|
||
- HA area ids комнат сохраняются буквально; preview отдельно показывает
|
||
отсутствующие target areas, автоматического matching по названию нет.
|
||
- Full import не выполняет entity actions и не меняет HA registry.
|
||
|
||
## 9. Правила импорта пространства
|
||
|
||
### 9.1 Новое пространство, ids и ссылки
|
||
|
||
- Import всегда создаёт новый safe space id (`SPACE_ID_RE`, `validation.py:17`).
|
||
- Title сравнивается после trim + Unicode normalization + casefold; конфликт
|
||
получает ` (2)`, ` (3)` и далее.
|
||
- Все space-owned ids всегда получают новые ids, даже если сейчас не
|
||
конфликтуют: room, room draft, partition, column, opening и decor.
|
||
- Геометрические wall keys и `open_spans` не являются object ids и
|
||
пересчитываются/сохраняются по действующему wall contract, а не получают
|
||
случайный suffix.
|
||
- Одна immutable remap map переписывает все внутренние ссылки.
|
||
|
||
| Поле | Действие |
|
||
|---|---|
|
||
| `room.id`, `opening.id`, `decor.id`, `partition.id`, `wall_column.id`, `room_draft.id`, `marker.id`, `space.id` | новый id |
|
||
| `room.open_to[]` | remap на новые room id |
|
||
| `marker.room_id`, `marker.space` | remap |
|
||
| `marker.vacuum.segment_map` (значения — room id; поле объявлено в `types.ts:110`, потребителя пока нет) | remap, чтобы не оставить мину |
|
||
| layout keys `<marker_id>`, `rl_<room_id>`, `v_*` | новые ключи по той же map |
|
||
| `pos.s` | новый space id; `k` сохраняется |
|
||
| `marker.binding`, `marker.tap_target`, `marker.vacuum.source` | **буквально**, guessed matching запрещён |
|
||
| `marker.controls[]` — значения `light.*`/`switch.*` | **буквально** |
|
||
| `marker.controls[]` — значения `marker:<id>` (вводятся задачей #84) | remap по той же map, что и `marker.id`; ссылка на маркер вне импортируемого пространства **удаляется**, а не остаётся битой, и считается в preview отдельной строкой |
|
||
| `room.area`, `room.settings.temp_source`, `room.settings.hum_source` | **буквально** |
|
||
| `opening.contact`, `opening.lock`, decor `entity` | **буквально** |
|
||
| `marker.vacuum.calibration` (ключи — map id робота, `vacuum.ts:80`) | **буквально** |
|
||
| `space.plan_url`, `marker.pdfs[].url` | по правилам §5.3 |
|
||
| layout keys вида HA `device_id` и `lg_<entity_id>` | **буквально**: это идентификаторы HA, а не наши |
|
||
|
||
- Повторный импорт того же файла создаёт ещё одно независимое пространство и
|
||
не меняет результат первого импорта.
|
||
|
||
### 9.2 HA bindings
|
||
|
||
- `device:`/`entity:` ids сохраняются буквально; guessed matching запрещён.
|
||
- Room `area` ids сохраняются буквально; отсутствующий area не блокирует
|
||
геометрию комнаты и явно отмечается в preview.
|
||
- Отсутствующий target binding импортируется как явный orphan marker.
|
||
- Disabled binding импортируется, но остаётся скрытым по существующему правилу
|
||
disabled devices.
|
||
- `controls` и другие entity refs сохраняются; runtime отдельно фильтрует
|
||
недоступные targets.
|
||
- Binding конфликтует, если target уже имеет live, hidden, removed tombstone
|
||
или layout-only placement с тем же binding. Ничего не реактивируется молча.
|
||
- Для конфликта действует выбранная в preview политика `skip` или безопасная
|
||
virtual visual copy из §4.5.
|
||
|
||
### 9.3 Итоговые limits
|
||
|
||
До выдачи apply token валидируется **merged target**, а не только файл:
|
||
|
||
| Лимит | Значение | Источник |
|
||
|---|---|---|
|
||
| `MAX_CONFIG_BYTES` | 2 MiB | `validation.py:114` |
|
||
| `MAX_SPACES` | 50 | `validation.py:79-108` |
|
||
| `MAX_ROOMS` | 400 | там же |
|
||
| `MAX_MARKERS` | 2000 | там же |
|
||
| `MAX_LAYOUT` | 5000 | там же |
|
||
| `MAX_DECOR` / `MAX_OPENINGS` / `MAX_WALLS` | 1000 / 500 / 500 | там же |
|
||
| `MAX_EXPORT_BYTES` | 8 MiB | новая, `const.py` |
|
||
|
||
Плюс все inner collection caps. При превышении preview read-only и ничего не
|
||
записывается.
|
||
|
||
## 10. Безопасность и приватность
|
||
|
||
- UI visibility не считается authorization; каждый новый endpoint вызывает
|
||
`may_write` (`auth.py:16`) и `_runtime`. Исключение `content/sign`
|
||
(`websocket_api.py:530`) сделано ради read-only зрителей и образцом не
|
||
является (R17).
|
||
- JSON содержит персональные HA ids/имена/ссылки; это прямо указано до export.
|
||
В отличие от `diagnostics.py`, здесь ничего не редактируется намеренно.
|
||
- Strict parser отклоняет NaN/Infinity, duplicate keys, BOM после первого
|
||
символа, trailing data и рекурсивные `__proto__`/`prototype`/`constructor`.
|
||
- Path traversal, неизвестный internal content namespace и неканонический
|
||
internal URL дают invalid content error; канон — `CONTENT_URL` +
|
||
`/plans/_/<name>` и `/files/<marker_id>/<name>` (`const.py:13`,
|
||
`websocket_api.py:1075`, `http_api.py:248`).
|
||
- Preview не возвращает payload обратно в DOM: только bounded summary и token.
|
||
- Raw document, entity ids, titles, coordinates и links не логируются; допустимы
|
||
digest, bytes, kind, counts и stable error code.
|
||
- Preview/apply не вызывают HA services и не загружают external URLs.
|
||
- Cancel, validation error, conflict, expired token и disconnect дают ноль
|
||
store writes.
|
||
- Backend export создаёт обычный JSON response; никакая внешняя telemetry не
|
||
используется.
|
||
|
||
## 11. Stable error codes
|
||
|
||
Минимальный набор:
|
||
|
||
- `unauthorized`, `not_ready`, `too_large`;
|
||
- `invalid_json`, `invalid_format`, `unsupported_export_version`;
|
||
- `future_model`, `invalid_config`, `invalid_layout`, `invalid_content`;
|
||
- `space_not_found`, `capacity_exceeded`;
|
||
- `preview_expired`, `preview_owner_mismatch`, `conflict`;
|
||
- `content_confirmation_required`, `missing_plan`, `missing_content`,
|
||
`commit_failed`, `no_backup`.
|
||
|
||
Preview обязан заранее поймать недостающий plan и предложить отсоединение
|
||
(§5.3). Но файл может исчезнуть после preview, поэтому apply повторяет проверку
|
||
под write-lock и в этой гонке возвращает стабильный `missing_plan`. Та же
|
||
проверка применяется к локальным PDF-вложениям и возвращает
|
||
`missing_content`, если файл исчез после preview. Frontend локализует code;
|
||
backend message остаётся диагностикой, но не является единственным
|
||
пользовательским текстом.
|
||
|
||
## 12. Проверки
|
||
|
||
### Backend (`tests_backend/`, HA-тесты в файлах `test_ha_*.py`)
|
||
|
||
- export во время конкурентной записи даёт одну согласованную пару revisions;
|
||
- current normalized full roundtrip сохраняет config + live layout + unknown
|
||
supported fields;
|
||
- strict parser и каждый envelope/schema/size/content error не пишут stores;
|
||
- preview token: owner, TTL, cap, policy, single-use, conflict; `revalidate`
|
||
выдаёт свежие rev и не требует повторной загрузки файла;
|
||
- full paired commit: success, failure каждой write, crash recovery, events;
|
||
- **apply не удаляет файлы**: план предыдущей конфигурации остаётся на диске
|
||
после полной замены (прямая проверка R2);
|
||
- **apply с недостающим внутренним планом** без отсоединения отклоняется, с
|
||
отсоединением проходит (R3);
|
||
- Undo работает один раз, отдаёт `undo_kind: "import"` и становится stale после
|
||
любого следующего edit; Optimize и импорт делят один слот (R5);
|
||
- запись layout-стора любым существующим хендлером сохраняет служебные ключи
|
||
(регрессия на `geometry/repair`, R4);
|
||
- space paired commit не оставляет config без layout и наоборот;
|
||
- remap всех ids/references из таблицы §9.1, повторный импорт,
|
||
title/layout/marker collisions;
|
||
- layout-only device, `lg_*`, legacy-запись без `s`, orphan, disabled, hidden,
|
||
removed, virtual и duplicate binding с обеими policies;
|
||
- vacuum calibration/segment map сохраняются, trails отсутствуют, сироты
|
||
трек-книги удаляются после full import (R12);
|
||
- `known_devices`/`new_device_ids` отбрасываются при чужом fingerprint и
|
||
сохраняются при своём (R6);
|
||
- same-instance internal asset сохраняется; missing/cross-instance требует
|
||
detach confirmation; external URL не fetch-ится;
|
||
- merged collection/byte caps проверяются до token;
|
||
- parity-тест `PLAN_MODEL_VERSION` между `const.py` и `plan-optimizer.ts` (R1).
|
||
|
||
### Frontend (`test/*.test.mjs`, `demo/smoke_*.mjs`)
|
||
|
||
- раздел виден только writer; две кнопки имеют правильные accessible names;
|
||
- обновлён эталон `demo/smoke_general_settings.mjs` (`rows`, массив групп) —
|
||
без этого смок красный (R10);
|
||
- паритет RU/EN для всех `backup.*` (`test/i18n.test.mjs`);
|
||
- full/space export формируют корректный download и revoke object URL;
|
||
- import file picker не парсит payload и не пишет state до preview/apply;
|
||
- invalid/too-large/future files показывают локализованную read-only ошибку;
|
||
- full preview: counts, missing-content checkbox, focus, cancel, danger apply;
|
||
- space preview: suffix, target-global warning, смена duplicate policy идёт
|
||
через `revalidate`;
|
||
- success reload очищает stale dialogs/caches и выбирает корректное space;
|
||
- cancel/Escape/scrim/double click/disconnect не применяют token дважды;
|
||
- desktop + narrow touch: footer не обрезан, horizontal scroll отсутствует;
|
||
- RU/EN golden для export dialog, full destructive preview и space preview:
|
||
требуется новая ветка в `demo/golden/harness.mjs` (сейчас поддержаны только
|
||
`device` и `decor-color`) и bump `GOLDEN_MATRIX_VERSION` (R11).
|
||
|
||
### Mutation/negative gate (контракт #85)
|
||
|
||
Задача не считается выполненной, пока не показано, что тесты краснеют при:
|
||
|
||
1. возврате `collect_plans`/`collect_attachments` на путь apply — тест «план
|
||
предыдущей конфигурации остался на диске» обязан упасть;
|
||
2. пропуске detach-проверки — apply обязан упасть на `missing_plan`;
|
||
3. записи layout-стора белым списком (как в `geometry/repair`) — снапшот Undo
|
||
обязан пропасть и тест обязан это заметить;
|
||
4. приёме payload от клиента вместо preview token;
|
||
5. переиспользовании исходных ids при space import;
|
||
6. отправке event между двумя Store writes;
|
||
7. удалении секции из «Общих настроек» — смок обязан покраснеть на эталоне.
|
||
|
||
## 13. Acceptance criteria
|
||
|
||
1. Writer скачивает один versioned JSON для всей модели или текущего space.
|
||
2. Full export является согласованным snapshot config + live layout.
|
||
3. Full import ничего не меняет до preview и явной danger-кнопки, затем заменяет
|
||
оба stores согласованно и предоставляет one-deep Undo — **тот же самый**, что
|
||
у оптимизации, одной кнопкой.
|
||
4. Полная замена конфигурации не удаляет ни одного файла с диска.
|
||
5. Space import всегда добавляет независимое пространство, remap-ит все
|
||
внутренние ids/references из таблицы §9.1 и не перезаписывает существующие
|
||
данные.
|
||
6. Missing/disabled bindings, duplicates и layout-only devices имеют
|
||
предсказуемое поведение из §9; guessed matching отсутствует.
|
||
7. Локальные assets другой instance не подменяются одноимёнными target files;
|
||
их отсоединение всегда видно и отдельно подтверждено.
|
||
8. Invalid, future, oversize, stale, unauthorized и interrupted операции не
|
||
оставляют наблюдаемую несогласованную пару config/layout.
|
||
9. Trails, runtime state, revisions, signed tokens и undo metadata в export не
|
||
попадают; import не вызывает HA services и внешнюю сеть.
|
||
10. UI доступен с клавиатуры, не обрезается на узком экране и локализован RU/EN.
|
||
11. Документация честно называет JSON backup моделью без media bytes.
|
||
|
||
## 14. Release artifacts
|
||
|
||
В том же release cycle обязательны:
|
||
|
||
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #50;
|
||
- новый раздел backup/transfer в `docs/USER-GUIDE.ru.md`;
|
||
- обновление `docs/ARCHITECTURE.md`: новый HTTP view, paired commit, token,
|
||
manifests, единый helper записи layout-стора;
|
||
- `docs/STATUS.md` после реализации;
|
||
- RU/EN goldens из §12, обновлённый `demo/smoke_general_settings.mjs` и
|
||
backend/frontend test inventory;
|
||
- короткий release body: пользовательская ценность, без перечисления protocol.
|