mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
fix v1.41.2: uploaded files survive marker rebinding
- new WS houseplan/files/migrate moves /files/<oldId>/ to the new id (admin-only, sanitized ids, merge-safe, removes the empty old dir) - _saveMarker calls it and rewrites pdf urls (migratePdfUrls pure helper, +1 unit test, 112) when rebinding changes the id - field incident: the sauna heater manuals pointed at an orphaned old-id folder that got cleaned up; data restored by hand on the home instance (official Harvia PDFs re-downloaded) - TESTING/CHANGELOG same-commit
This commit is contained in:
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
|||||||
FILES_URL = "/houseplan_files/files"
|
FILES_URL = "/houseplan_files/files"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.41.1"
|
VERSION = "1.41.2"
|
||||||
|
|
||||||
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.41.1"
|
"version": "1.41.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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_files_migrate)
|
||||||
|
|
||||||
|
|
||||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||||
@@ -108,6 +109,61 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
connection.send_result(msg["id"], {"ok": True})
|
connection.send_result(msg["id"], {"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "houseplan/files/migrate",
|
||||||
|
vol.Required("from_id"): str,
|
||||||
|
vol.Required("to_id"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||||
|
"""Move a marker's uploaded files to its new id (rebinding changes the id).
|
||||||
|
|
||||||
|
Without this the PDF urls keep pointing at the OLD id's folder, which then
|
||||||
|
looks orphaned and is one cleanup away from deletion — the exact way the
|
||||||
|
owner lost the sauna heater manuals (field incident, 2026-07-26).
|
||||||
|
"""
|
||||||
|
if not _check_write(hass, connection):
|
||||||
|
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
|
||||||
|
return
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from .const import FILES_DIR
|
||||||
|
from .validation import sanitize_marker_id
|
||||||
|
|
||||||
|
src_id = sanitize_marker_id(msg["from_id"])
|
||||||
|
dst_id = sanitize_marker_id(msg["to_id"])
|
||||||
|
if not src_id or not dst_id or src_id == dst_id:
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "moved": 0})
|
||||||
|
return
|
||||||
|
base = Path(hass.config.path(FILES_DIR))
|
||||||
|
src = base / src_id
|
||||||
|
dst = base / dst_id
|
||||||
|
|
||||||
|
def _move() -> int:
|
||||||
|
if not src.is_dir():
|
||||||
|
return 0
|
||||||
|
dst.mkdir(parents=True, exist_ok=True)
|
||||||
|
n = 0
|
||||||
|
for f in src.iterdir():
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
target = dst / f.name
|
||||||
|
if not target.exists():
|
||||||
|
shutil.move(str(f), str(target))
|
||||||
|
n += 1
|
||||||
|
try:
|
||||||
|
src.rmdir() # only when empty
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return n
|
||||||
|
|
||||||
|
moved = await hass.async_add_executor_job(_move)
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "moved": moved})
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "houseplan/layout/delete",
|
vol.Required("type"): "houseplan/layout/delete",
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.41.2 — 2026-07-26
|
||||||
|
- Fixed attached PDF manuals breaking after a marker is rebound to another
|
||||||
|
device: the id changes, but the uploaded files stayed in the OLD id's
|
||||||
|
folder — orphaned and eventually lost (that is exactly how the sauna
|
||||||
|
heater's manuals died). Rebinding now moves the files server-side
|
||||||
|
(`houseplan/files/migrate`) and rewrites the attached urls.
|
||||||
|
|
||||||
## v1.41.1 — 2026-07-24 (docs)
|
## v1.41.1 — 2026-07-24 (docs)
|
||||||
- README (en/ru) reworked for discoverability: keyword-rich hero ("interactive
|
- README (en/ru) reworked for discoverability: keyword-rich hero ("interactive
|
||||||
floor plan card for Home Assistant"), badges, a feature-highlights list
|
floor plan card for Home Assistant"), badges, a feature-highlights list
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
(explicit ripple color still wins); off/white lights unchanged [auto]
|
(explicit ripple color still wins); off/white lights unchanged [auto]
|
||||||
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
||||||
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
||||||
|
- [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached
|
||||||
|
PDFs to another device — the server moves /files/<oldId>/ to the new id
|
||||||
|
and the links keep opening; the old folder disappears (no orphans) [auto]
|
||||||
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
|
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
|
||||||
editor (admins incl.), full-height stage; swipe left/right switches
|
editor (admins incl.), full-height stage; swipe left/right switches
|
||||||
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
|
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.41.1",
|
"version": "1.41.2",
|
||||||
"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",
|
||||||
|
|||||||
+12
-2
@@ -14,7 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
|
||||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale,
|
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls,
|
||||||
snapToWall, openingAmount,
|
snapToWall, openingAmount,
|
||||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||||
@@ -32,7 +32,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.41.1';
|
const CARD_VERSION = '1.41.2';
|
||||||
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';
|
||||||
@@ -2459,6 +2459,7 @@ class HouseplanCard extends LitElement {
|
|||||||
angle: dlg.angle ? dlg.angle : null,
|
angle: dlg.angle ? dlg.angle : null,
|
||||||
tap_action: dlg.tapAction || null,
|
tap_action: dlg.tapAction || null,
|
||||||
controls: dlg.controls.length ? dlg.controls : null,
|
controls: dlg.controls.length ? dlg.controls : null,
|
||||||
|
// pdfs may be rewritten below when rebinding changes the marker id
|
||||||
glow_radius_cm: (() => {
|
glow_radius_cm: (() => {
|
||||||
const v = parseFloat(dlg.glowRadius);
|
const v = parseFloat(dlg.glowRadius);
|
||||||
if (!Number.isFinite(v) || v <= 0) return null;
|
if (!Number.isFinite(v) || v <= 0) return null;
|
||||||
@@ -2480,6 +2481,15 @@ class HouseplanCard extends LitElement {
|
|||||||
const prevRoomId = prevDev?.marker?.room_id ?? null;
|
const prevRoomId = prevDev?.marker?.room_id ?? null;
|
||||||
const roomChanged = !!dlg.room && prevDev != null
|
const roomChanged = !!dlg.room && prevDev != null
|
||||||
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
|
&& (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)
|
||||||
|
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);
|
||||||
|
}
|
||||||
// remove the previous marker (by the old id and by the new id)
|
// 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);
|
cfg.markers = cfg.markers.filter((m) => m.id !== id && m.id !== oldId);
|
||||||
cfg.markers.push(marker);
|
cfg.markers.push(marker);
|
||||||
|
|||||||
@@ -965,6 +965,21 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
|
|||||||
return cutSegments(edges, cuts, eps);
|
return cutSegments(edges, cuts, eps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- marker files ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite attached-file urls when a marker's id changes (rebinding): the
|
||||||
|
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
|
||||||
|
*/
|
||||||
|
export function migratePdfUrls<T extends { url: string }>(
|
||||||
|
pdfs: T[], oldId: string, newId: 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));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- kiosk gestures ----------------
|
// ---------------- kiosk gestures ----------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
outlineWithout,
|
outlineWithout,
|
||||||
alignGuides, segmentAngle, is45,
|
alignGuides, segmentAngle, is45,
|
||||||
swipeTarget, clampScale,
|
swipeTarget, clampScale,
|
||||||
|
migratePdfUrls,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
@@ -787,3 +788,15 @@ test('clampScale', () => {
|
|||||||
assert.equal(clampScale('x'), 1);
|
assert.equal(clampScale('x'), 1);
|
||||||
assert.equal(clampScale(undefined, 1.5), 1.5);
|
assert.equal(clampScale(undefined, 1.5), 1.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('migratePdfUrls: rebinding rewrites file urls', () => {
|
||||||
|
const pdfs = [
|
||||||
|
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
|
||||||
|
{ name: 'b.pdf', url: '/houseplan_files/files/other/b.pdf?v=2' },
|
||||||
|
];
|
||||||
|
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99');
|
||||||
|
assert.equal(out[0].url, '/houseplan_files/files/dev99/a.pdf?v=1');
|
||||||
|
assert.equal(out[1].url, pdfs[1].url); // чужие пути не трогаем
|
||||||
|
// без смены id — как есть
|
||||||
|
assert.equal(migratePdfUrls(pdfs, 'x', 'x'), pdfs);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user