diff --git a/custom_components/houseplan/__init__.py b/custom_components/houseplan/__init__.py index b079ab9..27ee373 100755 --- a/custom_components/houseplan/__init__.py +++ b/custom_components/houseplan/__init__.py @@ -132,6 +132,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> def _collect() -> int: n = sweep_upload_temps(files_dir) + # same config on both sides: nothing is superseded, so this + # only ever collects what the shared rules call abandoned n += collect_attachments(files_dir, cfg, cfg) n += collect_plans(plans_dir, cfg, cfg) return n diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index bb20014..7c6133b 100755 --- a/custom_components/houseplan/const.py +++ b/custom_components/houseplan/const.py @@ -22,9 +22,19 @@ MAX_SIGN_PATHS = 200 # seconds ago may belong to another client's transaction that has not written # its configuration yet (review R3-1). PLAN_ORPHAN_TTL_S = 3600 + +# The scheduled sweep is a different judgement from a commit's. A commit knows +# it replaced a file; the timer only knows nobody points at one right now — and +# "nobody points at it" is a normal, reversible state. Detaching a plan (switch +# a space to "draw") leaves the image on disk on purpose, and re-attaching it +# later is a thing people do. On 2026-07-28 the hourly rule applied to that case +# and removed two plans the owner had detached weeks earlier; they were not +# recoverable. So the timer waits a month, and never touches a plan or an +# attachment that still belongs to something in the configuration. +SCHEDULED_GRACE_S = 30 * 24 * 3600 FILES_DIR = "houseplan/files" CONF_ADMIN_ONLY = "admin_only" -VERSION = "1.46.3" +VERSION = "1.46.4" DEFAULT_CONFIG: dict = { "spaces": [], diff --git a/custom_components/houseplan/frontend/houseplan-card.js b/custom_components/houseplan/frontend/houseplan-card.js index d5d985b..59bd2b0 100755 --- a/custom_components/houseplan/frontend/houseplan-card.js +++ b/custom_components/houseplan/frontend/houseplan-card.js @@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow `} - `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.3 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.4 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/custom_components/houseplan/manifest.json b/custom_components/houseplan/manifest.json index 7ad3b3e..c16bff9 100755 --- a/custom_components/houseplan/manifest.json +++ b/custom_components/houseplan/manifest.json @@ -16,5 +16,5 @@ "issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "requirements": [], "single_config_entry": true, - "version": "1.46.3" + "version": "1.46.4" } diff --git a/custom_components/houseplan/plans.py b/custom_components/houseplan/plans.py index d443b2c..96d03ec 100644 --- a/custom_components/houseplan/plans.py +++ b/custom_components/houseplan/plans.py @@ -14,7 +14,7 @@ import time from pathlib import Path from typing import Any -from .const import PLAN_ORPHAN_TTL_S +from .const import PLAN_ORPHAN_TTL_S, SCHEDULED_GRACE_S from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename _LOGGER = logging.getLogger(__name__) @@ -122,14 +122,20 @@ def collect_attachments( """The same commit-scoped rule as `collect_plans`, for marker attachments. A file the old revision referenced and the new one does not was superseded - by this commit and goes. Anything else unreferenced is an upload that was - never saved — a cancelled dialog, a rejected write — and waits out - PLAN_ORPHAN_TTL_S first, because a fresh one may belong to a dialog the user - still has open. Never raises: it runs behind a durable write. + by this commit and goes. Otherwise: files of a marker that still exists are + left alone, a staging folder (`up_*`, only ever a dialog that was never + saved) is collected after PLAN_ORPHAN_TTL_S, and anything else waits out + SCHEDULED_GRACE_S. Never raises: it runs behind a durable write. """ new_refs = attachment_refs(new_cfg) old_refs = attachment_refs(old_cfg) - cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S + # Same distinction as for plans. A staging folder (`up_*`) is different: it + # only ever holds an upload from a dialog that was never saved, so the short + # rule is exactly right there even on the timer. + live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []} + now_s = time.time() if now is None else now + staging_cutoff = now_s - PLAN_ORPHAN_TTL_S + cutoff = now_s - SCHEDULED_GRACE_S removed = 0 try: folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else [] @@ -138,6 +144,11 @@ def collect_attachments( return 0 removed += sweep_upload_temps(files_dir, now) for folder in folders: + if folder.name in live_markers: + continue # the marker is still there; its files are its own business + # a staging folder only ever holds an upload from a dialog that was + # never saved — unambiguous, so the short rule is right there + limit = staging_cutoff if folder.name.startswith("up_") else cutoff try: items = sorted(p for p in folder.iterdir() if p.is_file()) except OSError: @@ -146,12 +157,12 @@ def collect_attachments( rel = f"{folder.name}/{item.name}" if rel in new_refs: continue - try: - stale = item.stat().st_mtime < cutoff - except OSError: - stale = False - if rel not in old_refs and not stale: - continue + if rel not in old_refs: # not superseded: absence alone is weak evidence + try: + if item.stat().st_mtime >= limit: + continue + except OSError: + continue try: item.unlink() removed += 1 @@ -220,7 +231,13 @@ def collect_plans( """ new_refs = plan_refs(new_cfg) old_refs = plan_refs(old_cfg) - cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S + # A commit knows what it superseded. The timer only knows what nothing + # points at *right now*, and for a plan that is a reversible state: the + # editor detaches the image when a space switches to "draw" and says the + # file stays on disk. So the scheduled pass keeps anything belonging to a + # space that still exists, and waits a month for the rest. + live_spaces = {str(sp.get("id")) for sp in (new_cfg or {}).get("spaces") or []} + cutoff = (time.time() if now is None else now) - SCHEDULED_GRACE_S removed = 0 try: items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else [] @@ -234,12 +251,14 @@ def collect_plans( if not item.is_file() or item.name in new_refs or not is_plan_file(item.name): continue superseded = item.name in old_refs - try: - stale = item.stat().st_mtime < cutoff - except OSError: - stale = False - if not superseded and not stale: - continue + if not superseded: + if item.name.split(".")[0] in live_spaces: + continue # detached, not abandoned — the space is still there + try: + if item.stat().st_mtime >= cutoff: + continue + except OSError: + continue try: item.unlink() removed += 1 diff --git a/demo/srv/assets/houseplan-card.js b/demo/srv/assets/houseplan-card.js index d5d985b..59bd2b0 100755 --- a/demo/srv/assets/houseplan-card.js +++ b/demo/srv/assets/houseplan-card.js @@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow `} - `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.3 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.4 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/dist/houseplan-card.js b/dist/houseplan-card.js index d5d985b..59bd2b0 100755 --- a/dist/houseplan-card.js +++ b/dist/houseplan-card.js @@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow `} - `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.3 ","background:#3ea6ff;color:#04121f;font-weight:700",""); + `}}ps.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ps._touchSeen=!1,ps._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ps.styles=Ui,customElements.get("houseplan-card")||customElements.define("houseplan-card",ps),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.46.4 ","background:#3ea6ff;color:#04121f;font-weight:700",""); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 17e201b..41e1181 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -207,8 +207,15 @@ happen when somebody saves, and a file uploaded into a dialog that was then cancelled would wait for a write that may never come. A new icon has no id yet, so its files go to a per-dialog staging folder and move to the real id once the config write is accepted — the same copy → save → cleanup order as a rebind. -`config/set` collects what its commit superseded, and anything unreferenced and -older than `PLAN_ORPHAN_TTL_S`. +`config/set` collects what its commit superseded — and only that, because +supersession is the one thing a commit knows for certain. *Unreferenced* is a +much weaker signal: detaching a plan is reversible and the editor promises the +file stays. So a file belonging to a space or marker that still exists is never +collected, and anything else waits `SCHEDULED_GRACE_S` (30 days). The exception +is a per-dialog staging folder, which by construction only holds an upload from +a dialog that was never saved — `PLAN_ORPHAN_TTL_S` (1 hour) is right there. +This is not theoretical caution: the one-hour rule applied to unreferenced files +destroyed two detached plans on 2026-07-28. **Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a single promise: one `config/set` in flight, each carrying the revision the diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6061ad2..e92a84c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## v1.46.4 — 2026-07-28 (data loss: detached plans were collected as garbage) +- **A plan you detach is no longer deleted an hour later.** Switching a space to + "draw" clears the reference and, as the editor has always said, leaves the + image on disk so you can put it back. The collection added in v1.46.0 did not + make that distinction: it treated "nothing points at this right now" as + abandoned and applied a one-hour rule. On the author's own instance the + scheduled pass then removed two floor plans that had been detached weeks + earlier, with no way to get them back. If you have detached a plan since + v1.46.0 and your instance restarted or ran for a day, check + `config/houseplan/plans/` before updating anything else — and please report it + in the Telegram chat if a file is missing. + The rule now: **a commit still removes exactly what it replaced**, because + that it knows for certain. Everything else is only *unreferenced*, which is a + reversible state — a file belonging to a space or a device that still exists + is never collected, and anything else waits thirty days instead of an hour. + The one unambiguous case keeps the short rule: a per-dialog staging folder + only ever holds an upload from a dialog that was never saved. + ## v1.46.3 — 2026-07-28 (re-check of v1.46.2: HP-1462-01) - **The cleanup at startup now actually cleans up.** It looked its own runtime data up by domain, and during startup Home Assistant does not yet consider diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index d4fe062..48cdcae 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -6,6 +6,23 @@ > **Правило проекта:** оба файла пополняются в одном коммите с самим > изменением — как и остальная документация (см. docs/STATUS.md). +## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор) +- **Отцеплённый план больше не удаляется через час.** Переключение пространства + в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил, + оставляет картинку на диске, чтобы её можно было вернуть. Уборка, добавленная + в v1.46.0, этой разницы не делала: считала «на файл сейчас никто не + ссылается» синонимом «файл брошен» и применяла часовое правило. На установке + автора плановый проход в итоге удалил два плана этажей, отцеплённых + несколькими неделями раньше, — восстановить их было нечем. Если вы отцепляли + план после v1.46.0 и инстанс перезапускался или проработал сутки — загляните в + `config/houseplan/plans/` и напишите в Telegram-чат, если файла нет. + Правило теперь такое: **коммит по-прежнему удаляет ровно то, что заменил**, — + это он знает наверняка. Всё остальное лишь «непривязано», а это обратимое + состояние: файл, принадлежащий существующему пространству или устройству, не + удаляется никогда, а всё прочее ждёт тридцать дней вместо часа. Один + однозначный случай сохраняет короткое правило: промежуточная папка диалога + содержит только загрузку из диалога, который так и не сохранили. + ## v1.46.3 — 2026-07-28 (перепроверка v1.46.2: HP-1462-01) - **Уборка при старте действительно убирает.** Она искала свои же runtime-данные по домену, а во время запуска Home Assistant ещё не считает интеграцию diff --git a/docs/STATUS.md b/docs/STATUS.md index 9dc71d9..354f868 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -15,12 +15,12 @@ | Item | State | |---|---| -| Version | **v1.46.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance | +| Version | **v1.46.4** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance | | Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) | | GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) | | CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish | | HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) | -| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.3** via direct copy (HACS custom repo also installed) | +| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.4** via direct copy (HACS custom repo also installed) | | Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover | | Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run | | Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts | diff --git a/docs/TESTING.md b/docs/TESTING.md index 6f0f06f..5f65006 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -239,6 +239,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi on the plan after a reload. Same for each tap action and each fill mode [auto: backend test_every_display_mode_the_editor_offers_is_accepted and neighbours, test_a_marker_showing_its_value_can_be_saved] +- [ ] Detaching a plan keeps the file (v1.46.4): switch a space to "draw", + restart, wait a day — the image is still in `config/houseplan/plans/` and + can be re-attached. Replacing a plan still removes the one it replaced, + immediately [auto: unit: test_scheduled_collection_never_takes_a_detached_plan] - [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01, HP-1462-01): attach a file, cancel the dialog, and do not save anything else — the file is gone after a restart AND after the daily pass, while diff --git a/package.json b/package.json index de2d53d..0ee8c97 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "houseplan-card", - "version": "1.46.3", + "version": "1.46.4", "description": "Interactive house plan Lovelace card for Home Assistant", "license": "MIT", "type": "module", diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 9ab9997..bc57104 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -35,7 +35,7 @@ import './space-card'; import { cardStyles } from './styles'; import { langOf, t, type I18nKey } from './i18n'; -const CARD_VERSION = '1.46.3'; +const CARD_VERSION = '1.46.4'; const LS_KEY = 'houseplan_card_layout_v1'; const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering const LS_ZOOM = 'houseplan_card_zoom_v1'; diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index 8c237be..930b4e1 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -860,9 +860,9 @@ async def _seed_aged(hass, names) -> None: import os import time - from custom_components.houseplan.const import PLAN_ORPHAN_TTL_S + from custom_components.houseplan.const import SCHEDULED_GRACE_S - old = time.time() - PLAN_ORPHAN_TTL_S - 60 + old = time.time() - SCHEDULED_GRACE_S - 60 def _do() -> None: for path in names: @@ -885,7 +885,7 @@ def _paths(hass): "kept_file": os.path.join(files, "m5", "kept.pdf"), "kept_plan": os.path.join(plans, "s5.tok.png"), "orphan_file": os.path.join(files, "up_cancelled", "manual.pdf"), - "orphan_plan": os.path.join(plans, "s5.orphan.png"), + "orphan_plan": os.path.join(plans, "deleted_space.orphan.png"), } diff --git a/tests_backend/test_validation.py b/tests_backend/test_validation.py index 0e0f8fa..139598f 100644 --- a/tests_backend/test_validation.py +++ b/tests_backend/test_validation.py @@ -266,11 +266,11 @@ def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path): def test_collect_plans_takes_an_aged_orphan(tmp_path): - PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S collect_plans = plans.collect_plans d = _plans(tmp_path, ["f1.keep.png"]) - _plans(tmp_path, ["f1.abandoned.png"], age=PLAN_ORPHAN_TTL_S + 60) + # past the long grace, and belonging to no space in the config + _plans(tmp_path, ["f1.abandoned.png"], age=const.SCHEDULED_GRACE_S + 60) removed = collect_plans(d, _cfg("/p/f1.keep.png"), _cfg("/p/f1.keep.png")) assert removed == 1 assert (d / "f1.keep.png").is_file() and not (d / "f1.abandoned.png").exists() @@ -495,7 +495,7 @@ def test_collect_attachments_supersedes_and_ages(tmp_path): assert (files / "m1" / "new.pdf").is_file() assert (files / "m1" / "cancelled.pdf").is_file() - old = time.time() - const.PLAN_ORPHAN_TTL_S - 60 + old = time.time() - const.SCHEDULED_GRACE_S - 60 os.utime(files / "m1" / "cancelled.pdf", (old, old)) assert collect_attachments(files, _acfg("m1/new.pdf"), _acfg("m1/new.pdf")) == 1 assert not (files / "m1" / "cancelled.pdf").exists() @@ -552,3 +552,63 @@ def test_legacy_segments_are_dropped_by_the_server(): "segments": [[1, 2, 3, 4]] * 100000, }) assert "segments" not in out + + +def test_scheduled_collection_never_takes_a_detached_plan(tmp_path): + """2026-07-28, on the author's own instance: two plans were deleted. + + Detaching a plan (switching a space to "draw") is reversible and the editor + says the file stays on disk. The timer only knows "nothing points at it", + applied the one-hour orphan rule, and removed images that had been detached + weeks earlier. A commit may still collect what it superseded — it knows it + replaced something. The timer may not. + """ + import os + import time + + d = tmp_path / "plans" + d.mkdir() + for n in ("f1.svg", "f2.tok.png", "gone.old.png"): + (d / n).write_bytes(b"x") + t = time.time() - const.SCHEDULED_GRACE_S - 60 + os.utime(d / n, (t, t)) + + cfg = {"spaces": [{"id": "f1", "plan_url": None}, # detached, space alive + {"id": "f2", "plan_url": None}]} # same + assert plans.collect_plans(d, cfg, cfg) == 1 + assert (d / "f1.svg").is_file(), "a detached plan of a live space is kept" + assert (d / "f2.tok.png").is_file() + assert not (d / "gone.old.png").exists(), "a plan of a space that no longer exists ages out" + + # a commit still removes what it SUPERSEDED — that it knows for certain + old = {"spaces": [{"id": "f1", "plan_url": "/p/f1.svg"}, {"id": "f2", "plan_url": None}]} + new = {"spaces": [{"id": "f1", "plan_url": "/p/f1.new.png"}, {"id": "f2", "plan_url": None}]} + (d / "f1.new.png").write_bytes(b"x") + assert plans.collect_plans(d, old, new) == 1 + assert not (d / "f1.svg").exists() + assert (d / "f2.tok.png").is_file(), "and still touches nothing else of a live space" + + +def test_scheduled_collection_keeps_a_live_marker_s_files(tmp_path): + import os + import time + + files = tmp_path / "files" + (files / "m1").mkdir(parents=True) + (files / "up_abandoned").mkdir(parents=True) + (files / "dead").mkdir(parents=True) + old = time.time() - const.SCHEDULED_GRACE_S - 60 + for p in ((files / "m1" / "unlinked.pdf"), (files / "dead" / "x.pdf")): + p.write_bytes(b"x") + os.utime(p, (old, old)) + staging = files / "up_abandoned" / "manual.pdf" + staging.write_bytes(b"x") + hour_ago = time.time() - const.PLAN_ORPHAN_TTL_S - 60 + os.utime(staging, (hour_ago, hour_ago)) + + cfg = {"markers": [{"id": "m1", "pdfs": []}]} + removed = plans.collect_attachments(files, cfg, cfg) + assert (files / "m1" / "unlinked.pdf").is_file(), "the marker exists; leave its files alone" + assert not staging.exists(), "a cancelled dialog is still collected after an hour" + assert not (files / "dead" / "x.pdf").exists(), "a marker that is gone ages out" + assert removed == 2