mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7fe63776a | ||
|
|
01bc4f9711 | ||
|
|
85491d0fea | ||
|
|
d37a67c29f | ||
|
|
a66272c6f4 | ||
|
|
f4af2fe508 | ||
|
|
8e07e3c958 | ||
|
|
9868f1035f | ||
|
|
f2c9b07cc1 | ||
|
|
2c7a2f849d | ||
|
|
33e71ca96c |
@@ -23,18 +23,15 @@ MAX_SIGN_PATHS = 200
|
||||
# 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.
|
||||
# Kept for compatibility with anything reading it; the collectors no longer use
|
||||
# a long grace at all. Every attempt to age files out ended badly — first by
|
||||
# deleting detached plans, then by racing the save that was about to reference a
|
||||
# retried upload. What is left is deliberately simple: files go when the user's
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.46.4"
|
||||
VERSION = "1.47.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.46.4"
|
||||
"version": "1.47.0"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import PLAN_ORPHAN_TTL_S, SCHEDULED_GRACE_S
|
||||
from .const import PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -121,20 +121,25 @@ def collect_attachments(
|
||||
) -> int:
|
||||
"""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. Otherwise 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.
|
||||
A file the old revision referenced and the new one does not, whose marker
|
||||
still exists, was removed on purpose — the dialog has a trash button and
|
||||
promises nothing. It goes. Everything else is kept, except a staging folder
|
||||
(`up_*`), which by construction only ever holds an upload from a dialog that
|
||||
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
|
||||
behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
# Removing an attachment from a device that still exists is the user saying
|
||||
# "drop this one" — a trash button, no promise that anything is kept. A
|
||||
# device that is GONE is a different transition, and its files follow the
|
||||
# same rule as a deleted space's plan: kept.
|
||||
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
|
||||
# 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.
|
||||
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 []
|
||||
@@ -144,10 +149,8 @@ def collect_attachments(
|
||||
removed += sweep_upload_temps(files_dir, now)
|
||||
for folder in folders:
|
||||
# A staging folder only ever holds an upload from a dialog that was never
|
||||
# saved — unambiguous, so an hour is right. A marker's own folder is not:
|
||||
# removing an attachment is deliberate, but so is re-adding one, and the
|
||||
# file may have been detached rather than abandoned. Give it a month.
|
||||
limit = staging_cutoff if folder.name.startswith("up_") else cutoff
|
||||
# saved — unambiguous, so an hour is right, and no device owns it.
|
||||
staging = folder.name.startswith("up_")
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
except OSError:
|
||||
@@ -156,9 +159,16 @@ def collect_attachments(
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
continue
|
||||
if rel not in old_refs: # not superseded: absence alone is weak evidence
|
||||
dropped = rel in old_refs and folder.name in live_markers
|
||||
if not dropped:
|
||||
if not staging:
|
||||
# Same rule as for plans: not asked for, so kept. A file in
|
||||
# a device's folder that the device does not list is an
|
||||
# upload whose save was rejected — and ageing those out
|
||||
# raced the retry that was about to reference them.
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= limit:
|
||||
if item.stat().st_mtime >= staging_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
@@ -196,6 +206,14 @@ def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
return out
|
||||
|
||||
|
||||
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""space id -> the plan file it references ('' when it has none)."""
|
||||
return {
|
||||
str(sp.get("id")): plan_basename(sp.get("plan_url"))
|
||||
for sp in (cfg or {}).get("spaces") or []
|
||||
}
|
||||
|
||||
|
||||
def is_plan_file(name: str) -> bool:
|
||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||
parts = name.split(".")
|
||||
@@ -240,11 +258,19 @@ def collect_plans(
|
||||
# other file of its own is a superseded or rejected upload, so the short
|
||||
# rule is right for those. Getting this distinction wrong (protecting
|
||||
# nothing) destroyed two detached plans on 2026-07-28.
|
||||
detached = {
|
||||
str(sp.get("id")) for sp in (new_cfg or {}).get("spaces") or []
|
||||
if not sp.get("plan_url")
|
||||
# The short rule fits exactly one case: a space that HAS a plan, where any
|
||||
# other file of its own can only be a superseded or rejected upload.
|
||||
old_by_space = plan_by_space(old_cfg)
|
||||
new_by_space = plan_by_space(new_cfg)
|
||||
# A file that left the configuration tells us nothing on its own: replacing a
|
||||
# plan, detaching one and deleting a space all look identical from
|
||||
# `old_refs - new_refs`. Only the first is a deletion the user asked for
|
||||
# (HP-1465-01 — the guards below were written and then never reached,
|
||||
# because the code decided "superseded" before asking why).
|
||||
replaced = {
|
||||
name for space, name in old_by_space.items()
|
||||
if new_by_space.get(space) and new_by_space[space] != name
|
||||
}
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
@@ -257,15 +283,22 @@ def collect_plans(
|
||||
for item in items:
|
||||
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
|
||||
if not superseded:
|
||||
if item.name.split(".")[0] in detached:
|
||||
continue # detached, not abandoned — the space is waiting for it
|
||||
try:
|
||||
if item.stat().st_mtime >= cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
if item.name not in replaced:
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||
# not told to delete is kept, however long it sits there. Detaching
|
||||
# is one click to undo and the editor says the image stays; deleting
|
||||
# a space is deliberate but the image was imported and may be
|
||||
# nowhere else. The errors are not symmetrical — unnecessary
|
||||
# megabytes can be removed by hand, a deleted file cannot be
|
||||
# brought back.
|
||||
#
|
||||
# There is deliberately no age rule here. An earlier version aged
|
||||
# out "rejected uploads" — a file of a space that has a plan, which
|
||||
# was never the plan — and that raced a save: the sweep deleted the
|
||||
# upload from the failed attempt while a retry was committing a
|
||||
# reference to it. A rule that can delete a file somebody is about
|
||||
# to point at is not worth the disk it reclaims.
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
|
||||
@@ -20,11 +20,14 @@ from .const import (
|
||||
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import collect_attachments, collect_plans, reserve_filename
|
||||
from .plans import (
|
||||
collect_attachments, collect_plans, is_plan_file, plan_basename, plan_refs,
|
||||
reserve_filename,
|
||||
)
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,6 +44,8 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
@@ -215,6 +220,100 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/plans/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Plan images on the server, with what still uses them.
|
||||
|
||||
Files are never removed for being unreferenced (docs/SCOPE.md), which only
|
||||
works as a policy if the user can see them: detaching a plan keeps the
|
||||
image, and this is how it gets picked up again — or deleted on purpose.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
used: dict[str, list[str]] = {}
|
||||
for space in cfg.get("spaces") or []:
|
||||
name = plan_basename(space.get("plan_url"))
|
||||
if name:
|
||||
used.setdefault(name, []).append(space.get("title") or space.get("id") or "?")
|
||||
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
|
||||
def _scan() -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
if not plans_dir.is_dir():
|
||||
return out
|
||||
for item in sorted(plans_dir.iterdir()):
|
||||
if not item.is_file() or not is_plan_file(item.name):
|
||||
continue
|
||||
try:
|
||||
st = item.stat()
|
||||
except OSError:
|
||||
continue
|
||||
out.append({
|
||||
"name": item.name,
|
||||
"url": f"{CONTENT_URL}/plans/_/{item.name}",
|
||||
"size": st.st_size,
|
||||
"modified": int(st.st_mtime),
|
||||
"used_by": used.get(item.name, []),
|
||||
})
|
||||
out.sort(key=lambda x: -x["modified"])
|
||||
return out
|
||||
|
||||
connection.send_result(msg["id"], {"plans": await hass.async_add_executor_job(_scan)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plans/delete",
|
||||
vol.Required("name"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plans_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete a plan image because the user asked — the only way one goes.
|
||||
|
||||
Refuses while a space still references it: the answer to "can I delete this"
|
||||
is the stored configuration's, not the client's.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete plans")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
name = sanitize_filename(msg["name"])
|
||||
if not is_plan_file(name):
|
||||
connection.send_error(msg["id"], "invalid_name", "Not a plan file")
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
if name in plan_refs(cfg):
|
||||
connection.send_error(
|
||||
msg["id"], "in_use", "A space still uses this plan — detach it first"
|
||||
)
|
||||
return
|
||||
path = Path(hass.config.path(PLANS_DIR)) / name
|
||||
|
||||
def _rm() -> bool:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not delete %s: %s", path, err)
|
||||
return False
|
||||
|
||||
removed = await hass.async_add_executor_job(_rm)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/content/sign",
|
||||
@@ -263,34 +362,68 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete a marker's file folder — called only AFTER the config is committed."""
|
||||
"""Drop a marker folder's leftovers after its files moved elsewhere.
|
||||
|
||||
Called after a rebind: the files were copied to the new marker id and the
|
||||
config that references them is committed, so the source folder is spent.
|
||||
|
||||
It used to `rmtree` the folder on the client's word alone. Two ways that
|
||||
ends badly: a partial copy leaves some urls still pointing INTO this folder
|
||||
(the migration deliberately does not rewrite those), and a wrong or stale
|
||||
id from any client deletes a live marker's attachments outright. So the
|
||||
server checks for itself — under the config lock — and removes only files
|
||||
the stored configuration does not reference. Same principle as the
|
||||
collector: a client may say what it no longer needs, never what may go.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||
return
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .const import FILES_DIR
|
||||
from .plans import attachment_refs
|
||||
from .validation import sanitize_marker_id
|
||||
|
||||
mid = sanitize_marker_id(msg["marker_id"])
|
||||
if not mid:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": False})
|
||||
return
|
||||
base = Path(hass.config.path(FILES_DIR)).resolve()
|
||||
target = (base / mid).resolve()
|
||||
if not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": False})
|
||||
target = (base / mid).resolve() if mid else base
|
||||
if not mid or not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
|
||||
return
|
||||
|
||||
def _rm() -> bool:
|
||||
if not target.is_dir():
|
||||
return False
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
return True
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = attachment_refs(stored.get("config") or {})
|
||||
|
||||
removed = await hass.async_add_executor_job(_rm)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
def _rm() -> tuple[int, int]:
|
||||
if not target.is_dir():
|
||||
return 0, 0
|
||||
removed = kept = 0
|
||||
for item in sorted(target.iterdir()):
|
||||
if not item.is_file():
|
||||
continue
|
||||
if f"{mid}/{item.name}" in refs:
|
||||
kept += 1
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
|
||||
if not kept:
|
||||
try:
|
||||
target.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return removed, kept
|
||||
|
||||
removed, kept = await hass.async_add_executor_job(_rm)
|
||||
if kept:
|
||||
_LOGGER.info(
|
||||
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
|
||||
)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -446,10 +579,11 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||
# that is later rejected — revision conflict, validation, lost connection —
|
||||
# leaves the stored plan exactly as it was. The card calls
|
||||
# nothing here; the superseded file is collected by `config/set` itself,
|
||||
# inside the write lock, once a revision that no longer references it has
|
||||
# been accepted (review R3-1). A crash in between leaves an orphan, which
|
||||
# the same collector removes on a later commit once it is old enough.
|
||||
# nothing here; the file a commit REPLACES is collected by `config/set`
|
||||
# itself, inside the write lock (review R3-1). An upload that never gets
|
||||
# committed is not collected at all — it is offered back in the space
|
||||
# dialog's "already uploaded" list, where the user can attach or delete it.
|
||||
# Every attempt to age these out ended in data loss or a race (v1.46.4-6).
|
||||
#
|
||||
# `.` separates the id from the token because a space id cannot contain one
|
||||
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// «Уже загруженные»: план, который не удаляется за ненадобностью, обязан быть
|
||||
// находимым. Иначе обещание «отцепил — файл остался» неполноценно: вернуть его
|
||||
// из карточки было нельзя, старый URL нигде не хранится (HP-1466-02).
|
||||
// Заодно это единственный способ удалить план — явным действием.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const base = c.hass.callWS;
|
||||
|
||||
let serverPlans = [
|
||||
{ name: 'f1.aaa.png', url: '/api/houseplan/content/plans/_/f1.aaa.png', size: 121335, modified: 2, used_by: [] },
|
||||
{ name: 'f2.bbb.png', url: '/api/houseplan/content/plans/_/f2.bbb.png', size: 26931, modified: 1, used_by: ['2 этаж'] },
|
||||
];
|
||||
const deleted = [];
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
|
||||
if (m.type === 'houseplan/plans/delete') {
|
||||
const p = serverPlans.find((x) => x.name === m.name);
|
||||
if (p?.used_by.length) { const e = new Error('in_use'); e.code = 'in_use'; throw e; }
|
||||
deleted.push(m.name);
|
||||
serverPlans = serverPlans.filter((x) => x.name !== m.name);
|
||||
return { ok: true, removed: true };
|
||||
}
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
const urls = {}; for (const p of m.paths) urls[p] = p + '?authSig=X'; return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
window.confirm = () => true;
|
||||
|
||||
// пространство без плана — как после отцепления
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// открываем список сохранённых
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c.updateComplete;
|
||||
const rows = [...sr().querySelectorAll('.savedplan')];
|
||||
out.listed = rows.length;
|
||||
out.showsUsage = (rows[1]?.textContent || '').includes('2 этаж');
|
||||
out.deleteDisabledForUsed = !!rows[1]?.querySelector('.btn.danger[disabled]');
|
||||
out.deleteEnabledForFree = !rows[0]?.querySelector('.btn.danger[disabled]');
|
||||
out.thumbnailSigned = (rows[0]?.querySelector('img')?.getAttribute('src') || '').includes('authSig=');
|
||||
|
||||
// выбираем свободный план — он подставляется в диалог
|
||||
c._useServerPlan(serverPlans[0].url);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
|
||||
out.listClosed = !c._spaceDialog.pickSaved;
|
||||
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// удаление: занятый нельзя, свободный можно
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c._deleteServerPlan('f2.bbb.png').catch(() => {});
|
||||
out.usedNotDeleted = !deleted.includes('f2.bbb.png');
|
||||
await c._deleteServerPlan('f1.aaa.png');
|
||||
await c.updateComplete;
|
||||
out.freeDeleted = deleted.includes('f1.aaa.png');
|
||||
out.rowGone = !(c._spaceDialog.saved || []).some((p) => p.name === 'f1.aaa.png');
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.47.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
saveBlockedWithoutPlan: true,
|
||||
listed: 2,
|
||||
showsUsage: true,
|
||||
deleteDisabledForUsed: true,
|
||||
deleteEnabledForFree: true,
|
||||
thumbnailSigned: true,
|
||||
picked: true,
|
||||
listClosed: true,
|
||||
saveEnabledAfterPick: true,
|
||||
usedNotDeleted: true,
|
||||
freeDeleted: true,
|
||||
rowGone: true,
|
||||
});
|
||||
await finish(browser);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+80
-25
File diff suppressed because one or more lines are too long
+32
-10
@@ -178,6 +178,8 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
| `houseplan/config/get` | — | `{config, rev}` |
|
||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}]}` |
|
||||
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
|
||||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||
|
||||
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
|
||||
@@ -208,16 +210,36 @@ cancelled would wait for a write that may never come. A new icon has no id yet,
|
||||
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 — that much a commit knows for
|
||||
certain. *Unreferenced* is a far weaker signal, and how weak depends on the
|
||||
case. A space with `plan_url = null` had its image **detached**, which is
|
||||
reversible and which the editor promises leaves the file alone: those are never
|
||||
collected. A space that does have a plan can only be holding its own rejected
|
||||
uploads, so `PLAN_ORPHAN_TTL_S` (1 hour) still applies to them. For attachments,
|
||||
a per-dialog staging folder holds nothing but an upload from a dialog that was
|
||||
never saved — one hour again — while a marker's own folder waits
|
||||
`SCHEDULED_GRACE_S` (30 days). This is not theoretical caution: applying the
|
||||
one-hour rule to every unreferenced file destroyed two detached plans on
|
||||
2026-07-28.
|
||||
certain. *Unreferenced* is a far weaker signal, and the policy follows from one
|
||||
asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
|
||||
we should not have removed cannot be brought back.** When the evidence is weak,
|
||||
keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
|
||||
every unreferenced file destroyed two detached plans.
|
||||
|
||||
The classification is by **owner**, not by "is it referenced". A file leaving
|
||||
the configuration looks identical whether the plan was replaced, detached, or
|
||||
its space deleted — and only the first is a deletion the user asked for. Reading
|
||||
`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it
|
||||
was detached, under documentation promising the opposite (HP-1465-01).
|
||||
|
||||
| Case | What it means | Rule |
|
||||
|---|---|---|
|
||||
| Space in both, plan A → plan B | the user picked another image | removed immediately |
|
||||
| Space in both, plan → none | detached; one click undoes it | **kept** |
|
||||
| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** |
|
||||
| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them |
|
||||
| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately |
|
||||
| Marker gone | same call as a deleted space's plan | **kept** |
|
||||
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
|
||||
| Marker there, file it never listed | a rejected upload | **kept**, same reason |
|
||||
|
||||
Nothing is deleted for being old, with one exception: a per-dialog staging
|
||||
folder (`up_*`), which by construction can only hold an upload from a dialog
|
||||
that was never saved. The disk therefore stays bounded by the user, not by a
|
||||
timer — `houseplan/plans/list` shows every stored plan with its size and which
|
||||
space uses it, and `houseplan/plans/delete` removes one on request, refusing
|
||||
while a space still references it. That listing is what makes "we never delete"
|
||||
livable: a detached plan is not lost, it is one click away in the space dialog.
|
||||
|
||||
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
|
||||
single promise: one `config/set` in flight, each carrying the revision the
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## v1.47.0 — 2026-07-28 (pick a plan you already uploaded)
|
||||
- **The space dialog can now show the plans stored on the server.** Detaching a
|
||||
plan keeps the image on disk — that has been the rule since v1.46.4, but until
|
||||
now the only way back was to find the original file on your computer and
|
||||
upload it again. "Already uploaded" lists what is there, with a thumbnail, the
|
||||
file size and which space uses it. One click attaches it; the aspect ratio is
|
||||
read from the image, exactly as on upload.
|
||||
- **And it is where you delete one.** A plan file is never removed automatically
|
||||
— not for being detached, not for being old — which is only a sensible policy
|
||||
if you can see what is being kept and get rid of it deliberately. The trash
|
||||
button does that, and refuses while a space still uses the plan: the answer to
|
||||
"may this go" comes from the stored configuration, not from the browser.
|
||||
- Documentation caught up with the code: several comments still described the
|
||||
age-based collection that v1.46.6 removed.
|
||||
|
||||
## v1.46.6 — 2026-07-28 (the detach promise, actually kept this time)
|
||||
- **Switching a space to "draw" no longer deletes its image.** v1.46.4 and
|
||||
v1.46.5 said it did not, and the scheduled cleanup indeed left detached plans
|
||||
alone — but the save itself deleted the file the moment the reference was
|
||||
cleared, before any of those guards were reached. The cause: a file that left
|
||||
the configuration was called "superseded", and from that difference alone
|
||||
replacing a plan, detaching one and deleting its space are indistinguishable.
|
||||
Only the first is a deletion anybody asked for. The transition is now
|
||||
classified by the space that owned the file, and the same distinction applies
|
||||
to attachments: dropping one from a device that still exists removes it,
|
||||
deleting the device keeps its manuals.
|
||||
- **A plan whose space was deleted is kept**, rather than the thirty days
|
||||
v1.46.5 promised — thirty days measured from the file's age is meaningless
|
||||
anyway, since it was usually uploaded months earlier.
|
||||
- **Nothing is deleted for being old any more**, except a per-dialog staging
|
||||
folder. The rule that aged out "rejected uploads" turned out to race a retry:
|
||||
the cleanup removed the file from a failed save while the next attempt was
|
||||
committing a reference to it. A rule that can delete a file somebody is about
|
||||
to point at is not worth the disk it reclaims. Files therefore go when an
|
||||
action says so, and otherwise stay.
|
||||
|
||||
## v1.46.5 — 2026-07-28 (audit of every automatic deletion)
|
||||
- **A detached plan is never deleted, at any age.** v1.46.4 gave it a month;
|
||||
this makes it permanent and writes the reason down where the next change will
|
||||
see it. The rule, now in docs/SCOPE.md: the component may delete a file only
|
||||
when a user action says so — replacing a plan, removing an attachment,
|
||||
deleting a device. "Nothing points at this any more" is not such an action.
|
||||
The errors are not symmetrical: wasted disk is visible, cheap and reversible;
|
||||
a deleted file is none of those.
|
||||
- **`houseplan/files/cleanup` no longer takes a folder on the client's word.**
|
||||
After a device is rebound its files are copied to the new id and the old
|
||||
folder is dropped — with `rmtree`, on whatever id the card sent. Two ways that
|
||||
ends badly: a partial copy leaves some urls still pointing into that folder
|
||||
(the migration deliberately does not rewrite those, so they were live links to
|
||||
files being deleted), and a wrong or stale id from any client would destroy a
|
||||
live device's manuals. The server now checks the stored configuration itself,
|
||||
under the config lock, and removes only files nothing references.
|
||||
- **A plan of a space that was deleted waits thirty days instead of an hour.**
|
||||
Deleting a space is deliberate, but an hour is a short window in which to
|
||||
notice it was a misclick.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -6,6 +6,62 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов)
|
||||
- **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление
|
||||
плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было
|
||||
только найдя исходный файл у себя и загрузив заново. «Уже загруженные»
|
||||
показывают, что есть: миниатюра, размер файла и то, какое пространство его
|
||||
использует. Клик прикрепляет, пропорции читаются из самой картинки — так же,
|
||||
как при загрузке.
|
||||
- **Там же план и удаляется.** Файл плана никогда не удаляется автоматически —
|
||||
ни за отцепление, ни за возраст, — и это разумная политика ровно до тех пор,
|
||||
пока видно, что именно хранится, и есть способ убрать это осознанно. Кнопка
|
||||
корзины делает это и отказывает, пока план используется пространством: ответ
|
||||
на вопрос «можно ли удалить» даёт сохранённая конфигурация, а не браузер.
|
||||
- Документация догнала код: несколько комментариев всё ещё описывали удаление по
|
||||
возрасту, убранное в v1.46.6.
|
||||
|
||||
## v1.46.6 — 2026-07-28 (обещание про отцепление, теперь выполненное)
|
||||
- **Переключение пространства в «нарисовать» больше не удаляет его картинку.**
|
||||
v1.46.4 и v1.46.5 утверждали, что не удаляет, и плановая уборка действительно
|
||||
отцеплённые планы не трогала — но само сохранение удаляло файл в тот момент,
|
||||
когда снималась ссылка, ещё до всех этих проверок. Причина: файл, покинувший
|
||||
конфигурацию, считался «заменённым», а по одной этой разнице замену плана,
|
||||
отцепление и удаление пространства различить невозможно. Удалением, о котором
|
||||
просили, является только первое. Теперь переход классифицируется по
|
||||
пространству-владельцу, и та же разница применяется к вложениям: убрали файл у
|
||||
существующего устройства — он удаляется, удалили устройство — его инструкции
|
||||
остаются.
|
||||
- **План удалённого пространства сохраняется**, а не тридцать дней, как обещала
|
||||
v1.46.5: тридцать дней по возрасту файла всё равно бессмысленны — обычно он
|
||||
загружен месяцы назад.
|
||||
- **По возрасту больше не удаляется ничего**, кроме промежуточной папки диалога.
|
||||
Правило, которое вычищало «отвергнутые загрузки», оказалось в гонке с
|
||||
повторной попыткой: уборка удаляла файл неудавшегося сохранения ровно тогда,
|
||||
когда следующая попытка коммитила ссылку на него. Правило, способное удалить
|
||||
файл, на который кто-то вот-вот сошлётся, не стоит освобождаемого места.
|
||||
Файлы уходят по действию, в остальных случаях остаются.
|
||||
|
||||
## v1.46.5 — 2026-07-28 (ревизия всех автоматических удалений)
|
||||
- **Отцеплённый план не удаляется никогда, ни в каком возрасте.** В v1.46.4 ему
|
||||
давался месяц; теперь это навсегда, и причина записана там, где её увидит
|
||||
следующая правка. Правило, оно же в docs/SCOPE.md: компонент вправе удалить
|
||||
файл только тогда, когда об этом говорит действие пользователя — замена
|
||||
плана, удаление вложения, удаление устройства. «На это больше никто не
|
||||
ссылается» таким действием не является. Ошибки несимметричны: занятое зря
|
||||
место видно, стоит копейки и обратимо; удалённый файл — ничего из этого.
|
||||
- **`houseplan/files/cleanup` больше не сносит папку по слову клиента.** После
|
||||
перепривязки устройства файлы копируются под новый id, а старая папка
|
||||
удалялась — через `rmtree`, по тому id, который прислала карточка. Два плохих
|
||||
исхода: при частичном копировании часть ссылок продолжает указывать внутрь
|
||||
этой папки (миграция намеренно их не переписывает — то есть это были живые
|
||||
ссылки на удаляемые файлы), а неверный или устаревший id от любого клиента
|
||||
уничтожил бы инструкции существующего устройства. Теперь сервер сам сверяется
|
||||
с сохранённой конфигурацией, под её блокировкой, и удаляет только то, на что
|
||||
никто не ссылается.
|
||||
- **План удалённого пространства ждёт тридцать дней вместо часа.** Удаление
|
||||
пространства осознанно, но час — короткое окно, чтобы заметить промах.
|
||||
|
||||
## v1.46.4 — 2026-07-28 (потеря данных: отцеплённые планы убирались как мусор)
|
||||
- **Отцеплённый план больше не удаляется через час.** Переключение пространства
|
||||
в режим «нарисовать» снимает ссылку и, как редактор всегда и говорил,
|
||||
|
||||
@@ -66,6 +66,18 @@ refuse locks or be added to this paragraph.
|
||||
- Plan-level "security glance": one badge for "all locked / N open" (J2).
|
||||
- Threshold colouring for room-card metrics (J5).
|
||||
|
||||
## Standing rule: never delete a user's file on an inference
|
||||
|
||||
Fixed with the owner on 2026-07-28, after automatic collection removed two
|
||||
detached floor plans. The component may delete a file only when the user's
|
||||
action says so — replacing a plan, removing an attachment, deleting a device.
|
||||
"Nothing points at this any more" is not such an action: detaching a plan is one
|
||||
click and reversible, and the editor tells the user the file stays.
|
||||
|
||||
The asymmetry is the whole argument. Wasted disk is visible, cheap and
|
||||
reversible; a deleted file is none of those. Where the evidence is weak, keep
|
||||
the file — and if a future version wants to reclaim that space, it asks.
|
||||
|
||||
## Out of scope — never build, point users to the right tool
|
||||
|
||||
- Automations, scenes, scripts, notifications → HA core.
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.46.4** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.47.0** 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.4** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.47.0** 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 |
|
||||
|
||||
+18
-4
@@ -239,10 +239,24 @@ 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]
|
||||
- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE
|
||||
PAGE, open space settings → "Already uploaded" → the image is listed with
|
||||
its size and no "in use" note → attach it → it renders. The one a space
|
||||
uses shows that space and cannot be deleted; a free one can, with a
|
||||
confirm, and disappears from the list
|
||||
[auto: smoke_saved_plans + backend test_stored_plans_can_be_listed_and_deleted_on_request]
|
||||
- [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and
|
||||
SAVE — the image is still in `config/houseplan/plans/` right afterwards,
|
||||
and after a restart, and can be re-attached. Deleting the space keeps it
|
||||
too. Replacing a plan still removes the one it replaced, immediately.
|
||||
Check straight after the save: the earlier bug deleted the file at that
|
||||
moment, while every scheduled-pass test passed
|
||||
[auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix,
|
||||
backend test_detaching_a_plan_keeps_the_file]
|
||||
- [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to
|
||||
a device, rebind it to another HA device — both are readable afterwards.
|
||||
If a copy failed, the file it failed on is still there rather than deleted
|
||||
with the folder [auto: backend test_files_cleanup_keeps_referenced_files]
|
||||
- [ ] 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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.46.4",
|
||||
"version": "1.47.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+103
-2
@@ -35,7 +35,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.46.4';
|
||||
const CARD_VERSION = '1.47.0';
|
||||
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';
|
||||
@@ -277,6 +277,15 @@ class HouseplanCard extends LitElement {
|
||||
title: string;
|
||||
planUrl: string | null;
|
||||
planFile: { ext: string; b64: string; aspect: number; name: string } | null;
|
||||
/**
|
||||
* The "already uploaded" list, its contents, and the aspect of whatever was
|
||||
* picked from it. Plans are never deleted for being unreferenced, which is
|
||||
* only a sane policy if they can be found again (docs/SCOPE.md).
|
||||
*/
|
||||
pickSaved?: boolean;
|
||||
saved?: { name: string; url: string; size: number; modified: number; used_by: string[] }[] | null;
|
||||
savedBusy?: boolean;
|
||||
savedAspect?: number;
|
||||
source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms
|
||||
orientation: 'landscape' | 'portrait' | 'square';
|
||||
showBorders: boolean;
|
||||
@@ -3060,6 +3069,89 @@ class HouseplanCard extends LitElement {
|
||||
this._spaceDialog = { ...this._spaceDialog, planFile: { ext, b64, aspect, name: file.name } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans that are on the server but not attached anywhere are not garbage —
|
||||
* the component never deletes them (docs/SCOPE.md), which only makes sense if
|
||||
* they can be found again. This is that: detach a plan, come back later, pick
|
||||
* it out of the list. It is also the only way one is ever deleted.
|
||||
*/
|
||||
private _toggleServerPlans = async (): Promise<void> => {
|
||||
const d = this._spaceDialog;
|
||||
if (!d) return;
|
||||
if (d.pickSaved) {
|
||||
this._spaceDialog = { ...d, pickSaved: false };
|
||||
return;
|
||||
}
|
||||
this._spaceDialog = { ...d, pickSaved: true, savedBusy: true };
|
||||
try {
|
||||
const r: any = await this.hass.callWS({ type: 'houseplan/plans/list' });
|
||||
const cur = this._spaceDialog;
|
||||
if (cur) this._spaceDialog = { ...cur, saved: r?.plans || [], savedBusy: false };
|
||||
} catch (e: any) {
|
||||
const cur = this._spaceDialog;
|
||||
if (cur) this._spaceDialog = { ...cur, saved: [], savedBusy: false };
|
||||
this._showToast(this._t('toast.plans_list_failed', { err: this._errText(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
private _useServerPlan(url: string): void {
|
||||
const d = this._spaceDialog;
|
||||
if (!d) return;
|
||||
// the aspect ratio is read from the image itself, exactly as on upload
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cur = this._spaceDialog;
|
||||
if (!cur) return;
|
||||
const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1.414;
|
||||
this._spaceDialog = {
|
||||
...cur, planUrl: url, planFile: null, pickSaved: false,
|
||||
savedAspect: Number.isFinite(ratio) && ratio > 0 ? ratio : 1.414,
|
||||
};
|
||||
};
|
||||
img.onerror = () => {
|
||||
const cur = this._spaceDialog;
|
||||
if (cur) this._spaceDialog = { ...cur, planUrl: url, planFile: null, pickSaved: false, savedAspect: 1.414 };
|
||||
};
|
||||
img.src = this._display(url);
|
||||
}
|
||||
|
||||
private async _deleteServerPlan(name: string): Promise<void> {
|
||||
if (!confirm(this._t('confirm.delete_plan', { name }))) return;
|
||||
try {
|
||||
await this.hass.callWS({ type: 'houseplan/plans/delete', name });
|
||||
const d = this._spaceDialog;
|
||||
if (d?.saved) this._spaceDialog = { ...d, saved: d.saved.filter((p) => p.name !== name) };
|
||||
} catch (e: any) {
|
||||
this._showToast(this._t('toast.plan_delete_failed', { err: this._errText(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
private _renderServerPlans(d: NonNullable<typeof this._spaceDialog>): TemplateResult {
|
||||
if (d.savedBusy) return html`<div class="savedplans muted">${this._t('space.loading')}</div>`;
|
||||
const list = d.saved || [];
|
||||
if (!list.length) return html`<div class="savedplans muted">${this._t('space.no_saved')}</div>`;
|
||||
const kb = (n: number) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB');
|
||||
return html`<div class="savedplans">
|
||||
${list.map((p) => html`
|
||||
<div class="savedplan ${p.url === d.planUrl ? 'cur' : ''}">
|
||||
<img src=${this._display(p.url)} alt="" />
|
||||
<div class="savedmeta">
|
||||
<b>${p.name}</b>
|
||||
<span class="muted">${kb(p.size)}${p.used_by.length
|
||||
? ' · ' + this._t('space.used_by', { list: p.used_by.join(', ') })
|
||||
: ''}</span>
|
||||
</div>
|
||||
<button class="btn ghost" @click=${() => this._useServerPlan(p.url)}
|
||||
?disabled=${p.url === d.planUrl}>${this._t('btn.use')}</button>
|
||||
<button class="btn ghost danger" title=${p.used_by.length ? this._t('space.in_use') : this._t('btn.delete')}
|
||||
?disabled=${p.used_by.length > 0}
|
||||
@click=${() => this._deleteServerPlan(p.name)}>
|
||||
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
|
||||
</button>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private async _saveSpaceDialog(): Promise<void> {
|
||||
const d = this._spaceDialog;
|
||||
if (!d || d.busy || !d.title.trim()) return;
|
||||
@@ -3109,6 +3201,10 @@ class HouseplanCard extends LitElement {
|
||||
if (uploaded) {
|
||||
sp.plan_url = uploaded.url;
|
||||
sp.aspect = uploaded.aspect;
|
||||
} else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) {
|
||||
// picked from the server list: no upload, just a reference
|
||||
sp.plan_url = d.planUrl;
|
||||
if (d.savedAspect) sp.aspect = d.savedAspect;
|
||||
}
|
||||
// switching an existing space to "draw" detaches its background image
|
||||
// (the uploaded file stays on disk; only the reference is cleared)
|
||||
@@ -5135,7 +5231,12 @@ class HouseplanCard extends LitElement {
|
||||
<input type="file" hidden accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
|
||||
@change=${(e: Event) => this._pickPlanFile(e)} />
|
||||
</label>
|
||||
</div>`
|
||||
<button class="btn ghost" @click=${this._toggleServerPlans}
|
||||
title=${this._t('space.pick_saved_hint')}>
|
||||
<ha-icon icon="mdi:folder-image"></ha-icon>${this._t('space.pick_saved')}
|
||||
</button>
|
||||
</div>
|
||||
${d.pickSaved ? this._renderServerPlans(d) : nothing}`
|
||||
: nothing}
|
||||
<label class="srcrow">
|
||||
<input type="radio" name="plansrc" .checked=${d.source === 'draw'}
|
||||
|
||||
+11
-1
@@ -328,5 +328,15 @@
|
||||
"marker.is_light": "This device is a light source",
|
||||
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
|
||||
"confirm.unlock": "Unlock “{name}”?",
|
||||
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}"
|
||||
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}",
|
||||
"space.pick_saved": "Already uploaded",
|
||||
"space.pick_saved_hint": "Plans stored on the server, including ones you detached earlier",
|
||||
"space.no_saved": "No plans stored on the server yet.",
|
||||
"space.loading": "Loading…",
|
||||
"space.used_by": "in use: {list}",
|
||||
"space.in_use": "A space still uses this plan — detach it first",
|
||||
"btn.use": "Use",
|
||||
"confirm.delete_plan": "Delete the plan file \"{name}\" from the server? This cannot be undone.",
|
||||
"toast.plans_list_failed": "Could not list the stored plans: {err}",
|
||||
"toast.plan_delete_failed": "Could not delete the plan: {err}"
|
||||
}
|
||||
|
||||
+11
-1
@@ -328,5 +328,15 @@
|
||||
"marker.is_light": "Это устройство — источник света",
|
||||
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
|
||||
"confirm.unlock": "Открыть замок «{name}»?",
|
||||
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}"
|
||||
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}",
|
||||
"space.pick_saved": "Уже загруженные",
|
||||
"space.pick_saved_hint": "Планы, сохранённые на сервере, включая отцеплённые ранее",
|
||||
"space.no_saved": "На сервере пока нет сохранённых планов.",
|
||||
"space.loading": "Загрузка…",
|
||||
"space.used_by": "используется: {list}",
|
||||
"space.in_use": "План используется пространством — сначала отцепите его",
|
||||
"btn.use": "Выбрать",
|
||||
"confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.",
|
||||
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
|
||||
"toast.plan_delete_failed": "Не удалось удалить план: {err}"
|
||||
}
|
||||
|
||||
@@ -1062,6 +1062,40 @@ export const cardStyles = css`
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
/* the "already uploaded" picker: a plan is never deleted for being
|
||||
unreferenced, so it has to be findable again */
|
||||
.savedplans {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
margin: 6px 0 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: 8px;
|
||||
background: var(--hp-bg2, rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
.savedplan {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.savedplan.cur { outline: 1px solid var(--hp-accent); border-radius: 6px; }
|
||||
.savedplan img {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
flex: none;
|
||||
}
|
||||
.savedmeta { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.savedmeta b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.savedmeta .muted { font-size: 11px; }
|
||||
.savedplan .btn.danger ha-icon { color: #f25a4a; }
|
||||
.savedplan .btn[disabled] { opacity: 0.4; pointer-events: none; }
|
||||
.planprev {
|
||||
max-width: 120px;
|
||||
max-height: 70px;
|
||||
|
||||
@@ -176,10 +176,12 @@ async def test_files_migrate_copies_and_reports_mapping(
|
||||
assert src_kept, "migrate must COPY, not move (review CR-2)"
|
||||
assert other == b"OTHER" and copied == b"SOURCE"
|
||||
|
||||
# cleanup runs only after the config is safely committed
|
||||
# cleanup runs only after the config is safely committed, and since v1.46.5
|
||||
# reports how many files it removed rather than a bare boolean — it now also
|
||||
# keeps anything the stored configuration still references
|
||||
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "old1"})
|
||||
resp2 = await client.receive_json()
|
||||
assert resp2["success"] and resp2["result"]["removed"] is True
|
||||
assert resp2["success"] and resp2["result"]["removed"] >= 1 and resp2["result"]["kept"] == 0
|
||||
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
|
||||
|
||||
|
||||
@@ -340,35 +342,41 @@ async def test_commit_does_not_collect_another_client_s_uncommitted_upload(
|
||||
assert not (plans / pa).exists()
|
||||
|
||||
|
||||
async def test_abandoned_uploads_are_collected_once_old(
|
||||
async def test_a_rejected_upload_is_kept_not_aged_out(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""A rejected upload must not accumulate forever — but only age may free it."""
|
||||
"""v1.46.6: age is never a reason to delete a plan file.
|
||||
|
||||
It used to be, for "a file of a space that has a plan and never was one" —
|
||||
an upload whose save had failed. That raced the retry: the sweep removed the
|
||||
file while the next save was committing a reference to it. Keeping it costs
|
||||
a few megabytes nobody can lose.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR, PLAN_ORPHAN_TTL_S
|
||||
from custom_components.houseplan.const import PLANS_DIR, SCHEDULED_GRACE_S
|
||||
from custom_components.houseplan.store import get_data
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
plans = Path(hass.config.path(PLANS_DIR))
|
||||
plans.mkdir(parents=True, exist_ok=True)
|
||||
for stale in plans.glob("r3.*"):
|
||||
stale.unlink()
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
|
||||
url0, p0 = await _upload(client, "r3", b"zero")
|
||||
rev = (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), 0))["result"]["rev"]
|
||||
|
||||
_url, orphan = await _upload(client, "r3", b"abandoned")
|
||||
old = time.time() - PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(plans / orphan, (old, old))
|
||||
# r3 still HAS a plan, so this really is a rejected upload — collectable
|
||||
_url, orphan = await _upload(client, "r3", b"never saved")
|
||||
old = time.time() - SCHEDULED_GRACE_S * 12
|
||||
os.utime(os.path.join(plans, orphan), (old, old))
|
||||
|
||||
ok = await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev)
|
||||
assert ok["success"]
|
||||
assert not (plans / orphan).exists(), "an aged, unreferenced upload is collected"
|
||||
assert (plans / p0).is_file(), "the referenced plan is never touched"
|
||||
# a commit, and the scheduled pass, and any amount of age: it stays
|
||||
assert (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev))["success"]
|
||||
data = get_data(hass)
|
||||
await data.sweep()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, orphan))
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, p0))
|
||||
|
||||
|
||||
async def test_collection_ignores_files_that_are_not_plans(
|
||||
@@ -887,7 +895,9 @@ 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, "deleted_space.orphan.png"),
|
||||
# a plan file is never collected by age any more; keep one around and
|
||||
# assert exactly that
|
||||
"kept_reject": os.path.join(plans, "s5.reject.png"),
|
||||
}
|
||||
|
||||
|
||||
@@ -905,8 +915,12 @@ async def _assert_swept(hass, p) -> None:
|
||||
|
||||
assert await hass.async_add_executor_job(os.path.isfile, p["kept_file"]), "referenced file kept"
|
||||
assert await hass.async_add_executor_job(os.path.isfile, p["kept_plan"]), "referenced plan kept"
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_file"])
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_plan"])
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, p["orphan_file"]), (
|
||||
"a staging folder from a dialog nobody saved is the one thing age collects"
|
||||
)
|
||||
assert await hass.async_add_executor_job(os.path.isfile, p["kept_reject"]), (
|
||||
"a plan file is never removed for being old"
|
||||
)
|
||||
|
||||
|
||||
async def test_startup_sweep_collects_what_no_commit_will(
|
||||
@@ -989,16 +1003,161 @@ async def test_sweep_and_a_config_write_do_not_race(
|
||||
cfg2 = await _referenced_config()
|
||||
cfg2["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/_/s5.newcomer.png"
|
||||
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
await asyncio.gather(
|
||||
hass.config_entries.async_reload(entry.entry_id), # runs the sweep
|
||||
_save(client, cfg2, rev),
|
||||
)
|
||||
# Drive the sweep directly rather than through a reload: an entry reload has
|
||||
# an unload window in which any WS call legitimately answers `not_ready`, so
|
||||
# a save racing THAT proves nothing about the lock and fails at random.
|
||||
from custom_components.houseplan.store import get_data
|
||||
|
||||
data = get_data(hass)
|
||||
assert data is not None and data.sweep is not None
|
||||
_swept, saved = await asyncio.gather(data.sweep(), _save(client, cfg2, rev))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# assert the CONCRETE outcome: a save that came back `not_ready` would leave
|
||||
# the old config pointing at the old file and satisfy a vaguer check
|
||||
assert saved["success"], saved.get("error")
|
||||
await client.send_json_auto_id({"type": "houseplan/config/get"})
|
||||
stored = (await client.receive_json())["result"]["config"]
|
||||
referenced = stored["spaces"][0]["plan_url"].rsplit("/", 1)[-1]
|
||||
assert stored["spaces"][0]["plan_url"].endswith("s5.newcomer.png")
|
||||
assert await hass.async_add_executor_job(
|
||||
os.path.isfile, os.path.join(plans, referenced)
|
||||
), f"the accepted config points at {referenced}, which must exist"
|
||||
os.path.isfile, os.path.join(plans, "s5.newcomer.png")
|
||||
), "the file the accepted config points at must exist"
|
||||
|
||||
|
||||
async def test_files_cleanup_keeps_referenced_files(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""v1.46.5: the client may say what it no longer needs, never what may go.
|
||||
|
||||
`files/cleanup` used to rmtree the folder it was handed. A partial migration
|
||||
leaves some urls pointing into that folder — the copy deliberately does not
|
||||
rewrite the ones it could not confirm — so those were live links to files
|
||||
being deleted. A wrong id from any client had the same effect on a device's
|
||||
manuals.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import FILES_DIR
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
folder = os.path.join(hass.config.path(FILES_DIR), "m7")
|
||||
|
||||
def _seed() -> None:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
for n in ("kept.pdf", "orphan.pdf"):
|
||||
with open(os.path.join(folder, n), "wb") as fh:
|
||||
fh.write(b"x")
|
||||
|
||||
await hass.async_add_executor_job(_seed)
|
||||
|
||||
cfg = await _cfg([{"id": "s7", "plan_url": None}])
|
||||
cfg["markers"] = [
|
||||
{"id": "other", "binding": "virtual",
|
||||
"pdfs": [{"name": "k", "url": "/api/houseplan/content/files/m7/kept.pdf"}]}
|
||||
]
|
||||
assert (await _save(client, cfg, 0))["success"]
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/files/cleanup", "marker_id": "m7"})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"] and resp["result"] == {"ok": True, "removed": 1, "kept": 1}
|
||||
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "kept.pdf")), (
|
||||
"a file the configuration still references survives a cleanup of its folder"
|
||||
)
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(folder, "orphan.pdf"))
|
||||
|
||||
|
||||
async def test_detaching_a_plan_keeps_the_file(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1465-01, through the real save — where the earlier tests never looked.
|
||||
|
||||
Every check for this lived in the pure collector with old and new config
|
||||
equal, i.e. the scheduled pass. The transition that matters is a save, and
|
||||
there the file was deleted the moment the reference was cleared.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
|
||||
url, name = await _upload(client, "d1", b"PLAN", ext="png")
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), 0))["result"]["rev"]
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||
|
||||
# detach: the space stays, its plan does not
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": None}]), rev))["result"]["rev"]
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name)), (
|
||||
"the editor says the image stays on disk — it has to actually stay"
|
||||
)
|
||||
|
||||
# a restart does not change its mind either
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||
|
||||
# re-attach, then replace: THAT removes the one it replaced
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url}]), rev))["result"]["rev"]
|
||||
url2, name2 = await _upload(client, "d1", b"NEWPLAN", ext="png")
|
||||
rev = (await _save(client, await _cfg([{"id": "d1", "plan_url": url2}]), rev))["result"]["rev"]
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
|
||||
assert not await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name))
|
||||
|
||||
# and deleting the space keeps its plan
|
||||
await _save(client, await _cfg([]), rev)
|
||||
await hass.async_block_till_done()
|
||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
|
||||
|
||||
|
||||
async def test_stored_plans_can_be_listed_and_deleted_on_request(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1466-01/-02: "we never delete" needs the files to be findable.
|
||||
|
||||
A detached plan stays on disk. Without a way to see it, that is neither a
|
||||
recovery path nor a disk policy — it is just accumulation. Listing gives
|
||||
both: the user attaches it again, or deletes it on purpose, which is the
|
||||
only way a plan file is ever removed.
|
||||
"""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
used_url, used = await _upload(client, "p1", b"USED", ext="png")
|
||||
_free_url, free = await _upload(client, "p2", b"FREE", ext="png")
|
||||
rev = (await _save(client, await _cfg([{"id": "p1", "plan_url": used_url}]), 0))["result"]["rev"]
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/list"})
|
||||
# the HA test config dir is shared across the module: look at ours, not all
|
||||
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
|
||||
assert {used, free} <= set(plans)
|
||||
assert plans[used]["used_by"] and not plans[free]["used_by"]
|
||||
assert plans[used]["size"] == 4 and plans[free]["url"].endswith(free)
|
||||
|
||||
# a plan a space still uses is refused — the config decides, not the client
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": used})
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "in_use"
|
||||
|
||||
# an unused one goes on request
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": free})
|
||||
assert (await client.receive_json())["result"]["removed"] is True
|
||||
|
||||
# detach the other, and now it is deletable — and listed as free until then
|
||||
await _save(client, await _cfg([{"id": "p1", "plan_url": None}]), rev)
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/list"})
|
||||
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
|
||||
assert used in plans, "the detached plan is still there, ready to re-attach"
|
||||
assert not plans[used]["used_by"]
|
||||
assert free not in plans, "and the one we deleted is gone"
|
||||
|
||||
# nothing outside the plans folder can be reached through the name
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plans/delete", "name": "../../configuration.yaml"}
|
||||
)
|
||||
bad = await client.receive_json()
|
||||
assert not bad["success"] and bad["error"]["code"] == "invalid_name"
|
||||
|
||||
@@ -265,17 +265,6 @@ def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
|
||||
assert (d / "f1.inflight.png").is_file()
|
||||
|
||||
|
||||
def test_collect_plans_takes_an_aged_orphan(tmp_path):
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
d = _plans(tmp_path, ["f1.keep.png"])
|
||||
# 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()
|
||||
|
||||
|
||||
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
|
||||
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
|
||||
collect_plans = plans.collect_plans
|
||||
@@ -477,31 +466,6 @@ def test_attachment_refs_reads_marker_urls():
|
||||
assert plans.attachment_refs(cfg) == set()
|
||||
|
||||
|
||||
def test_collect_attachments_supersedes_and_ages(tmp_path):
|
||||
import os
|
||||
import time
|
||||
|
||||
collect_attachments = plans.collect_attachments
|
||||
files = tmp_path / "files"
|
||||
(files / "m1").mkdir(parents=True)
|
||||
for n in ("old.pdf", "new.pdf", "cancelled.pdf"):
|
||||
(files / "m1" / n).write_bytes(b"x")
|
||||
|
||||
# the commit swapped old.pdf for new.pdf; cancelled.pdf is a fresh upload
|
||||
# nobody saved — it may belong to a dialog that is still open
|
||||
removed = collect_attachments(files, _acfg("m1/old.pdf"), _acfg("m1/new.pdf"))
|
||||
assert removed == 1
|
||||
assert not (files / "m1" / "old.pdf").exists()
|
||||
assert (files / "m1" / "new.pdf").is_file()
|
||||
assert (files / "m1" / "cancelled.pdf").is_file()
|
||||
|
||||
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()
|
||||
assert (files / "m1" / "new.pdf").is_file()
|
||||
|
||||
|
||||
def test_collect_attachments_removes_the_empty_folder_and_never_raises(tmp_path):
|
||||
import os
|
||||
import time
|
||||
@@ -554,69 +518,151 @@ def test_legacy_segments_are_dropped_by_the_server():
|
||||
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.
|
||||
"""
|
||||
def _aged(path, seconds):
|
||||
import os
|
||||
import time
|
||||
|
||||
t = time.time() - seconds
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
def _sp(sid, url):
|
||||
return {"id": sid, "plan_url": f"/api/houseplan/content/plans/_/{url}" if url else None}
|
||||
|
||||
|
||||
def test_plan_collection_matrix(tmp_path):
|
||||
"""Which config transition means "the user asked for this file to go"?
|
||||
|
||||
`old_refs - new_refs` cannot tell replace, detach and delete-space apart —
|
||||
they look identical. v1.46.4/v1.46.5 added guards for detach and only ever
|
||||
reached them on the scheduled pass, so the commit itself still deleted a
|
||||
detached plan the moment it was detached (HP-1465-01). One case is a
|
||||
deletion the user asked for; the rest are kept.
|
||||
"""
|
||||
collect = plans.collect_plans
|
||||
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"
|
||||
def seed(*names):
|
||||
for n in names:
|
||||
(d / n).write_bytes(b"x")
|
||||
_aged(d / n, const.SCHEDULED_GRACE_S * 2) # old enough for any rule
|
||||
|
||||
# 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"
|
||||
# 1. replace: the user picked a different image for the same space
|
||||
seed("f1.old.png", "f1.new.png")
|
||||
assert collect(d, {"spaces": [_sp("f1", "f1.old.png")]},
|
||||
{"spaces": [_sp("f1", "f1.new.png")]}) == 1
|
||||
assert not (d / "f1.old.png").exists() and (d / "f1.new.png").is_file()
|
||||
|
||||
# 2. detach: same space, switched to "draw"
|
||||
seed("f2.png")
|
||||
assert collect(d, {"spaces": [_sp("f2", "f2.png")]},
|
||||
{"spaces": [_sp("f2", None)]}) == 0
|
||||
assert (d / "f2.png").is_file(), "the editor says the file stays — it stays"
|
||||
|
||||
# 3. the space is deleted outright
|
||||
seed("f3.png")
|
||||
assert collect(d, {"spaces": [_sp("f3", "f3.png")]}, {"spaces": []}) == 0
|
||||
assert (d / "f3.png").is_file()
|
||||
|
||||
# 4. the scheduled pass, later, still keeps both
|
||||
cfg = {"spaces": [_sp("f2", None)]}
|
||||
assert collect(d, cfg, cfg) == 0
|
||||
assert (d / "f2.png").is_file() and (d / "f3.png").is_file()
|
||||
|
||||
# 5. an upload whose save was rejected is kept too, at any age. Ageing those
|
||||
# out raced the retry: the sweep deleted a file the save was committing a
|
||||
# reference to (caught by test_sweep_and_a_config_write_do_not_race).
|
||||
seed("f4.current.png", "f4.reject.png")
|
||||
live = {"spaces": [_sp("f4", "f4.current.png")]}
|
||||
assert collect(d, live, live) == 0
|
||||
assert (d / "f4.current.png").is_file() and (d / "f4.reject.png").is_file()
|
||||
|
||||
# 6. the same file still referenced by another space is never touched
|
||||
seed("shared.png")
|
||||
assert collect(d, {"spaces": [_sp("a", "shared.png"), _sp("b", "shared.png")]},
|
||||
{"spaces": [_sp("a", None), _sp("b", "shared.png")]}) == 0
|
||||
assert (d / "shared.png").is_file()
|
||||
|
||||
|
||||
def test_attachment_grace_is_a_month_outside_a_staging_folder(tmp_path):
|
||||
"""A staging folder is unambiguous; a marker folder is not.
|
||||
def test_attachment_collection_matrix(tmp_path):
|
||||
"""Removing an attachment is a trash button; deleting the device is not."""
|
||||
collect = plans.collect_attachments
|
||||
|
||||
`up_*` only ever holds an upload from a dialog that was never saved, so an
|
||||
hour is right there. A marker's own folder may hold a file that is merely
|
||||
unreferenced at the moment, and one hour of that turned out to be a way to
|
||||
lose data (2026-07-28).
|
||||
"""
|
||||
def case(name):
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
def seed(root, folder, fname, age=None):
|
||||
(root / folder).mkdir(parents=True, exist_ok=True)
|
||||
p = root / folder / fname
|
||||
p.write_bytes(b"x")
|
||||
if age:
|
||||
_aged(p, age)
|
||||
return p
|
||||
|
||||
def cfg(*markers):
|
||||
return {"markers": [
|
||||
{"id": mid, "pdfs": [{"url": f"/api/houseplan/content/files/{mid}/{n}"} for n in names]}
|
||||
for mid, names in markers
|
||||
]}
|
||||
|
||||
# 1. the user removed one attachment from a device that still exists
|
||||
d = case("dropped")
|
||||
seed(d, "m1", "dropped.pdf")
|
||||
seed(d, "m1", "kept.pdf")
|
||||
assert collect(d, cfg(("m1", ["dropped.pdf", "kept.pdf"])), cfg(("m1", ["kept.pdf"]))) == 1
|
||||
assert not (d / "m1" / "dropped.pdf").exists()
|
||||
assert (d / "m1" / "kept.pdf").is_file()
|
||||
|
||||
# 2. the device itself is gone: its manuals are not ours to throw away
|
||||
d = case("device_gone")
|
||||
seed(d, "m2", "manual.pdf", age=const.SCHEDULED_GRACE_S * 2)
|
||||
assert collect(d, cfg(("m2", ["manual.pdf"])), cfg()) == 0
|
||||
assert (d / "m2" / "manual.pdf").is_file()
|
||||
# and the scheduled pass, later, agrees
|
||||
assert collect(d, cfg(), cfg()) == 0
|
||||
assert (d / "m2" / "manual.pdf").is_file()
|
||||
|
||||
# 3. a dialog that was never saved, in its own staging folder
|
||||
d = case("staging")
|
||||
seed(d, "up_x", "manual.pdf", age=const.PLAN_ORPHAN_TTL_S + 60)
|
||||
assert collect(d, cfg(), cfg()) == 1
|
||||
assert not (d / "up_x").exists()
|
||||
|
||||
# 4. an upload into a live device's folder whose save was rejected: kept,
|
||||
# for the same reason as a plan's — a retry may be about to reference it
|
||||
d = case("reject")
|
||||
seed(d, "m3", "current.pdf")
|
||||
seed(d, "m3", "rejected.pdf", age=const.SCHEDULED_GRACE_S + 60)
|
||||
live = cfg(("m3", ["current.pdf"]))
|
||||
assert collect(d, live, live) == 0
|
||||
assert (d / "m3" / "current.pdf").is_file()
|
||||
assert (d / "m3" / "rejected.pdf").is_file()
|
||||
|
||||
|
||||
def test_only_a_staging_folder_ages_out(tmp_path):
|
||||
"""The one age rule left. Everything else waits for the user to say so."""
|
||||
import os
|
||||
import time
|
||||
|
||||
files = tmp_path / "files"
|
||||
(files / "m1").mkdir(parents=True)
|
||||
(files / "up_abandoned").mkdir(parents=True)
|
||||
ancient = time.time() - const.SCHEDULED_GRACE_S * 12
|
||||
hour_ago = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
month_ago = time.time() - const.SCHEDULED_GRACE_S - 60
|
||||
|
||||
for path, when in (
|
||||
((files / "m1" / "recent.pdf"), hour_ago),
|
||||
((files / "m1" / "ancient.pdf"), month_ago),
|
||||
((files / "m1" / "ancient.pdf"), ancient),
|
||||
((files / "up_abandoned" / "manual.pdf"), hour_ago),
|
||||
):
|
||||
path.write_bytes(b"x")
|
||||
os.utime(path, (when, when))
|
||||
|
||||
cfg = {"markers": [{"id": "m1", "pdfs": []}]}
|
||||
removed = plans.collect_attachments(files, cfg, cfg)
|
||||
assert (files / "m1" / "recent.pdf").is_file(), "an hour is not enough for a marker file"
|
||||
assert not (files / "m1" / "ancient.pdf").exists(), "a month is"
|
||||
assert not (files / "up_abandoned").exists(), "a cancelled dialog still goes after an hour"
|
||||
assert removed == 2
|
||||
assert plans.collect_attachments(files, cfg, cfg) == 1
|
||||
assert (files / "m1" / "ancient.pdf").is_file(), "age alone is never a reason"
|
||||
assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user