mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.47.0: pick a plan you already uploaded
Closes both findings from the v1.46.6 review with one feature, because they are the same gap seen from two sides. HP-1466-02: a detached plan stayed on disk and could not be re-attached from the card — the old url is nowhere in the config, and the backend test 'proved' reattach by remembering it in a Python variable. HP-1466-01: files kept forever with no way to see or remove them is not a policy, it is accumulation. New: houseplan/plans/list (name, url, size, modified, and which spaces use it) and houseplan/plans/delete, which refuses while a space still references the file — the stored configuration answers that, not the client. In the space dialog, 'Already uploaded' shows the list with thumbnails; one click attaches, reading the aspect from the image as an upload does; the trash button is the only way a plan file is ever deleted. That also bounds the disk without any timer, which is the part every automatic attempt got wrong: v1.46.4 deleted detached plans, v1.46.5 raced the retry that was about to reference an upload. The user decides, and can now see what they are deciding about. Docs: comments in plans.py and websocket_api.py still described the age-based collection v1.46.6 removed (report §6); ARCHITECTURE gained the two new routes and an explanation of why the listing is what makes 'never delete' livable.
This commit is contained in:
@@ -31,7 +31,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
|||||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
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.6"
|
VERSION = "1.47.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"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",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.46.6"
|
"version": "1.47.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ from .const import (
|
|||||||
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||||
)
|
)
|
||||||
from .auth import may_write
|
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 .store import HouseplanData, get_data, get_entry
|
||||||
from .validation import (
|
from .validation import (
|
||||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
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_get)
|
||||||
websocket_api.async_register_command(hass, ws_config_set)
|
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_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_migrate)
|
||||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||||
websocket_api.async_register_command(hass, ws_content_sign)
|
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)})
|
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(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "houseplan/content/sign",
|
vol.Required("type"): "houseplan/content/sign",
|
||||||
@@ -480,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
|
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||||
# that is later rejected — revision conflict, validation, lost connection —
|
# that is later rejected — revision conflict, validation, lost connection —
|
||||||
# leaves the stored plan exactly as it was. The card calls
|
# leaves the stored plan exactly as it was. The card calls
|
||||||
# nothing here; the superseded file is collected by `config/set` itself,
|
# nothing here; the file a commit REPLACES is collected by `config/set`
|
||||||
# inside the write lock, once a revision that no longer references it has
|
# itself, inside the write lock (review R3-1). An upload that never gets
|
||||||
# been accepted (review R3-1). A crash in between leaves an orphan, which
|
# committed is not collected at all — it is offered back in the space
|
||||||
# the same collector removes on a later commit once it is old enough.
|
# 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
|
# `.` 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
|
# (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
@@ -178,6 +178,8 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
|||||||
| `houseplan/config/get` | — | `{config, rev}` |
|
| `houseplan/config/get` | — | `{config, rev}` |
|
||||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
| `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/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) |
|
| `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
|
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
|
||||||
@@ -231,6 +233,14 @@ was detached, under documentation promising the opposite (HP-1465-01).
|
|||||||
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
|
| 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 |
|
| 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
|
**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
|
||||||
previous one returned. The debounce still spaces out *when* a write starts;
|
previous one returned. The debounce still spaces out *when* a write starts;
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# 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)
|
## 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
|
- **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
|
v1.46.5 said it did not, and the scheduled cleanup indeed left detached plans
|
||||||
|
|||||||
@@ -6,6 +6,21 @@
|
|||||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||||
|
|
||||||
|
## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов)
|
||||||
|
- **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление
|
||||||
|
плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было
|
||||||
|
только найдя исходный файл у себя и загрузив заново. «Уже загруженные»
|
||||||
|
показывают, что есть: миниатюра, размер файла и то, какое пространство его
|
||||||
|
использует. Клик прикрепляет, пропорции читаются из самой картинки — так же,
|
||||||
|
как при загрузке.
|
||||||
|
- **Там же план и удаляется.** Файл плана никогда не удаляется автоматически —
|
||||||
|
ни за отцепление, ни за возраст, — и это разумная политика ровно до тех пор,
|
||||||
|
пока видно, что именно хранится, и есть способ убрать это осознанно. Кнопка
|
||||||
|
корзины делает это и отказывает, пока план используется пространством: ответ
|
||||||
|
на вопрос «можно ли удалить» даёт сохранённая конфигурация, а не браузер.
|
||||||
|
- Документация догнала код: несколько комментариев всё ещё описывали удаление по
|
||||||
|
возрасту, убранное в v1.46.6.
|
||||||
|
|
||||||
## v1.46.6 — 2026-07-28 (обещание про отцепление, теперь выполненное)
|
## v1.46.6 — 2026-07-28 (обещание про отцепление, теперь выполненное)
|
||||||
- **Переключение пространства в «нарисовать» больше не удаляет его картинку.**
|
- **Переключение пространства в «нарисовать» больше не удаляет его картинку.**
|
||||||
v1.46.4 и v1.46.5 утверждали, что не удаляет, и плановая уборка действительно
|
v1.46.4 и v1.46.5 утверждали, что не удаляет, и плановая уборка действительно
|
||||||
|
|||||||
+2
-2
@@ -15,12 +15,12 @@
|
|||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.46.6** 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) |
|
| 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.6** 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 |
|
| 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,12 @@ 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]
|
||||||
|
- [ ] 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
|
- [ ] 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,
|
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
|
and after a restart, and can be re-attached. Deleting the space keeps it
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.46.6",
|
"version": "1.47.0",
|
||||||
"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",
|
||||||
|
|||||||
+103
-2
@@ -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.6';
|
const CARD_VERSION = '1.47.0';
|
||||||
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';
|
||||||
@@ -277,6 +277,15 @@ class HouseplanCard extends LitElement {
|
|||||||
title: string;
|
title: string;
|
||||||
planUrl: string | null;
|
planUrl: string | null;
|
||||||
planFile: { ext: string; b64: string; aspect: number; name: 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
|
source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms
|
||||||
orientation: 'landscape' | 'portrait' | 'square';
|
orientation: 'landscape' | 'portrait' | 'square';
|
||||||
showBorders: boolean;
|
showBorders: boolean;
|
||||||
@@ -3060,6 +3069,89 @@ class HouseplanCard extends LitElement {
|
|||||||
this._spaceDialog = { ...this._spaceDialog, planFile: { ext, b64, aspect, name: file.name } };
|
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> {
|
private async _saveSpaceDialog(): Promise<void> {
|
||||||
const d = this._spaceDialog;
|
const d = this._spaceDialog;
|
||||||
if (!d || d.busy || !d.title.trim()) return;
|
if (!d || d.busy || !d.title.trim()) return;
|
||||||
@@ -3109,6 +3201,10 @@ class HouseplanCard extends LitElement {
|
|||||||
if (uploaded) {
|
if (uploaded) {
|
||||||
sp.plan_url = uploaded.url;
|
sp.plan_url = uploaded.url;
|
||||||
sp.aspect = uploaded.aspect;
|
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
|
// switching an existing space to "draw" detaches its background image
|
||||||
// (the uploaded file stays on disk; only the reference is cleared)
|
// (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"
|
<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)} />
|
@change=${(e: Event) => this._pickPlanFile(e)} />
|
||||||
</label>
|
</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}
|
: nothing}
|
||||||
<label class="srcrow">
|
<label class="srcrow">
|
||||||
<input type="radio" name="plansrc" .checked=${d.source === 'draw'}
|
<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": "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).",
|
"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}”?",
|
"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": "Это устройство — источник света",
|
||||||
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
|
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
|
||||||
"confirm.unlock": "Открыть замок «{name}»?",
|
"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;
|
align-items: center;
|
||||||
gap: 10px;
|
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 {
|
.planprev {
|
||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
max-height: 70px;
|
max-height: 70px;
|
||||||
|
|||||||
@@ -1112,3 +1112,50 @@ async def test_detaching_a_plan_keeps_the_file(
|
|||||||
await _save(client, await _cfg([]), rev)
|
await _save(client, await _cfg([]), rev)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert await hass.async_add_executor_job(os.path.isfile, os.path.join(plans, name2))
|
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"})
|
||||||
|
plans = {p["name"]: p for p in (await client.receive_json())["result"]["plans"]}
|
||||||
|
assert set(plans) == {used, free}
|
||||||
|
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 list(plans) == [used], "the detached plan is still there, ready to re-attach"
|
||||||
|
assert not plans[used]["used_by"]
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user