mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
+33
-8
@@ -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
@@ -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
@@ -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
@@ -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 ----------------
|
||||
|
||||
Reference in New Issue
Block a user