Files
houseplan-card/docs/specs/167-plan-only-export.md
T
2026-08-17 13:16:04 +03:00

509 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Issue #167 — экспорт «только планировка»
Дата: 2026-08-17
Тип: `feature` · приоритет: `P1` · пользовательская ценность: 7/10 ·
сложность: 5/10 · риск: 6/10
Issue: [#167](https://github.com/Matysh/houseplan-card/issues/167)
Ветка: `issue/167-plan-only-export`
Зависимость: [#50](https://github.com/Matysh/houseplan-card/issues/50) — выполнена
и выпущена в stable v1.62.0.
Канонические документы: [SCOPE](../SCOPE.md),
[CONFIG-COMPATIBILITY](../CONFIG-COMPATIBILITY.md),
[TOUCH-SUPPORT](../TOUCH-SUPPORT.md), [USER-GUIDE](../USER-GUIDE.md),
[USER-GUIDE.ru](../USER-GUIDE.ru.md),
[ТЗ #50](050-config-export-import.md).
## 1. Сценарий и персона
Владелец уже нарисовал этаж и хочет:
- перенести его геометрию в другой Home Assistant, где устройства и Area имеют
другие идентификаторы;
- передать чистый шаблон планировки другому пользователю;
- сохранить архитектурную заготовку без раскрытия HA-привязок.
В General settings он открывает действующий экспорт, выбирает «Current space»
и включает «Plan only». Полученный JSON импортируется существующим потоком как
новое пространство: комнаты, стены, проёмы, декор и фон остаются, а устройства
и автоматические привязки на новом экземпляре настраиваются заново.
Это сценарии J4/J6 из `docs/SCOPE.md`: первоначальная настройка и дальнейшее
обслуживание House Plan. Нового поведения обычного View задача не вводит.
## 2. Что человек увидит до и после
**До:** экспорт текущего пространства всегда содержит его маркеры, device layout и
HA-привязки. Для чистого переноса пользователь должен вручную редактировать
JSON, рискуя повредить структуру или случайно оставить идентификаторы.
**После:** рядом с выбором текущего пространства доступен выключенный по
умолчанию флажок «Plan only». В этом режиме файл сохраняет переносимую
планировку и вручную расставленные подписи комнат, но не содержит маркеров,
device layout и известных структурных HA-привязок. Preview импорта явно
сообщает, что файл содержит только
планировку; импорт добавляет новое несвязанное пространство существующим
безопасным механизмом #50.
Обычный full export и обычный export current space работают как раньше.
## 3. Проблема и подтверждённая причина
1. `houseplan/export/create` принимает только `kind`, `space_id` и версию
карточки; отдельного намерения «только планировка» нет.
2. `create_export()` для `kind == "space"` намеренно выбирает маркеры этого
пространства и соответствующий live layout.
3. HA-привязки находятся не только в маркерах. Они есть в `room.area`,
`room.settings.temp_source|hum_source`, `opening.contact|lock` и live-text
декора; поэтому одного удаления массива `markers` недостаточно.
4. Современный live text хранит ссылку прямо в `decor.text` токеном вида
`{sensor.kitchen}`; legacy-конфиги дополнительно могут содержать поля
`entity`, `attr`, `unit` и placeholder `{}`.
5. Действующий импорт пространства уже умеет remap внутренних id, добавить
новое пространство без замены существующего, отсоединить недоступный
content и показать preview. Новый импорт-процесс не требуется.
## 4. Scope
В задачу входят:
1. опция «Plan only» только для экспорта текущего пространства;
2. schema-aware проекция переносимой геометрии и визуальных настроек;
3. полное удаление реальных и виртуальных маркеров, marker/auto-device/light-
group layout и структурных HA-привязок при сохранении безопасных позиций
подписей комнат `rl_<room_id>`;
4. статическая нейтрализация live-text токенов по решению владельца;
5. аддитивный признак `transfer.plan_only: true` в JSON;
6. строгая проверка plan-only инварианта при чтении файла;
7. существующий preview/apply пространства с явным plan-only статусом;
8. одинаковый контракт в RU/EN;
9. unit, backend, smoke, golden и executable mutation coverage;
10. пользовательская документация и оба changelog.
## 5. Non-scope
В задачу не входят:
- полноценная анонимизация пользовательского содержимого;
- удаление или замена названий пространства и комнат, статического текста,
имён файлов, внешних URL и иных пользовательских строк;
- встраивание backdrop или attachment bytes в JSON — действует content-
контракт #50;
- экспорт нескольких выбранных пространств;
- новый формат файла, отдельный import endpoint или replace существующего
пространства;
- сопоставление Area, устройств и сущностей при импорте;
- перенос marker icon, actions, vacuum paths, runtime states, histories,
trails, known/new-device bookkeeping;
- сохранение виртуальных маркеров вроде пользовательских заметок «Котёл»:
`binding: virtual` не делает marker частью архитектурной геометрии, поэтому
он удаляется вместе со всеми остальными маркерами;
- создание PDF/изображения чистого плана — это сценарий #53;
- дополнительное privacy-предупреждение специально для plan-only;
- изменение редакторов, View, kiosk или touch-жестов;
- миграция сохранённого server config либо layout store.
## 6. Пользовательский и UX-контракт
### 6.1. Диалог экспорта
В существующем диалоге:
1. Full backup и Current space остаются взаимоисключающими radio options.
2. Флажок «Plan only» показывается и доступен только при выбранном Current
space и наличии текущего пространства.
3. При каждом открытии диалога флажок выключен.
4. Переключение на Full backup сбрасывает флажок; возврат к Current space не
включает его автоматически.
5. На сервер отправляется `plan_only: true` только при Current space +
включённом флажке. При всех остальных состояниях поле отсутствует или false.
6. Действующий `backup.privacy_warning` сохраняется без изменений. Новое
предупреждение, требующее отдельного подтверждения, не добавляется.
7. Label и короткий нейтральный hint должны объяснять результат, но не обещать
анонимизацию: «Сохранить комнаты, стены, проёмы и декор без устройств и
привязок Home Assistant».
Флажок следует существующей keyboard/focus семантике `ha-checkbox`, имеет
доступную подпись и не уменьшает действующие touch targets.
### 6.2. Preview импорта
Для plan-only файла preview:
- явно показывает информационную строку «Файл содержит только планировку»;
- показывает `markers = 0`, device/entity/virtual bindings = 0, а `layout`
считает только сохранённые позиции подписей комнат;
- не показывает duplicate policy, поскольку дубликатов устройств нет;
- не показывает missing Area, поскольку `room.area` очищен;
- показывает обычные counts комнат, стен, проёмов, декора и content;
- сохраняет действующие final-name, content detach и confirmation правила #50;
- после revalidate продолжает показывать plan-only статус.
Кнопка применения остаётся «Add space». Импорт никогда не заменяет текущее
пространство и не вводит отдельного Undo.
## 7. Контракт экспортируемой модели
### 7.1. Envelope и совместимость формата
Файл остаётся обычным envelope #50:
```json
{
"kind": "space",
"transfer": {
"plan_only": true,
"dropped_marker_links": 0
},
"payload": {
"config": { "spaces": ["…"], "markers": [] },
"layout": {
"rl_room-kitchen": { "x": 0.42, "y": 0.31, "s": "floor-1", "k": 1.4 }
}
},
"placement_manifest": [
{
"layout_id": "rl_room-kitchen",
"space_id": "floor-1",
"owner": "room_label",
"owner_id": "room-kitchen",
"binding": null,
"label": null,
"icon": null
}
],
"content_manifest": ["…"]
}
```
- `export_version` и `model_version` не повышаются только из-за этой опции.
- `transfer.plan_only` допускается только как strict boolean и только при
`kind == "space"`.
- Поле присутствует только при true. У обычного space export документ при
фиксированных входе и времени остаётся семантически и структурно идентичен
прежнему, без `plan_only: false`.
- `plan_only: true` у `kind == "full"` отклоняется как `invalid_format`.
- Старые файлы без поля читаются как обычный экспорт.
### 7.2. Что сохраняется
Экспорт строит новую проекцию из текущего известного portable-plan allowlist,
а не копирует произвольные объекты с последующим чёрным списком. Сохраняются:
- одно пространство: внутренний id, title и известные собственные визуальные
настройки;
- rooms: внутренние id, title, polygon/geometry, толщина/вид стен и известные
визуальные room settings;
- walls, drafts, partitions, columns, open spans и иные поддерживаемые
геометрические примитивы пространства;
- openings/open boundaries: id, тип, геометрия, ориентация и flip-поля;
- decor/backdrop: тип, геометрия, transform, style, статический текст и
переносимые content references;
- `plan_url` и backdrop transforms по действующему content manifest #50.
Внутренние House Plan id сохраняются только внутри файла и затем remap-ятся
существующим `build_space_merge()`. Они не являются HA-привязками.
### 7.3. Что удаляется или нейтрализуется
Обязательная проекция:
| Источник | Результат plan-only |
|---|---|
| `config.markers` | `[]`; marker config целиком отсутствует |
| `payload.layout` | только `rl_<room_id>` для комнаты экспортируемого пространства: обязательные `x/y/s` и опциональный конечный масштаб карточки `k` в диапазоне `0.5..3`; marker, `v_*`, `lg_*`, auto-device, неизвестные поля и невалидный `k` удаляются |
| `placement_manifest` | только canonical `room_label` entries, точно соответствующие сохранённым `rl_*` ключам |
| marker attachment/content entries | отсутствуют |
| `room.area` | отсутствует или canonical unbound value |
| `room.settings.temp_source` | отсутствует |
| `room.settings.hum_source` | отсутствует |
| `opening.contact` / `opening.lock` | отсутствуют |
| contact-specific `opening.invert` | отсутствует как часть binding behavior |
| decor legacy `entity` / `attr` / `unit` | отсутствуют |
| valid live tokens и legacy `{}` в `decor.text` | заменены на `—` |
| `known_devices` / `new_device_ids` | не переносятся |
Реальные и виртуальные markers удаляются одинаково: `binding: virtual`, имя или
статичная иконка не переводят marker в архитектурный decor.
`flip_h`, `flip_v` и другие геометрические параметры проёма не являются
HA-binding behavior и сохраняются.
### 7.4. Live text
Используется тот же синтаксический контракт live-text, что во фронтенде, без
подстановки runtime state:
- каждый валидный HA live token `{sensor.kitchen}` заменяется одним символом
`—`;
- legacy placeholder `{}` также заменяется на `—`;
- окружающий пользовательский текст, whitespace и форматирование сохраняются;
- malformed braces, которые parser не признаёт live token, остаются обычным
статическим текстом;
- legacy `entity`, `attr`, `unit` удаляются независимо от наличия placeholder.
Пример: `Температура {sensor.kitchen} °C` → `Температура — °C`.
Это принятое владельцем решение Q1. Текущее значение сущности не читается и
не записывается: экспорт остаётся deterministic относительно server config.
### 7.5. Граница privacy-обещания
Режим гарантирует отсутствие HA-specific identifier/binding в известных
структурных позициях модели и распознанных live tokens. Он не сканирует и не
анонимизирует произвольный пользовательский текст. Поэтому сохраняются названия
пространства/комнат, статические decor labels, filenames и внешние URL, даже
если пользователь сам написал в них строку, похожую на entity id.
Это принятое владельцем решение Q2. Дополнительное UI-предупреждение не
добавляется.
Неизвестные поля внутри экспортируемых model objects не копируются автоматически:
новое переносимое поле сначала должно быть классифицировано как geometry,
presentation, user content или HA binding. Это fail-closed защита от утечки
нового binding-поля в будущей версии.
## 8. Контракт API, парсинга и импорта
### 8.1. Export endpoint
`houseplan/export/create` получает optional strict boolean `plan_only`.
- `plan_only == true` требует `kind == "space"` и валидный `space_id`.
- Право доступа, readiness, limits, source fingerprint, signing/content и
download contract остаются от #50.
- Проекция строится на backend; frontend не получает полный config для
самостоятельной очистки.
- Экспорт не читает HA runtime states и не выполняет network requests.
### 8.2. Проверка входящего файла
`parse_document()` не доверяет одному флагу. Для
`kind == "space" && transfer.plan_only == true` он дополнительно проверяет:
- ровно одно пространство;
- `markers == []`;
- каждый layout key строго равен `rl_<room_id>` существующей комнаты
экспортируемого пространства, `pos.s` равен id этого пространства, а запись
содержит только `x/y/s` и опциональный конечный `k` в диапазоне `0.5..3`;
- каждый placement entry canonical: `owner == "room_label"`, `owner_id`
совпадает с room id, `binding|label|icon == null`, и set записей точно
совпадает с layout;
- отсутствие marker-owned content;
- отсутствие Area/temp/hum/opening/decor legacy bindings;
- отсутствие валидных live-text токенов и legacy `{}`;
- согласованность обычного content manifest.
Нарушение возвращает существующий стабильный `invalid_format`; файл не
попадает в preview/apply. Это предотвращает ложную маркировку вручную
отредактированного файла как «только планировка».
### 8.3. Preview, revalidate и apply
- `create_preview()` возвращает `plan_only: true` для валидного файла.
- Кандидат и `revalidate_candidate()` сохраняют это значение.
- Existing space merge remap-ит внутренние id, добавляет suffix к конфликтному
title и не меняет global settings.
- Content availability/detach повторно проверяется перед apply под действующим
lock по контракту #50.
- Apply не добавляет маркеры; существующий remap переносит только room-label
layout на новые room/space ids, а комнаты остаются unbound.
- Events, revision conflict, token ownership/expiry и capacity limits не
меняются.
## 9. Модель данных, миграция и compatibility
Server config, layout store и localStorage не получают новых полей. Опция
существует только в краткоживущем состоянии export dialog и в export envelope.
Прямая миграция не нужна: новая версия читает прежние full/space файлы без
изменений. Обратная совместимость best-effort: старая версия, поддерживающая
тот же `export_version` и игнорирующая additive transfer metadata, увидит
структурно валидный обычный space export с нулём маркеров. При этом именно
новая версия обязана проверить усиленный plan-only инвариант.
Ordinary full и space exports, обычный preview/apply и existing import Undo не
меняются.
## 10. i18n, accessibility и touch
Нужны синхронные RU/EN keys минимум для:
- label «Plan only»;
- короткого hint без обещания анонимизации;
- informational preview line.
Новых error keys и дополнительного privacy warning нет; invalid document
использует `backup.error.invalid_format`.
Диалог остаётся keyboard-operable: label связан с checkbox, visible focus и
screen-reader name обеспечиваются действующим компонентом. Preview status
доступен как обычный текст, не только цветом.
Touch View и kiosk не затронуты. General settings/editor остаётся desktop-
first по `TOUCH-SUPPORT`, но диалог не должен переполнять узкий viewport и
действующие touch targets не уменьшаются.
## 11. Acceptance criteria и доказательства
1. При Current space пользователь видит выключенный «Plan only»; при Full
backup опции нет, а request не содержит true.
2. Plan-only export содержит одно пространство, `markers: []`, только валидные
`rl_<room_id>` layout/room-label placement entries и
`transfer.plan_only: true`.
3. Геометрия rooms/walls/drafts/partitions/columns/openings/open spans,
decor/backdrop и переносимые визуальные настройки сохраняются по allowlist.
4. Area, temperature/humidity source, opening contact/lock/invert, marker data,
known/new bookkeeping и legacy decor binding fields отсутствуют.
5. Все валидные inline live tokens и legacy `{}` заменены на `—` с сохранением
окружающего текста; runtime value в файл не попадает.
6. Названия, статический текст, filenames и external URLs сохраняются; UX не
обещает полную анонимизацию и не добавляет отдельного предупреждения.
7. Импорт plan-only файла на чистый целевой instance создаёт новое пространство
с той же планировкой, нулём устройств/HA-привязок, remap-нутыми позициями и
масштабом подписей комнат и unbound rooms.
8. Preview и revalidate явно сохраняют `plan_only: true`, показывают нулевые
binding counts и не предлагают duplicate policy.
9. File с true, но с маркером, не-room-label layout, несогласованным placement
или известной HA-привязкой отклоняется как `invalid_format` до preview.
10. Обычные full/space export и import проходят неизменённые regression
fixtures; normal space document не получает `plan_only: false`.
11. RU/EN тексты синхронны, checkbox доступен с клавиатуры и диалог проходит
narrow-viewport smoke.
12. Typecheck, unit и build зелёные; targeted backend import/export tests
зелёные в Linux CI.
13. Targeted golden подтверждает export dialog и plan-only preview; diff
просмотрен человеком и не имеет непреднамеренных изменений.
14. Все обязательные executable mutants из §12.3 действительно делают
соответствующий guard красным.
15. Оба changelog и обе пользовательские инструкции обновлены в том же
пользовательском коммите.
## 12. План автотестов
### 12.1. Backend unit/integration
Расширить `tests_backend/test_ha_import_export.py`:
- export normal space с фиксированным временем — прежний fixture без нового
поля;
- plan-only projection полного representative space со всеми типами geometry,
real/virtual marker layout, safe room-label layout, room/opening bindings,
modern и legacy live text;
- preserve static names/text/URLs/content owner и drop marker attachments;
- reject `plan_only=true` для full;
- reject non-boolean plan_only;
- reject forged plan-only files по одному для marker, чужого/невалидного
room-label layout/placement/scale, room area,
temp/hum, opening refs, legacy decor refs и inline token;
- preview/revalidate/apply happy path на same и foreign instance;
- missing internal backdrop + detach confirmation по действующему контракту;
- capacity, revision conflict, expired/foreign token regressions;
- ordinary full/space fixtures без изменений.
Полный HA harness канонически выполняется в Linux CI: Windows-путь блокируется
зависимостью `fcntl` и не является локальным release gate.
### 12.2. Frontend unit, smoke и golden
- unit: export dialog state по умолчанию, reset при Full, request payload;
- smoke: checkbox видим только для Current space, keyboard change и narrow
viewport;
- import smoke: `plan_only` line есть, duplicate controls отсутствуют;
- RU/EN i18n parity;
- добавить/обновить deterministic golden scenarios для export dialog с
включённой опцией и import preview; после слитого #166 поднять жёстко
проверяемый `GOLDEN_MATRIX_VERSION` с 23 до 24;
- review actual/expected/diff до принятия baseline.
В implementation loop выполняются только действующие быстрые gates:
`typecheck`, `unit`, `build`. Golden и smoke — перед бетой по runbook.
### 12.3. Executable mutation gate
Mutation harness обязан временно внести каждую поломку, запустить названный
guard, получить non-zero и восстановить файл:
1. оставить один inline live token либо `room.area` в проекции — backend
plan-only privacy test падает;
2. проигнорировать `plan_only` и вернуть marker либо не-room-label layout —
projection/roundtrip test падает;
3. прогнать normal space export через lossy projection или записать
`plan_only: false` — fixed ordinary-export regression падает;
4. не перенести `plan_only` через preview/revalidate или не показать строку —
backend preview test либо frontend smoke падает;
5. отключить строгую проверку forged plan-only документа — negative parser
test падает.
Gate считается доказанным только если лог содержит имя каждого mutant,
ожидаемый guard и зафиксированный non-zero exit; простой список будущих
мутантов acceptance criterion не выполняет.
## 13. Риски и меры
| Риск | Мера |
|---|---|
| Новый HA-binding field утечёт в файл | allowlist projection + fail-closed parser + mutation gate |
| Очистка затронет обычный export | отдельная true-ветка и fixed regression обычного файла |
| Live text потеряет полезную подпись | заменять только token, сохранять окружающий static text |
| Пользователь сочтёт файл полностью анонимным | нейтральный hint точно говорит «без устройств и HA-привязок», документация перечисляет сохраняемые данные |
| Backdrop не откроется на другом instance | действующие content preview/detach правила #50, без ложного обещания embed |
| Frontend и backend расходятся в понимании token | единые fixtures grammar и forged-file tests |
| Новый checkbox ломает узкий диалог | narrow-viewport smoke + golden review |
Производительность: проекция и проверка линейны по размеру одного пространства,
не выполняются в render loop и не меняют View performance budget.
Security: доступ остаётся только у `may_write`; backend не обращается к
entity states или сети. Режим уменьшает объём структурных HA-данных, но не
является средством анонимизации пользовательского текста.
## 14. Rollback
Откат — удалить UI option и обработку true на export endpoint. Сохранённые
server config/layout не менялись, поэтому миграция назад не нужна. Уже созданный
plan-only файл остаётся структурно обычным space export с нулём маркеров и
может быть импортирован по базовому контракту #50; additive metadata безопасно
игнорируется совместимой версией.
Если реализация не может доказать отсутствие известных HA-привязок, режим не
выпускается частично: обычный экспорт #50 остаётся доступен.
## 15. Release-артефакты
Задача пользовательская (`User-Visible: yes`). В том же продуктовом коммите
обязательны:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
- раздел экспорта/импорта в `docs/USER-GUIDE.md` и
`docs/USER-GUIDE.ru.md`, включая точную privacy-границу;
- при необходимости `docs/CONFIG-COMPATIBILITY.md` и архитектурное описание
additive `transfer.plan_only`;
- deterministic golden actual/expected/diff и обновлённая golden matrix;
- smoke/mutation logs согласно принятому тестовому контракту;
- перед бетой — golden, smoke и performance gates по runbook;
- terminal commit trailers `Issue: #167` и `User-Visible: yes`.
Push ветки выполняется после задачи; issue не закрывается до пакетного выпуска
беты. Перевод в `S4-spec-review` в рамках этого шага не выполняется.
## 16. Принятые предположения
1. `plan_only` — optional additive metadata внутри существующего export
version, а не новый kind или новая версия формата.
2. Безопасные ручные позиции и конечный масштаб `k` подписей комнат
`rl_<room_id>` сохраняются и remap-ятся; весь остальной layout удаляется.
3. Реальные и виртуальные markers удаляются одинаково.
4. Геометрический `flip_h|flip_v` сохраняется, contact-specific `invert`
удаляется вместе с binding.
5. Неизвестные поля в plan-only проекцию автоматически не попадают; обычный
export остаётся lossless.
6. Privacy invariant относится к структурным HA-полям и валидным live tokens,
но не к произвольным пользовательским строкам.
7. Новый informational label/hint допустим; отдельное предупреждение или новое
подтверждение по решению владельца запрещено.