fix v1.44.2: external review CR-1..CR-3

CR-1: the lock invariant is restated precisely (never by an accidental
tap; the door card's labeled button is the ONE sanctioned surface),
unlocking now confirms, and smoke_lock_invariant exercises all five
actuation paths (icon tap, controls[], card entities, _cardToggle,
opening card).

CR-2: attachment migration is transactional — the server COPIES files,
the config is committed with its revision check, and only then the old
folder is removed via the new houseplan/files/cleanup. A rejected save
no longer leaves the stored urls pointing at an emptied folder.

CR-3: migrate returns an exact {source: written} mapping; only confirmed
copies are rewritten, destination name collisions get a unique name
instead of silently linking a pre-existing file, and a failed migration
raises a toast instead of being swallowed.

+1 unit test (119), +1 backend test, +1 smoke (51 total); docs
same-commit
This commit is contained in:
Matysh
2026-07-27 12:58:27 +03:00
parent 45c863138a
commit ae9168f6ec
18 changed files with 289 additions and 48 deletions
+33 -8
View File
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.44.1';
const CARD_VERSION = '1.44.2';
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';
@@ -2667,14 +2667,23 @@ class HouseplanCard extends LitElement {
const prevRoomId = prevDev?.marker?.room_id ?? null;
const roomChanged = !!dlg.room && prevDev != null
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
// rebinding changed the id → move the uploaded files along (server-side)
// and rewrite the attached urls; otherwise the old-id folder goes orphan
// (that is how the sauna manuals were lost — incident 2026-07-26)
// Rebinding changes the marker id, so the uploaded files must follow.
// Order matters (review CR-2): COPY first, save the config, and only then
// delete the old folder. If the save is rejected, the old urls in the
// stored config still resolve — the files never left. A failed copy
// leaves the urls untouched and tells the user (review CR-3).
let cleanupOldFiles = false;
if (oldId && oldId !== id && marker.pdfs?.length) {
await this.hass
.callWS({ type: 'houseplan/files/migrate', from_id: oldId, to_id: id })
.catch(() => undefined);
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id);
try {
const res: any = await this.hass.callWS({
type: 'houseplan/files/migrate', from_id: oldId, to_id: id,
});
const mapping = res?.mapping || {};
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id, mapping);
cleanupOldFiles = Object.keys(mapping).length > 0;
} catch (e: any) {
this._showToast(this._t('toast.files_migrate_failed', { err: this._errText(e) }));
}
}
// remove the previous marker (by the old id and by the new id)
cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
@@ -2721,6 +2730,12 @@ class HouseplanCard extends LitElement {
delete this._layout[oldId];
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId }).catch(() => undefined);
}
// the config is committed — now it is safe to drop the old folder
if (cleanupOldFiles && oldId) {
await this.hass
.callWS({ type: 'houseplan/files/cleanup', marker_id: oldId })
.catch(() => undefined); // leftovers are harmless; broken links are not
}
this._markerDialog = null;
this._regSignature = '';
this._maybeRebuildDevices();
@@ -4258,6 +4273,16 @@ class HouseplanCard extends LitElement {
* clearly labeled action button — same interaction contract as HA's more-info.
*/
private _lockAction(entityId: string, action: 'lock' | 'unlock'): void {
// THE ONLY sanctioned lock actuation surface (review CR-1, 2026-07-27).
// The invariant is "no lock or alarm panel is ever actuated by a TAP on the
// plan" — icons, badges, controls[] and the device card all refuse. This
// button is a deliberate, labeled control inside an opened card, the same
// contract as Home Assistant's own more-info dialog. Unlocking additionally
// asks for confirmation; locking does not (locking is never destructive).
if (action === 'unlock') {
const name = this.hass?.states?.[entityId]?.attributes?.friendly_name || entityId;
if (!confirm(this._t('confirm.unlock', { name }))) return;
}
this.hass?.callService?.('lock', action, { entity_id: entityId });
}
+3 -1
View File
@@ -327,5 +327,7 @@
"room.settings_short": "Room",
"room.unnamed": "Unnamed room",
"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}”?",
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}"
}
+3 -1
View File
@@ -327,5 +327,7 @@
"room.settings_short": "Комната",
"room.unnamed": "Комната без имени",
"marker.is_light": "Это устройство — источник света",
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами)."
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
"confirm.unlock": "Открыть замок «{name}»?",
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}"
}
+15 -2
View File
@@ -1070,12 +1070,25 @@ export function roomFillModeOf(
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
*/
export function migratePdfUrls<T extends { url: string }>(
pdfs: T[], oldId: string, newId: string,
pdfs: T[], oldId: string, newId: string, mapping?: Record<string, string>,
): T[] {
if (!oldId || !newId || oldId === newId) return pdfs;
const from = '/files/' + oldId + '/';
const to = '/files/' + newId + '/';
return pdfs.map((p) => (p.url.includes(from) ? { ...p, url: p.url.split(from).join(to) } : p));
return pdfs.map((p) => {
if (!p.url.includes(from)) return p;
const tail = p.url.split(from)[1] || '';
const [name, query] = [tail.split('?')[0], tail.includes('?') ? '?' + tail.split('?')[1] : ''];
if (mapping) {
// review CR-3: rewrite ONLY files the server confirmed it copied, and use
// the name it actually wrote (collisions get a unique name). A url that
// was not copied keeps pointing at the still-existing old folder.
const dst = mapping[decodeURIComponent(name)] ?? mapping[name];
if (!dst) return p;
return { ...p, url: p.url.split(from + name)[0] + to + encodeURIComponent(dst) + query };
}
return { ...p, url: p.url.split(from).join(to) };
});
}
// ---------------- kiosk gestures ----------------