mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.46.4: detached plans were being collected as garbage — data loss
Deployed v1.46.3 to my own instance, restarted, and the startup sweep deleted
both floor plans: config/houseplan/plans/ went from f1.svg + f2.png to empty.
The backup is a SecureTar, so they are gone.
The rule was wrong, not the code. v1.46.0 introduced collection that treats
'nothing references this right now' as abandoned and gives it an hour. But
detaching a plan — switching a space to 'draw' — is a normal, reversible action,
and the editor's own comment says the file stays on disk. Those two plans had
been detached for weeks; every pass since v1.46.0 was entitled to remove them,
and the one that finally ran did.
New rule, one for every path:
* superseded by a commit (was in the old revision, is not in the new) — goes
immediately; that is the one thing a commit knows for certain;
* belongs to a space or marker that still exists — never collected, at any
age, because unreferenced is not abandoned;
* a per-dialog staging folder (up_*) — one hour, unchanged: by construction it
only ever holds an upload from a dialog that was never saved;
* anything else — thirty days.
The flag I added an hour ago is gone with it: two rules for the same
question is how this happened. Tests updated to the new grace, plus two that pin
the distinction directly.
I am sorry about the files.
This commit is contained in:
@@ -132,6 +132,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
|||||||
|
|
||||||
def _collect() -> int:
|
def _collect() -> int:
|
||||||
n = sweep_upload_temps(files_dir)
|
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_attachments(files_dir, cfg, cfg)
|
||||||
n += collect_plans(plans_dir, cfg, cfg)
|
n += collect_plans(plans_dir, cfg, cfg)
|
||||||
return n
|
return n
|
||||||
|
|||||||
@@ -22,9 +22,19 @@ MAX_SIGN_PATHS = 200
|
|||||||
# seconds ago may belong to another client's transaction that has not written
|
# seconds ago may belong to another client's transaction that has not written
|
||||||
# its configuration yet (review R3-1).
|
# its configuration yet (review R3-1).
|
||||||
PLAN_ORPHAN_TTL_S = 3600
|
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"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.46.3"
|
VERSION = "1.46.4"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
@@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
|
|||||||
</button>`}
|
</button>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`}}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","");
|
</div>`}}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","");
|
||||||
|
|||||||
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.46.3"
|
"version": "1.46.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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
|
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -122,14 +122,20 @@ def collect_attachments(
|
|||||||
"""The same commit-scoped rule as `collect_plans`, for marker 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
|
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
|
by this commit and goes. Otherwise: files of a marker that still exists are
|
||||||
never saved — a cancelled dialog, a rejected write — and waits out
|
left alone, a staging folder (`up_*`, only ever a dialog that was never
|
||||||
PLAN_ORPHAN_TTL_S first, because a fresh one may belong to a dialog the user
|
saved) is collected after PLAN_ORPHAN_TTL_S, and anything else waits out
|
||||||
still has open. Never raises: it runs behind a durable write.
|
SCHEDULED_GRACE_S. Never raises: it runs behind a durable write.
|
||||||
"""
|
"""
|
||||||
new_refs = attachment_refs(new_cfg)
|
new_refs = attachment_refs(new_cfg)
|
||||||
old_refs = attachment_refs(old_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
|
removed = 0
|
||||||
try:
|
try:
|
||||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
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
|
return 0
|
||||||
removed += sweep_upload_temps(files_dir, now)
|
removed += sweep_upload_temps(files_dir, now)
|
||||||
for folder in folders:
|
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:
|
try:
|
||||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -146,11 +157,11 @@ def collect_attachments(
|
|||||||
rel = f"{folder.name}/{item.name}"
|
rel = f"{folder.name}/{item.name}"
|
||||||
if rel in new_refs:
|
if rel in new_refs:
|
||||||
continue
|
continue
|
||||||
|
if rel not in old_refs: # not superseded: absence alone is weak evidence
|
||||||
try:
|
try:
|
||||||
stale = item.stat().st_mtime < cutoff
|
if item.stat().st_mtime >= limit:
|
||||||
|
continue
|
||||||
except OSError:
|
except OSError:
|
||||||
stale = False
|
|
||||||
if rel not in old_refs and not stale:
|
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
item.unlink()
|
item.unlink()
|
||||||
@@ -220,7 +231,13 @@ def collect_plans(
|
|||||||
"""
|
"""
|
||||||
new_refs = plan_refs(new_cfg)
|
new_refs = plan_refs(new_cfg)
|
||||||
old_refs = plan_refs(old_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
|
removed = 0
|
||||||
try:
|
try:
|
||||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||||
@@ -234,11 +251,13 @@ def collect_plans(
|
|||||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||||
continue
|
continue
|
||||||
superseded = item.name in old_refs
|
superseded = item.name in old_refs
|
||||||
|
if not superseded:
|
||||||
|
if item.name.split(".")[0] in live_spaces:
|
||||||
|
continue # detached, not abandoned — the space is still there
|
||||||
try:
|
try:
|
||||||
stale = item.stat().st_mtime < cutoff
|
if item.stat().st_mtime >= cutoff:
|
||||||
|
continue
|
||||||
except OSError:
|
except OSError:
|
||||||
stale = False
|
|
||||||
if not superseded and not stale:
|
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
item.unlink()
|
item.unlink()
|
||||||
|
|||||||
@@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
|
|||||||
</button>`}
|
</button>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`}}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","");
|
</div>`}}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","");
|
||||||
|
|||||||
Vendored
+1
-1
@@ -2561,4 +2561,4 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
|
|||||||
</button>`}
|
</button>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`}}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","");
|
</div>`}}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","");
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
write is accepted — the same copy → save → cleanup order as a rebind.
|
||||||
`config/set` collects what its commit superseded, and anything unreferenced and
|
`config/set` collects what its commit superseded — and only that, because
|
||||||
older than `PLAN_ORPHAN_TTL_S`.
|
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
|
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
|
||||||
single promise: one `config/set` in flight, each carrying the revision the
|
single promise: one `config/set` in flight, each carrying the revision the
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# 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)
|
## 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
|
- **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
|
data up by domain, and during startup Home Assistant does not yet consider
|
||||||
|
|||||||
@@ -6,6 +6,23 @@
|
|||||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
> изменением — как и остальная документация (см. 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)
|
## v1.46.3 — 2026-07-28 (перепроверка v1.46.2: HP-1462-01)
|
||||||
- **Уборка при старте действительно убирает.** Она искала свои же runtime-данные
|
- **Уборка при старте действительно убирает.** Она искала свои же runtime-данные
|
||||||
по домену, а во время запуска Home Assistant ещё не считает интеграцию
|
по домену, а во время запуска Home Assistant ещё не считает интеграцию
|
||||||
|
|||||||
+2
-2
@@ -15,12 +15,12 @@
|
|||||||
|
|
||||||
| Item | State |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
@@ -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
|
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
|
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
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,
|
- [ ] 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
|
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
|
else — the file is gone after a restart AND after the daily pass, while
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.46.3",
|
"version": "1.46.4",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import './space-card';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
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_KEY = 'houseplan_card_layout_v1';
|
||||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
|
|||||||
@@ -860,9 +860,9 @@ async def _seed_aged(hass, names) -> None:
|
|||||||
import os
|
import os
|
||||||
import time
|
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:
|
def _do() -> None:
|
||||||
for path in names:
|
for path in names:
|
||||||
@@ -885,7 +885,7 @@ def _paths(hass):
|
|||||||
"kept_file": os.path.join(files, "m5", "kept.pdf"),
|
"kept_file": os.path.join(files, "m5", "kept.pdf"),
|
||||||
"kept_plan": os.path.join(plans, "s5.tok.png"),
|
"kept_plan": os.path.join(plans, "s5.tok.png"),
|
||||||
"orphan_file": os.path.join(files, "up_cancelled", "manual.pdf"),
|
"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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
def test_collect_plans_takes_an_aged_orphan(tmp_path):
|
||||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
|
||||||
collect_plans = plans.collect_plans
|
collect_plans = plans.collect_plans
|
||||||
|
|
||||||
d = _plans(tmp_path, ["f1.keep.png"])
|
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"))
|
removed = collect_plans(d, _cfg("/p/f1.keep.png"), _cfg("/p/f1.keep.png"))
|
||||||
assert removed == 1
|
assert removed == 1
|
||||||
assert (d / "f1.keep.png").is_file() and not (d / "f1.abandoned.png").exists()
|
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" / "new.pdf").is_file()
|
||||||
assert (files / "m1" / "cancelled.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))
|
os.utime(files / "m1" / "cancelled.pdf", (old, old))
|
||||||
assert collect_attachments(files, _acfg("m1/new.pdf"), _acfg("m1/new.pdf")) == 1
|
assert collect_attachments(files, _acfg("m1/new.pdf"), _acfg("m1/new.pdf")) == 1
|
||||||
assert not (files / "m1" / "cancelled.pdf").exists()
|
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,
|
"segments": [[1, 2, 3, 4]] * 100000,
|
||||||
})
|
})
|
||||||
assert "segments" not in out
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user