diff --git a/docs/specs/167-plan-only-export.md b/docs/specs/167-plan-only-export.md new file mode 100644 index 00000000..f3159fd5 --- /dev/null +++ b/docs/specs/167-plan-only-export.md @@ -0,0 +1,471 @@ +# 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. Что человек увидит до и после + +**До:** экспорт текущего пространства всегда содержит его маркеры, layout и +HA-привязки. Для чистого переноса пользователь должен вручную редактировать +JSON, рискуя повредить структуру или случайно оставить идентификаторы. + +**После:** рядом с выбором текущего пространства доступен выключенный по +умолчанию флажок «Plan only». В этом режиме файл сохраняет переносимую +планировку, но не содержит маркеров, 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 layout и структурных HA-привязок; +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; +- создание 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`, `layout = 0`, device/entity/virtual bindings = 0; +- не показывает 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": {} + }, + "placement_manifest": [], + "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` | `{}`; удаляются все позиции, включая room labels | +| `placement_manifest` | `[]` | +| 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` | не переносятся | + +`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 == {}`, `placement_manifest == []`; +- отсутствие 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 не добавляет маркеры и layout, а комнаты остаются 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: []`, `layout: {}`, + empty placement manifest и `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 создаёт новое пространство + с той же планировкой, нулём устройств/позиций/привязок и unbound rooms. +8. Preview и revalidate явно сохраняют `plan_only: true`, показывают нулевые + binding counts и не предлагают duplicate policy. +9. File с true, но с маркером, layout или известной 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, + marker/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, layout, 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, поднять версию golden matrix; +- 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/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. Геометрический `flip_h|flip_v` сохраняется, contact-specific `invert` + удаляется вместе с binding. +3. Неизвестные поля в plan-only проекцию автоматически не попадают; обычный + export остаётся lossless. +4. Privacy invariant относится к структурным HA-полям и валидным live tokens, + но не к произвольным пользовательским строкам. +5. Новый informational label/hint допустим; отдельное предупреждение или новое + подтверждение по решению владельца запрещено. diff --git a/docs/specs/README.md b/docs/specs/README.md index 946277ce..9874890c 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -1,6 +1,6 @@ # Спецификации задач P1 и P2 -Актуально на 2026-08-14. +Актуально на 2026-08-17. GitHub Issues и GitHub Projects (v2) остаются единственным каноническим backlog проекта. Этот каталог содержит развёрнутые ТЗ: каждое ТЗ ссылается на issue, а issue — на соответствующий файл. Статус, приоритет и факт завершения меняются только в GitHub. @@ -50,6 +50,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#146](https://github.com/Matysh/houseplan-card/issues/146) Четырёхфазный фон «Следует за Солнцем» | [146-four-phase-sun-background.md](146-four-phase-sun-background.md) | | [#156](https://github.com/Matysh/houseplan-card/issues/156) Регрессии Full Performance перед v1.64.0 stable | [156-full-performance-regressions.md](156-full-performance-regressions.md) | | [#164](https://github.com/Matysh/houseplan-card/issues/164) Активный цикл стиральной машины должен быть жёлтым | [164-washer-active-cycle.md](164-washer-active-cycle.md) | +| [#167](https://github.com/Matysh/houseplan-card/issues/167) Экспорт «только планировка» | [167-plan-only-export.md](167-plan-only-export.md) | ## P2