mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
На вкладках «На плане» и «Скрытые» — флажки строк, «Выбрать все (N)» по всему отфильтрованному набору (включая строки за «Показать ещё»), панель «Выбрано: K · Скрыть/Показать выбранные (K) · Снять выбор» в отдельном контейнере .device-inbox-batch вне .device-inbox-filters. - src/device-inbox.ts: чистые inboxVisibilityAllowed (один источник для canHide/canShow и выбора), selectableInboxRows, effectiveInboxSelection, applyInboxVisibility — свёртка одиночных действий; одиночное = пакет из одной строки. - src/device-inbox-batch.ts (новый, lazy editor): выбор, панель, строка- флажок и writeInboxVisibility — одна запись houseplan/config/set с expected_rev на пакет, busy='__batch__' (inert), откат маркеров при отказе, выбор сохраняется и сужается. Одиночный _setInboxHidden рантайма — тонкая обёртка над той же записью; рантайм стал короче базы на 14 строк. - сброс выбора при смене вкладки (клик и стрелка), поиска и «Только новые». - i18n en/ru/de/fr: 9 ключей по ТЗ §8. - смок demo/smoke_device_inbox_batch.mjs (AC1–AC8, AC10), юниты test/device-inbox.test.mjs (AC2, AC4), 4 мутанта в реестре. - docs: USER-GUIDE ru/en, FILTERING.md, оба changelog. - monolith-baseline: hostRefs 4948→4959, bundleBytes 2500387→2505569 — новая фича (состояние выбора и запись идут через порт хоста). - bundle-budget: LAZY_EDITOR_GZIP_CEILING 245 400 → 246 600 (замер 245 610, +1 190 Б gzip от базы 244 420); initial View 289 449 → 289 765 (+316 Б, строки en-словаря), в пределах потолка. Golden device-inbox-* изменятся ожидаемо — пересъёмка предрелизным гейтом на Linux CI (§11.4). Issue: #618 User-Visible: yes
353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
/**
|
|
* Pure device-catalog projection for #29.
|
|
*
|
|
* The module deliberately owns neither Lit state nor persistence. The card
|
|
* supplies one registry snapshot and executes the capabilities exposed here
|
|
* through its existing marker/config transactions.
|
|
*/
|
|
import type { HaBindingStatus } from './ha-binding-status';
|
|
import { isRemovedPlanEntity, removedPlanBindings } from './devices';
|
|
import { markerIdForBinding } from './logic';
|
|
import type { DevItem, Marker } from './types';
|
|
|
|
export type DeviceInboxCategory = 'on_plan' | 'available' | 'hidden' | 'readd';
|
|
export type DeviceInboxReason =
|
|
| 'visible_auto' | 'visible_explicit'
|
|
| 'manual_hidden' | 'automatic_hidden'
|
|
| 'service_entry' | 'excluded_integration' | 'excluded_domain'
|
|
| 'grouped_light' | 'represented_by_parent'
|
|
| 'removed' | 'available' | 'no_bound_room';
|
|
|
|
export interface BindingCandidate {
|
|
value: string;
|
|
label: string;
|
|
sub: string;
|
|
kind: 'device' | 'entity';
|
|
ref: string;
|
|
areaId: string;
|
|
model: string;
|
|
parentDeviceId?: string;
|
|
}
|
|
|
|
export interface BindingCandidateLabels {
|
|
device: string;
|
|
z2mGroup: string;
|
|
group: string;
|
|
helper: string;
|
|
entity: string;
|
|
}
|
|
|
|
export interface BindingCandidateInput {
|
|
hass: any;
|
|
devices: readonly DevItem[];
|
|
markers: readonly Marker[];
|
|
showEntities: boolean;
|
|
currentBinding?: string;
|
|
currentDeviceId?: string;
|
|
labels: BindingCandidateLabels;
|
|
}
|
|
|
|
const HELPER_PLATFORMS = new Set([
|
|
'group', 'template', 'derivative', 'min_max', 'threshold', 'integration',
|
|
'statistics', 'trend', 'utility_meter', 'tod', 'switch_as_x', 'schedule',
|
|
]);
|
|
|
|
/** One shared Add/catalog eligibility implementation. Filtering and paging
|
|
* happen after this function so an exact entity can never disappear behind a
|
|
* pre-filter hard cap. */
|
|
export function bindingCandidates(input: BindingCandidateInput): BindingCandidate[] {
|
|
const { hass: h, devices, markers, showEntities, currentBinding, currentDeviceId, labels } = input;
|
|
const removed = removedPlanBindings(markers);
|
|
const removedBindings = new Set(markers.filter((m) => m.removed).map((m) => m.binding));
|
|
const taken = new Set<string>();
|
|
for (const dev of devices) {
|
|
if (dev.id === currentDeviceId) continue;
|
|
if (dev.bindingKind === 'device' && dev.bindingRef) taken.add(`device:${dev.bindingRef}`);
|
|
if (dev.bindingKind === 'entity' && dev.bindingRef) taken.add(`entity:${dev.bindingRef}`);
|
|
}
|
|
|
|
const shownKeys = new Set<string>();
|
|
for (const dev of devices) {
|
|
if (dev.bindingKind === 'device' && dev.name) shownKeys.add(`${dev.name.trim()}|${dev.area || ''}`);
|
|
}
|
|
|
|
const list: BindingCandidate[] = [];
|
|
for (const dev of Object.values<any>(h?.devices || {})) {
|
|
if (!dev || dev.entry_type === 'service') continue;
|
|
const value = `device:${dev.id}`;
|
|
if (taken.has(value)) continue;
|
|
const name = String(dev.name_by_user || dev.name || dev.id).trim();
|
|
if (value !== currentBinding && !removedBindings.has(value)
|
|
&& shownKeys.has(`${name}|${dev.area_id || ''}`)) continue;
|
|
list.push({
|
|
value, label: name,
|
|
sub: (dev.model || labels.device) + (dev.model === 'Group' ? labels.z2mGroup : ''),
|
|
kind: 'device', ref: dev.id, areaId: dev.area_id || '', model: dev.model || '',
|
|
});
|
|
}
|
|
|
|
for (const [eid, reg] of Object.entries<any>(h?.entities || {})) {
|
|
const value = `entity:${eid}`;
|
|
if (taken.has(value)) continue;
|
|
if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value)) continue;
|
|
const isHelper = HELPER_PLATFORMS.has(reg?.platform);
|
|
const isGroup = reg?.platform === 'group';
|
|
if (!isHelper && !isGroup) continue;
|
|
if (reg?.hidden && !removedBindings.has(value)) continue;
|
|
const state = h?.states?.[eid];
|
|
list.push({
|
|
value,
|
|
label: reg?.name || state?.attributes?.friendly_name || eid,
|
|
sub: `${eid.split('.')[0]} · ${isGroup ? labels.group : labels.helper}`,
|
|
kind: 'entity', ref: eid,
|
|
areaId: reg?.area_id || (reg?.device_id && h?.devices?.[reg.device_id]?.area_id) || '',
|
|
model: '', parentDeviceId: reg?.device_id || undefined,
|
|
});
|
|
}
|
|
|
|
if (showEntities) {
|
|
const seen = new Set(list.map((item) => item.value));
|
|
for (const [eid, reg] of Object.entries<any>(h?.entities || {})) {
|
|
const value = `entity:${eid}`;
|
|
if (taken.has(value) || seen.has(value) || (reg?.hidden && !removedBindings.has(value))) continue;
|
|
const childOfRemovedDevice = !!reg?.device_id && removed.devices.has(reg.device_id);
|
|
if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value) && !childOfRemovedDevice) continue;
|
|
const state = h?.states?.[eid];
|
|
const parent = reg?.device_id ? h?.devices?.[reg.device_id] : null;
|
|
const parentName = parent ? String(parent.name_by_user || parent.name || '') : '';
|
|
list.push({
|
|
value,
|
|
label: reg?.name || state?.attributes?.friendly_name || eid,
|
|
sub: `${eid.split('.')[0]} · ${labels.entity}${parentName ? ` · ${parentName}` : ''}`,
|
|
kind: 'entity', ref: eid,
|
|
areaId: reg?.area_id || parent?.area_id || '', model: '',
|
|
parentDeviceId: reg?.device_id || undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
return list.sort((a, b) => a.label.localeCompare(b.label) || a.value.localeCompare(b.value));
|
|
}
|
|
|
|
export interface DeviceInboxRow {
|
|
key: string;
|
|
binding: string;
|
|
category: DeviceInboxCategory;
|
|
status: HaBindingStatus;
|
|
reason: DeviceInboxReason;
|
|
deviceId?: string;
|
|
markerId?: string;
|
|
name: string;
|
|
icon: string;
|
|
model: string;
|
|
integration: string;
|
|
areaId: string;
|
|
areaName: string;
|
|
spaceId: string;
|
|
spaceName: string;
|
|
kind: 'device' | 'entity';
|
|
isNew: boolean;
|
|
searchText: string;
|
|
canFind: boolean;
|
|
canEdit: boolean;
|
|
canHide: boolean;
|
|
canShow: boolean;
|
|
canAdd: boolean;
|
|
}
|
|
|
|
export interface DeviceInboxInput {
|
|
devices: readonly DevItem[];
|
|
markers: readonly Marker[];
|
|
candidates: readonly BindingCandidate[];
|
|
statuses: ReadonlyMap<string, HaBindingStatus>;
|
|
newDeviceIds: ReadonlySet<string>;
|
|
showHiddenOnPlan: boolean;
|
|
areaNames?: Readonly<Record<string, string>>;
|
|
spaceNames?: Readonly<Record<string, string>>;
|
|
spaceByArea?: Readonly<Record<string, string>>;
|
|
integrationByBinding?: Readonly<Record<string, string>>;
|
|
/** Best-effort explanation of an automatically hidden/represented binding. */
|
|
reasonByBinding?: Readonly<Record<string, DeviceInboxReason>>;
|
|
}
|
|
|
|
const ACTIVE_STATUS: HaBindingStatus = {
|
|
kind: 'active', enabledEntityIds: [], allEntityIds: [],
|
|
};
|
|
|
|
function markerBindingKind(binding: string): 'device' | 'entity' {
|
|
return binding.startsWith('entity:') ? 'entity' : 'device';
|
|
}
|
|
|
|
function autoHiddenMarker(marker: Marker): boolean {
|
|
const split = marker.binding.indexOf(':');
|
|
const ref = split >= 0 ? marker.binding.slice(split + 1) : '';
|
|
const meaningful = Object.keys(marker).filter((key) => !['id', 'binding', 'hidden'].includes(key));
|
|
return marker.id === `h${ref}` && meaningful.length === 0;
|
|
}
|
|
|
|
/** Build one deterministic row for every exact binding in the catalog. */
|
|
export function buildDeviceInbox(input: DeviceInboxInput): DeviceInboxRow[] {
|
|
const {
|
|
devices, markers, candidates, statuses, newDeviceIds, showHiddenOnPlan,
|
|
areaNames = {}, spaceNames = {}, spaceByArea = {}, integrationByBinding = {},
|
|
reasonByBinding = {},
|
|
} = input;
|
|
const runtimeByBinding = new Map<string, DevItem>();
|
|
for (const device of devices) {
|
|
if (device.virtual || !device.bindingKind || device.bindingKind === 'virtual' || !device.bindingRef) continue;
|
|
runtimeByBinding.set(`${device.bindingKind}:${device.bindingRef}`, device);
|
|
}
|
|
const liveByBinding = new Map<string, Marker>();
|
|
const removedByBinding = new Map<string, Marker>();
|
|
for (const marker of markers) {
|
|
if (!marker?.binding || marker.binding === 'virtual') continue;
|
|
if (marker.removed) removedByBinding.set(marker.binding, marker);
|
|
else liveByBinding.set(marker.binding, marker);
|
|
}
|
|
const candidateByBinding = new Map(candidates.map((item) => [item.value, item]));
|
|
const keys = new Set<string>([
|
|
...runtimeByBinding.keys(), ...liveByBinding.keys(), ...candidateByBinding.keys(),
|
|
...[...removedByBinding.keys()].filter((key) => candidateByBinding.has(key)),
|
|
]);
|
|
|
|
const rows: DeviceInboxRow[] = [];
|
|
for (const binding of keys) {
|
|
const runtime = runtimeByBinding.get(binding);
|
|
const live = liveByBinding.get(binding);
|
|
const removed = live ? undefined : removedByBinding.get(binding);
|
|
const candidate = candidateByBinding.get(binding);
|
|
let category: DeviceInboxCategory;
|
|
if (removed && candidate) category = 'readd';
|
|
else if (live?.hidden === true) category = 'hidden';
|
|
else if (runtime || live) category = 'on_plan';
|
|
else if (candidate) category = 'available';
|
|
else continue;
|
|
|
|
const status = runtime?.bindingStatus || statuses.get(binding) || ACTIVE_STATUS;
|
|
const marker = live || removed;
|
|
const kind = markerBindingKind(binding);
|
|
const areaId = runtime?.area || candidate?.areaId || marker?.area || '';
|
|
const spaceId = runtime?.space || marker?.space || spaceByArea[areaId] || '';
|
|
const name = runtime?.name || marker?.name || candidate?.label || binding;
|
|
const model = runtime?.model || marker?.model || candidate?.model || '';
|
|
const representedByParent = !!candidate?.parentDeviceId
|
|
&& runtimeByBinding.has(`device:${candidate.parentDeviceId}`);
|
|
const reason: DeviceInboxReason = category === 'readd' ? 'removed'
|
|
: category === 'hidden' ? (live && autoHiddenMarker(live)
|
|
? reasonByBinding[binding] || 'automatic_hidden' : 'manual_hidden')
|
|
: category === 'on_plan' ? (live ? 'visible_explicit' : 'visible_auto')
|
|
: reasonByBinding[binding] || (representedByParent ? 'represented_by_parent'
|
|
: areaId && spaceId ? 'available' : 'no_bound_room');
|
|
const isRendered = !!runtime && (!runtime.hidden || showHiddenOnPlan);
|
|
const active = status.kind === 'active';
|
|
const visibilityAllowed = inboxVisibilityAllowed(category, status);
|
|
const canFind = isRendered && (active
|
|
|| (status.kind === 'ha_disabled' && showHiddenOnPlan));
|
|
const searchText = [
|
|
name, model, integrationByBinding[binding], areaNames[areaId], spaceNames[spaceId],
|
|
binding, candidate?.sub,
|
|
].filter(Boolean).join(' ').toLocaleLowerCase();
|
|
rows.push({
|
|
key: binding, binding, category, status, reason,
|
|
deviceId: runtime?.id,
|
|
markerId: marker?.id,
|
|
name, icon: runtime?.icon || marker?.icon || (kind === 'entity' ? 'mdi:code-braces' : 'mdi:devices'),
|
|
model, integration: integrationByBinding[binding] || '',
|
|
areaId, areaName: areaNames[areaId] || '',
|
|
spaceId, spaceName: spaceNames[spaceId] || '',
|
|
kind, isNew: !!runtime && newDeviceIds.has(runtime.id), searchText,
|
|
canFind,
|
|
canEdit: !!runtime || !!live,
|
|
canHide: category === 'on_plan' && visibilityAllowed,
|
|
canShow: category === 'hidden' && visibilityAllowed,
|
|
canAdd: (category === 'available' || category === 'readd') && active,
|
|
});
|
|
}
|
|
const statusRank = (row: DeviceInboxRow): number => row.status.kind === 'active' ? 1 : 0;
|
|
return rows.sort((a, b) => a.category.localeCompare(b.category)
|
|
|| Number(b.isNew) - Number(a.isNew)
|
|
|| statusRank(a) - statusRank(b)
|
|
|| a.name.localeCompare(b.name) || a.binding.localeCompare(b.binding));
|
|
}
|
|
|
|
export function filterDeviceInbox(
|
|
rows: readonly DeviceInboxRow[], category: DeviceInboxCategory, query: string, onlyNew = false,
|
|
): DeviceInboxRow[] {
|
|
const needle = query.trim().toLocaleLowerCase();
|
|
return rows.filter((row) => row.category === category
|
|
&& (!onlyNew || row.isNew)
|
|
&& (!needle || row.searchText.includes(needle)));
|
|
}
|
|
|
|
/** #618: the two catalog tabs whose rows can be selected for a batch. */
|
|
export type DeviceInboxBatchTab = 'on_plan' | 'hidden';
|
|
|
|
export function isInboxBatchTab(category: DeviceInboxCategory): category is DeviceInboxBatchTab {
|
|
return category === 'on_plan' || category === 'hidden';
|
|
}
|
|
|
|
/** #618 B2: one source for "Hide"/"Show" availability. The single-row
|
|
* buttons (`canHide`/`canShow`) and batch selection both read it, so a row
|
|
* that cannot be hidden alone can never be hidden in a batch either. */
|
|
export function inboxVisibilityAllowed(category: DeviceInboxCategory, status: HaBindingStatus): boolean {
|
|
return isInboxBatchTab(category) && status.kind === 'active';
|
|
}
|
|
|
|
/** #618 B2/B3: rows of an already filtered tab that may be selected. The
|
|
* caller passes the full filtered set (search and "New only" applied, but not
|
|
* the "Show more" limit), so "Select all (N)" covers rows below the fold. */
|
|
export function selectableInboxRows(rows: readonly DeviceInboxRow[]): DeviceInboxRow[] {
|
|
return rows.filter((row) => inboxVisibilityAllowed(row.category, row.status));
|
|
}
|
|
|
|
/** #618 B4/B9: the part of a stored selection that is still actionable, in
|
|
* row order. Keys that stopped being selectable drop out silently. */
|
|
export function effectiveInboxSelection(
|
|
selected: readonly string[] | undefined, selectable: readonly DeviceInboxRow[],
|
|
): DeviceInboxRow[] {
|
|
if (!selected?.length) return [];
|
|
const wanted = new Set(selected);
|
|
return selectable.filter((row) => wanted.has(row.key));
|
|
}
|
|
|
|
export interface InboxVisibilityResult {
|
|
markers: Marker[];
|
|
/** Markers whose saved `hidden` value actually changed (or were created). */
|
|
changed: number;
|
|
}
|
|
|
|
/** #618 B5: apply Hide/Show to exact bindings. A single-row action is a
|
|
* batch of one; a batch equals the left fold of single-row actions, so the
|
|
* config is the same whichever path wrote it.
|
|
*
|
|
* - Show keeps the marker with `hidden: false` (an automatically hidden stub
|
|
* is kept too; it is the anti-reseed guard of docs/FILTERING.md);
|
|
* - Hide of an automatic device without a marker creates `{id, binding,
|
|
* hidden: true}` with the id of `markerIdForBinding`;
|
|
* - only `hidden` changes on a marker with settings;
|
|
* - an exact binding never ends up with two live markers. */
|
|
export function applyInboxVisibility(
|
|
markers: readonly Marker[],
|
|
rows: readonly Pick<DeviceInboxRow, 'binding' | 'markerId'>[],
|
|
hidden: boolean,
|
|
newId: () => string,
|
|
): InboxVisibilityResult {
|
|
let next: Marker[] = [...markers];
|
|
let changed = 0;
|
|
for (const row of rows) {
|
|
const live = next.find((marker) => !marker.removed && marker.binding === row.binding);
|
|
if (!hidden && !live) continue;
|
|
const id = live?.id || markerIdForBinding(row.binding, row.markerId, newId);
|
|
const updated: Marker = live
|
|
? { ...live, hidden }
|
|
: { id, binding: row.binding, hidden: true };
|
|
if (!live || live.hidden !== hidden) changed += 1;
|
|
next = [
|
|
...next.filter((marker) => marker.id !== id
|
|
&& (marker.binding !== row.binding || marker.removed === true)),
|
|
updated,
|
|
];
|
|
}
|
|
return { markers: next, changed };
|
|
}
|