mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-25 05:03:58 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 824ab4fbe0 |
@@ -0,0 +1,85 @@
|
||||
import { stripBoundaryLabel } from "../string/strip_boundary_label";
|
||||
|
||||
export interface AreaForNameMatch {
|
||||
area_id: string;
|
||||
name: string;
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
export interface DeviceAreaSuggestion {
|
||||
name: string;
|
||||
area?: string;
|
||||
}
|
||||
|
||||
const areaLabels = (area: AreaForNameMatch): string[] =>
|
||||
[area.name, ...(area.aliases ?? [])].filter(Boolean);
|
||||
|
||||
// Longest matching label (name or alias) of a single area, and the device name
|
||||
// once that label is stripped off. `null` when the area does not match.
|
||||
const matchArea = (
|
||||
name: string,
|
||||
area: AreaForNameMatch
|
||||
): { strippedName: string; labelLength: number } | null => {
|
||||
let best: { strippedName: string; labelLength: number } | null = null;
|
||||
for (const label of areaLabels(area)) {
|
||||
const strippedName = stripBoundaryLabel(name, label);
|
||||
if (strippedName === null) {
|
||||
continue;
|
||||
}
|
||||
if (!best || label.length > best.labelLength) {
|
||||
best = { strippedName, labelLength: label.length };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
// The area with the longest matching label, and the stripped device name.
|
||||
const bestAreaMatch = (
|
||||
name: string,
|
||||
areas: AreaForNameMatch[]
|
||||
): { areaId: string; strippedName: string; labelLength: number } | null => {
|
||||
let best: {
|
||||
areaId: string;
|
||||
strippedName: string;
|
||||
labelLength: number;
|
||||
} | null = null;
|
||||
for (const area of areas) {
|
||||
const match = matchArea(name, area);
|
||||
if (match && (!best || match.labelLength > best.labelLength)) {
|
||||
best = { areaId: area.area_id, ...match };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
/**
|
||||
* Suggests a cleaned device name (and an area) when an area name or alias is a
|
||||
* prefix or suffix of the device name.
|
||||
*
|
||||
* - When the device already has an area, only match against that area, so an
|
||||
* assigned area is never overridden — just the name is cleaned.
|
||||
* - Otherwise pick the area with the longest matching name/alias and suggest it.
|
||||
* - The longest match wins with no fallback: if the name is exactly the area,
|
||||
* nothing is left to keep and the result is a no-op (`null`).
|
||||
*/
|
||||
export const computeDeviceAreaSuggestion = (
|
||||
deviceName: string | null | undefined,
|
||||
currentAreaId: string | null | undefined,
|
||||
areas: AreaForNameMatch[]
|
||||
): DeviceAreaSuggestion | null => {
|
||||
const name = deviceName?.trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentAreaId) {
|
||||
const area = areas.find((a) => a.area_id === currentAreaId);
|
||||
const match = area ? matchArea(name, area) : null;
|
||||
return match?.strippedName ? { name: match.strippedName } : null;
|
||||
}
|
||||
|
||||
const match = bestAreaMatch(name, areas);
|
||||
return match?.strippedName
|
||||
? { name: match.strippedName, area: match.areaId }
|
||||
: null;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
const SEPARATORS = "\\s\\-_.";
|
||||
const LEADING_SEPARATORS = new RegExp(`^[${SEPARATORS}]+`);
|
||||
const TRAILING_SEPARATORS = new RegExp(`[${SEPARATORS}]+$`);
|
||||
const STARTS_WITH_SEPARATOR = new RegExp(`^[${SEPARATORS}]`);
|
||||
const ENDS_WITH_SEPARATOR = new RegExp(`[${SEPARATORS}]$`);
|
||||
|
||||
/**
|
||||
* Strips `label` from the start or end of `text` on a word boundary,
|
||||
* case-insensitively, and trims the surrounding separators.
|
||||
*
|
||||
* Returns:
|
||||
* - `""` when `text` equals `label`,
|
||||
* - the trimmed remainder for a prefix or suffix match,
|
||||
* - `null` when `label` is not a word-boundary prefix or suffix of `text`.
|
||||
*/
|
||||
export const stripBoundaryLabel = (
|
||||
text: string,
|
||||
label: string
|
||||
): string | null => {
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerLabel = label.toLowerCase();
|
||||
|
||||
if (lowerText === lowerLabel) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (lowerText.startsWith(lowerLabel)) {
|
||||
const rest = text.slice(label.length);
|
||||
if (STARTS_WITH_SEPARATOR.test(rest)) {
|
||||
return rest.replace(LEADING_SEPARATORS, "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerText.endsWith(lowerLabel)) {
|
||||
const rest = text.slice(0, text.length - label.length);
|
||||
if (ENDS_WITH_SEPARATOR.test(rest)) {
|
||||
return rest.replace(TRAILING_SEPARATORS, "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeDeviceAreaSuggestion } from "../../common/entity/compute_device_area_suggestion";
|
||||
import {
|
||||
computeDeviceName,
|
||||
computeDeviceNameDisplay,
|
||||
@@ -72,6 +73,10 @@ class StepFlowCreateEntry extends LitElement {
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("devices")) {
|
||||
this._suggestDeviceUpdates();
|
||||
}
|
||||
|
||||
if (!changedProps.has("devices") && !changedProps.has("hass")) {
|
||||
return;
|
||||
}
|
||||
@@ -103,6 +108,32 @@ class StepFlowCreateEntry extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _suggestDeviceUpdates() {
|
||||
if (!this.hass || !this.devices?.length) {
|
||||
return;
|
||||
}
|
||||
const areas = Object.values(this.hass.areas);
|
||||
const updates = { ...this._deviceUpdate };
|
||||
let changed = false;
|
||||
for (const device of this.devices) {
|
||||
if (device.id in updates || device.name_by_user) {
|
||||
continue;
|
||||
}
|
||||
const suggestion = computeDeviceAreaSuggestion(
|
||||
computeDeviceName(device),
|
||||
device.area_id,
|
||||
areas
|
||||
);
|
||||
if (suggestion) {
|
||||
updates[device.id] = suggestion;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this._deviceUpdate = updates;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const localize = this.hass.localize;
|
||||
const domains = this.step.result
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
computeDeviceAreaSuggestion,
|
||||
type AreaForNameMatch,
|
||||
} from "../../../src/common/entity/compute_device_area_suggestion";
|
||||
|
||||
const AREAS: AreaForNameMatch[] = [
|
||||
{ area_id: "living_room", name: "Living Room", aliases: ["Lounge"] },
|
||||
{ area_id: "kitchen", name: "Kitchen" },
|
||||
{ area_id: "master", name: "Master" },
|
||||
{ area_id: "master_bedroom", name: "Master Bedroom" },
|
||||
];
|
||||
|
||||
describe("computeDeviceAreaSuggestion", () => {
|
||||
describe("device without an area", () => {
|
||||
it("strips a prefix area and suggests it", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Living Room Thermostat", null, AREAS)
|
||||
).toEqual({ name: "Thermostat", area: "living_room" });
|
||||
});
|
||||
|
||||
it("strips a suffix area and suggests it", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Thermostat Living Room", null, AREAS)
|
||||
).toEqual({ name: "Thermostat", area: "living_room" });
|
||||
});
|
||||
|
||||
it("matches an area alias", () => {
|
||||
expect(computeDeviceAreaSuggestion("Lounge Lamp", null, AREAS)).toEqual({
|
||||
name: "Lamp",
|
||||
area: "living_room",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the longest matching area name", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Master Bedroom Lamp", null, AREAS)
|
||||
).toEqual({ name: "Lamp", area: "master_bedroom" });
|
||||
});
|
||||
|
||||
it("is a no-op when the name equals an area and does not fall back to a shorter one", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Master Bedroom", null, AREAS)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not match in the middle of a word", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Kitchenette Sensor", null, AREAS)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("matches case-insensitively and keeps the remainder's casing", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("living room thermostat", null, AREAS)
|
||||
).toEqual({ name: "thermostat", area: "living_room" });
|
||||
});
|
||||
|
||||
it("trims residual separators after stripping", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Living Room - Thermostat", null, AREAS)
|
||||
).toEqual({ name: "Thermostat", area: "living_room" });
|
||||
});
|
||||
|
||||
it("does nothing when no area matches", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Random Device", null, AREAS)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("device with an area already set", () => {
|
||||
it("strips the name but keeps the area when it matches", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Kitchen Sensor", "kitchen", AREAS)
|
||||
).toEqual({ name: "Sensor" });
|
||||
});
|
||||
|
||||
it("never overrides the area when the name does not match it", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Living Room Sensor", "kitchen", AREAS)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is a no-op when the name equals its own area", () => {
|
||||
expect(
|
||||
computeDeviceAreaSuggestion("Kitchen", "kitchen", AREAS)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing for an empty name", () => {
|
||||
expect(computeDeviceAreaSuggestion(" ", null, AREAS)).toBeNull();
|
||||
expect(computeDeviceAreaSuggestion(undefined, null, AREAS)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { stripBoundaryLabel } from "../../../src/common/string/strip_boundary_label";
|
||||
|
||||
describe("stripBoundaryLabel", () => {
|
||||
it("returns an empty string when the text equals the label", () => {
|
||||
expect(stripBoundaryLabel("Kitchen", "Kitchen")).toBe("");
|
||||
});
|
||||
|
||||
it("strips a prefix on a word boundary", () => {
|
||||
expect(stripBoundaryLabel("Living Room Thermostat", "Living Room")).toBe(
|
||||
"Thermostat"
|
||||
);
|
||||
});
|
||||
|
||||
it("strips a suffix on a word boundary", () => {
|
||||
expect(stripBoundaryLabel("Thermostat Living Room", "Living Room")).toBe(
|
||||
"Thermostat"
|
||||
);
|
||||
});
|
||||
|
||||
it("is case-insensitive and keeps the remainder's casing", () => {
|
||||
expect(stripBoundaryLabel("living room Thermostat", "Living Room")).toBe(
|
||||
"Thermostat"
|
||||
);
|
||||
});
|
||||
|
||||
it("trims different separators", () => {
|
||||
expect(stripBoundaryLabel("Kitchen - Sensor", "Kitchen")).toBe("Sensor");
|
||||
expect(stripBoundaryLabel("Kitchen_Sensor", "Kitchen")).toBe("Sensor");
|
||||
expect(stripBoundaryLabel("Kitchen.Sensor", "Kitchen")).toBe("Sensor");
|
||||
});
|
||||
|
||||
it("does not match in the middle of a word", () => {
|
||||
expect(stripBoundaryLabel("Kitchenette Sensor", "Kitchen")).toBeNull();
|
||||
expect(stripBoundaryLabel("Sub Kitchenette", "Kitchen")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the label is absent", () => {
|
||||
expect(stripBoundaryLabel("Living Room Sensor", "Kitchen")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an empty string when only separators are left", () => {
|
||||
expect(stripBoundaryLabel("Kitchen -", "Kitchen")).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user