mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
feat: add complete German localization
Add Deutsch across all card surfaces and backend flows, backed by the language registry introduced in #62. German loads as a fingerprint-checked page-shared locale chunk so EN/RU remain synchronous and the initial View budget stays intact. Root render gates prevent mixed-language flashes, retry once, and fail open to English. Extend parity, runtime, bundle, browser and visual coverage, plus contributor and user documentation. Issue: #348 User-Visible: yes
This commit is contained in:
+8
-2
@@ -1,7 +1,8 @@
|
||||
/** Card configuration editor (Lovelace GUI). */
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import { LitElement, html, nothing, noChange } from 'lit';
|
||||
import { langOf, t, type Lang } from './i18n';
|
||||
import { languageOptions } from './i18n/registry';
|
||||
import { LANGUAGE_RUNTIME, languageOptions } from './i18n/registry';
|
||||
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
|
||||
import { invalidDefaultFloor } from './card-editor-validation';
|
||||
|
||||
class HouseplanCardEditor extends LitElement {
|
||||
@@ -113,6 +114,11 @@ class HouseplanCardEditor extends LitElement {
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) return nothing;
|
||||
const localeGate = languageRenderGate(
|
||||
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
|
||||
);
|
||||
if (localeGate === 'cold') return languageLoadingTemplate();
|
||||
if (localeGate === 'warm') return noChange;
|
||||
this._loadSpaces();
|
||||
const L = this._lang;
|
||||
const labels: Record<string, string> = {
|
||||
|
||||
+10
-3
@@ -6,7 +6,7 @@
|
||||
* 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas.
|
||||
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
|
||||
*/
|
||||
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
|
||||
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues } from 'lit';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import './hp-dialog';
|
||||
@@ -232,6 +232,8 @@ import {
|
||||
} from './coordinate-canonicalization';
|
||||
import { enqueueSerializedWrite } from './serialized-write-queue';
|
||||
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
|
||||
import { LANGUAGE_RUNTIME } from './i18n/registry';
|
||||
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
|
||||
import { CommandStack } from './command-stack';
|
||||
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
|
||||
import {
|
||||
@@ -10700,7 +10702,13 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
protected render(): TemplateResult | typeof nothing | typeof noChange {
|
||||
if (!this._config || !this.hass) return nothing;
|
||||
const localeGate = languageRenderGate(
|
||||
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
|
||||
);
|
||||
if (localeGate === 'cold') return languageLoadingTemplate();
|
||||
if (localeGate === 'warm') return noChange;
|
||||
const onboardingRuntimeRequested = !!this._importDialog
|
||||
|| !!(this._spaceDialog && this._spaceDialogUsesOnboardingRuntime(this._spaceDialog.mode));
|
||||
const editorRuntimeRequested = this._mode !== 'view'
|
||||
@@ -10717,7 +10725,6 @@ export class HouseplanCard extends LitElement {
|
||||
void this._ensureOnboardingRuntime();
|
||||
}
|
||||
if (editorRuntimeRequested && !this._editorRuntime) void this._ensureEditorRuntime();
|
||||
if (!this._config || !this.hass) return nothing;
|
||||
const model = this._model;
|
||||
const diagnostics = this.houseplanDiagnostics();
|
||||
const fixed = this._fixedFloorState(model);
|
||||
|
||||
+4
-4
@@ -9,7 +9,7 @@ import {
|
||||
FALLBACK_DICTIONARY,
|
||||
FALLBACK_LANGUAGE_CODE,
|
||||
LANGUAGE_REGISTRY,
|
||||
languageEntry,
|
||||
dictionaryFor,
|
||||
resolveLanguageCode,
|
||||
type Lang,
|
||||
} from './i18n/registry';
|
||||
@@ -30,13 +30,13 @@ export function langOf(hass: any, configLang?: string | null): Lang {
|
||||
|
||||
/** Translate a key with optional {placeholder} substitution. */
|
||||
export function t(lang: Lang, key: Key, vars?: Record<string, string | number>): string {
|
||||
const dictionary = languageEntry(lang)?.dictionary;
|
||||
return subst(dictionary?.[key] ?? FALLBACK_DICTIONARY[key] ?? key, vars);
|
||||
const dictionary = dictionaryFor(lang);
|
||||
return subst(dictionary[key] ?? FALLBACK_DICTIONARY[key] ?? key, vars);
|
||||
}
|
||||
|
||||
/** Whether a localized value exists and contains useful text after fallback. */
|
||||
export function hasTranslation(lang: Lang, key: string): boolean {
|
||||
const value = languageEntry(lang)?.dictionary[key] ?? FALLBACK_DICTIONARY[key];
|
||||
const value = dictionaryFor(lang)[key] ?? FALLBACK_DICTIONARY[key];
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
|
||||
+1061
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
import dictionary from './de.json' with { type: 'json' };
|
||||
|
||||
/** Must match the entry bundle before a lazy locale can be committed. */
|
||||
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
|
||||
export { dictionary };
|
||||
|
||||
@@ -71,8 +71,6 @@
|
||||
"title.zoom_in": "Zoom in",
|
||||
"title.zoom_out": "Zoom out",
|
||||
"title.zoom_fit": "Fit all",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show hidden and disabled devices as service markers (this tab only)",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
@@ -127,7 +125,6 @@
|
||||
"history.decor_transform": "Transform decor object",
|
||||
"history.decor_delete": "Delete decor object",
|
||||
"history.backdrop_transform": "Transform plan backdrop",
|
||||
"title.markup_opening": "Openings: click a wall to place, click an opening to edit",
|
||||
"opening.new": "New opening",
|
||||
"opening.edit": "Opening",
|
||||
"opening.door": "Door",
|
||||
@@ -212,8 +209,6 @@
|
||||
"marker.preview.multiple_sources": "{n} sources",
|
||||
"marker.preview.more_sources": "+{n} more",
|
||||
"marker.preview.scaled": "The preview is scaled to {n}% to fit. The saved size is unchanged.",
|
||||
"marker.preview.demo_activity": "Show activity example",
|
||||
"marker.preview.demo_notice": "Temporary activity example; the actual device state is unchanged.",
|
||||
"marker.preview.demo_short": "Show short activity",
|
||||
"marker.preview.demo_continuous": "Show continuous activity",
|
||||
"marker.preview.stop_continuous": "Stop continuous activity",
|
||||
@@ -294,7 +289,6 @@
|
||||
"confirm.delete_room": "Delete room \"{name}\"?",
|
||||
"confirm.delete_room_title": "Delete room \"{name}\"?",
|
||||
"confirm.delete_room_body": "Choose whether the room's exclusive physical walls should remain. Walls shared with another room are always kept.",
|
||||
"confirm.merge_rooms": "Merge rooms \"{a}\" and \"{b}\"?",
|
||||
"confirm.remove_marker": "Delete \"{name}\" from the plan? The device will disappear completely and stop contributing to plan data. You can add it again later.",
|
||||
"confirm.erase_decor": "Erase the {kind} object? You can undo this action from the editor history.",
|
||||
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
|
||||
@@ -311,7 +305,6 @@
|
||||
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
|
||||
"toast.cfg_save_failed": "Failed to save config: {err}",
|
||||
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
|
||||
"toast.contour_min_edges": "Draw at least two edges before closing the outline",
|
||||
"toast.contour_cannot_close": "The outline cannot close because it is degenerate or intersects itself",
|
||||
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
|
||||
"toast.rooms_merged": "Rooms merged into “{name}”",
|
||||
@@ -321,7 +314,6 @@
|
||||
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
|
||||
"merge.keep": "Keep",
|
||||
"merge.no_area": "no area",
|
||||
"room.split_header": "New room from the split",
|
||||
"toast.room_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
|
||||
"toast.room_saved_no_area": "Room saved ({n}, no area). Outline the next one or exit markup.",
|
||||
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
|
||||
@@ -361,8 +353,6 @@
|
||||
"editor.show_signal": "Show zigbee signal (LQI)",
|
||||
"editor.language": "Interface language",
|
||||
"editor.lang_auto": "Auto (HA profile)",
|
||||
"editor.lang_en": "English",
|
||||
"editor.lang_ru": "Русский",
|
||||
"title.icon_rules": "Icon rules: which MDI icon devices get by name",
|
||||
"rules.title": "Icon rules",
|
||||
"rules.hint": "Rules are checked top-down against “device name + model” (case-insensitive regex); the first match wins. When nothing matches, the entity device class decides, then the generic chip icon.",
|
||||
@@ -407,13 +397,7 @@
|
||||
"space.custom_fill": "Fill color",
|
||||
"space.source_file": "I have a floor-plan image",
|
||||
"space.source_draw": "No image — I'll outline rooms by hand",
|
||||
"space.orientation": "Canvas",
|
||||
"orient.landscape": "Landscape",
|
||||
"orient.portrait": "Portrait",
|
||||
"orient.square": "Square",
|
||||
"fill.temp": "Temperature",
|
||||
"space.temp_min": "Comfort from",
|
||||
"space.temp_max": "to",
|
||||
"tip.temp_avg": "average temperature:",
|
||||
"tip.hum_avg": "average humidity:",
|
||||
"space_card.button": "Open the space plan",
|
||||
@@ -457,8 +441,6 @@
|
||||
"opening.lock_action": "Lock",
|
||||
"opening.lock_pending": "Working…",
|
||||
"title.close_editor": "Close editor (back to view)",
|
||||
"devbar.add": "Add",
|
||||
"devbar.show_all": "Hidden and disabled",
|
||||
"devbar.rules": "Icon rules",
|
||||
"device_inbox.button": "Devices",
|
||||
"device_inbox.title": "Devices on the plan",
|
||||
@@ -734,7 +716,6 @@
|
||||
"marker.ha_registry_limited": "The full Home Assistant registry is unavailable to this user. The unverified object cannot be shown or used for now.",
|
||||
"marker.delete_tip": "Completely delete the device from the plan and every aggregate. You can add it again later.",
|
||||
"tap.run": "Run automation/script/scene",
|
||||
"tap.cover": "Open/close (curtains/blinds)",
|
||||
"marker.run_target_label": "What to run",
|
||||
"marker.run_search_ph": "Search: automation, script or scene…",
|
||||
"marker.run_target_gone": "Target {id} not found — pick again",
|
||||
@@ -772,7 +753,6 @@
|
||||
"run.scene": "scene",
|
||||
"confirm.tap_run": "Run \"{name}\"?",
|
||||
"confirm.tap_toggle": "Toggle \"{name}\"?",
|
||||
"confirm.tap_cover": "Open/close \"{name}\"?",
|
||||
"confirm.current_state": "Current state: {state}",
|
||||
"confirm.expected_state": "After switching: {state}",
|
||||
"confirm.group_current": "on {on} of {total}",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
|
||||
export type LocaleDictionary = Record<string, string>;
|
||||
|
||||
export interface LazyLanguageModule {
|
||||
dictionary: LocaleDictionary;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export interface RuntimeLanguageEntry {
|
||||
code: string;
|
||||
dictionary?: LocaleDictionary;
|
||||
loadDictionary?: (attempt: 0 | 1) => Promise<LazyLanguageModule>;
|
||||
}
|
||||
|
||||
export type LanguageLoadState = 'ready' | 'pending' | 'fallback';
|
||||
|
||||
export interface LanguageRuntimeContract {
|
||||
state(code: string): LanguageLoadState;
|
||||
dictionary(code: string): LocaleDictionary | undefined;
|
||||
ensure(code: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-scoped dictionary cache shared by all House Plan cards and editors.
|
||||
*
|
||||
* `fallback` is a settled state: a failed optional locale must never leave a
|
||||
* card behind an endless loading surface. The synchronous translation helper
|
||||
* then uses English in the ordinary way.
|
||||
*/
|
||||
export class LanguageRuntime {
|
||||
private readonly dictionaries = new Map<string, LocaleDictionary>();
|
||||
private readonly pending = new Map<string, Promise<void>>();
|
||||
private readonly failed = new Set<string>();
|
||||
|
||||
public constructor(
|
||||
private readonly entries: readonly RuntimeLanguageEntry[],
|
||||
private readonly expectedFingerprint: string,
|
||||
private readonly warn: (message: string, error: unknown) => void = console.warn,
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
if (entry.dictionary) this.dictionaries.set(entry.code, entry.dictionary);
|
||||
}
|
||||
}
|
||||
|
||||
public state(code: string): LanguageLoadState {
|
||||
if (this.dictionaries.has(code)) return 'ready';
|
||||
if (this.failed.has(code)) return 'fallback';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
public dictionary(code: string): LocaleDictionary | undefined {
|
||||
return this.dictionaries.get(code);
|
||||
}
|
||||
|
||||
public ensure(code: string): Promise<void> {
|
||||
if (this.state(code) !== 'pending') return Promise.resolve();
|
||||
const existing = this.pending.get(code);
|
||||
if (existing) return existing;
|
||||
const entry = this.entries.find((candidate) => candidate.code === code);
|
||||
const task = this.load(entry).finally(() => this.pending.delete(code));
|
||||
this.pending.set(code, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
private async load(entry: RuntimeLanguageEntry | undefined): Promise<void> {
|
||||
if (!entry?.loadDictionary) {
|
||||
if (entry) this.failed.add(entry.code);
|
||||
return;
|
||||
}
|
||||
let lastError: unknown;
|
||||
for (const attempt of [0, 1] as const) {
|
||||
try {
|
||||
const loaded = await entry.loadDictionary(attempt);
|
||||
if (loaded.fingerprint !== this.expectedFingerprint) {
|
||||
throw new Error(`locale fingerprint mismatch for ${entry.code}`);
|
||||
}
|
||||
this.dictionaries.set(entry.code, loaded.dictionary);
|
||||
this.failed.delete(entry.code);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
this.failed.add(entry.code);
|
||||
this.warn(`[houseplan] unable to load ${entry.code} locale; using English`, lastError);
|
||||
}
|
||||
}
|
||||
|
||||
interface LanguageHostElement {
|
||||
inert: boolean;
|
||||
isConnected: boolean;
|
||||
requestUpdate(): void;
|
||||
hasAttribute(name: string): boolean;
|
||||
setAttribute(name: string, value: string): void;
|
||||
removeAttribute(name: string): void;
|
||||
}
|
||||
|
||||
const committedHosts = new WeakSet<LanguageHostElement>();
|
||||
const localeInertHosts = new WeakSet<LanguageHostElement>();
|
||||
const localeBusyHosts = new WeakSet<LanguageHostElement>();
|
||||
|
||||
/** Lightweight production render gate; generic controller below remains testable in isolation. */
|
||||
export function languageRenderGate(
|
||||
host: LanguageHostElement,
|
||||
runtime: LanguageRuntimeContract,
|
||||
code: string | null,
|
||||
): LanguageRenderGate {
|
||||
const state = code ? runtime.state(code) : 'ready';
|
||||
if (!code || state !== 'pending') {
|
||||
if (localeInertHosts.delete(host)) host.inert = false;
|
||||
if (localeBusyHosts.delete(host)) host.removeAttribute('aria-busy');
|
||||
if (code) {
|
||||
host.setAttribute('lang', state === 'fallback' ? 'en' : code);
|
||||
committedHosts.add(host);
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
if (!host.inert) {
|
||||
host.inert = true;
|
||||
localeInertHosts.add(host);
|
||||
}
|
||||
if (!host.hasAttribute('aria-busy')) {
|
||||
host.setAttribute('aria-busy', 'true');
|
||||
localeBusyHosts.add(host);
|
||||
}
|
||||
void runtime.ensure(code).then(() => {
|
||||
if (host.isConnected) host.requestUpdate();
|
||||
});
|
||||
return committedHosts.has(host) ? 'warm' : 'cold';
|
||||
}
|
||||
|
||||
export type LanguageRenderGate = 'ready' | 'cold' | 'warm';
|
||||
|
||||
/** Keep a root Lit surface inert and visually stable while its lazy locale loads. */
|
||||
/** A language-neutral first frame: no fallback-language copy is allowed to flash. */
|
||||
export function languageLoadingTemplate(): TemplateResult {
|
||||
return html`<ha-circular-progress active role="status" aria-busy="true"></ha-circular-progress>`;
|
||||
}
|
||||
+62
-1
@@ -1,10 +1,26 @@
|
||||
import en from './en.json' with { type: 'json' };
|
||||
import ru from './ru.json' with { type: 'json' };
|
||||
import type {
|
||||
LanguageRuntimeContract,
|
||||
LazyLanguageModule,
|
||||
LocaleDictionary,
|
||||
} from './language-runtime';
|
||||
|
||||
const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
const GERMAN_RETRY_ASSET = '__HOUSEPLAN_DE_RETRY_ASSET__';
|
||||
|
||||
export interface LanguageEntry {
|
||||
code: string;
|
||||
nativeLabel: string;
|
||||
dictionary: Record<string, string>;
|
||||
dictionary?: LocaleDictionary;
|
||||
loadDictionary?: (attempt: 0 | 1) => Promise<LazyLanguageModule>;
|
||||
}
|
||||
|
||||
async function loadGerman(attempt: 0 | 1): Promise<LazyLanguageModule> {
|
||||
const module = attempt === 0
|
||||
? await import('./de')
|
||||
: await import(/* @vite-ignore */ new URL(`${GERMAN_RETRY_ASSET}?retry`, import.meta.url).href);
|
||||
return { dictionary: module.dictionary, fingerprint: module.fingerprint };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,6 +32,7 @@ export interface LanguageEntry {
|
||||
export const LANGUAGE_REGISTRY = [
|
||||
{ code: 'en', nativeLabel: 'English', dictionary: en },
|
||||
{ code: 'ru', nativeLabel: 'Русский', dictionary: ru },
|
||||
{ code: 'de', nativeLabel: 'Deutsch', loadDictionary: loadGerman },
|
||||
] as const satisfies readonly LanguageEntry[];
|
||||
|
||||
export type Lang = (typeof LANGUAGE_REGISTRY)[number]['code'];
|
||||
@@ -23,6 +40,50 @@ export type Lang = (typeof LANGUAGE_REGISTRY)[number]['code'];
|
||||
export const FALLBACK_LANGUAGE_CODE: Lang = 'en';
|
||||
export const FALLBACK_DICTIONARY = en;
|
||||
|
||||
/** One page-scoped cache: multiple cards share the same locale request. */
|
||||
let germanDictionary: LocaleDictionary | undefined;
|
||||
let germanPending: Promise<void> | undefined;
|
||||
let germanFailed = false;
|
||||
|
||||
async function settleGerman(): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (const attempt of [0, 1] as const) {
|
||||
try {
|
||||
const loaded = await loadGerman(attempt);
|
||||
if (loaded.fingerprint !== BUILD_FINGERPRINT) throw new Error('locale fingerprint mismatch');
|
||||
germanDictionary = loaded.dictionary;
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
germanFailed = true;
|
||||
console.warn('[houseplan] unable to load de locale; using English', lastError);
|
||||
}
|
||||
|
||||
export const LANGUAGE_RUNTIME: LanguageRuntimeContract = {
|
||||
state: (code) => code !== 'de' || germanDictionary
|
||||
? 'ready' : germanFailed ? 'fallback' : 'pending',
|
||||
dictionary: (code) => code === 'de'
|
||||
? germanDictionary : code === 'ru' ? ru : code === 'en' ? en : undefined,
|
||||
ensure: (code) => {
|
||||
if (code !== 'de' || germanDictionary || germanFailed) return Promise.resolve();
|
||||
return germanPending ??= settleGerman().finally(() => { germanPending = undefined; });
|
||||
},
|
||||
};
|
||||
|
||||
/** Return a loaded dictionary, falling back synchronously to English. */
|
||||
export function dictionaryFor(value: unknown): LocaleDictionary {
|
||||
const code = languageEntry(value)?.code ?? FALLBACK_LANGUAGE_CODE;
|
||||
return LANGUAGE_RUNTIME.dictionary(code) ?? FALLBACK_DICTIONARY;
|
||||
}
|
||||
|
||||
/** Ensure a lazy locale has settled (success or bounded English fallback). */
|
||||
export function ensureLanguage(value: unknown): Promise<void> {
|
||||
const code = languageEntry(value)?.code ?? FALLBACK_LANGUAGE_CODE;
|
||||
return LANGUAGE_RUNTIME.ensure(code);
|
||||
}
|
||||
|
||||
/** Normalize HA locale tags for registry lookup. */
|
||||
export function normalizeLanguageTag(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
|
||||
@@ -71,8 +71,6 @@
|
||||
"title.zoom_in": "Приблизить",
|
||||
"title.zoom_out": "Отдалить",
|
||||
"title.zoom_fit": "Вписать всё",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показать скрытые и деактивированные устройства служебными маркерами (только в этой вкладке)",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
@@ -127,7 +125,6 @@
|
||||
"history.decor_transform": "Трансформация объекта декора",
|
||||
"history.decor_delete": "Удаление объекта декора",
|
||||
"history.backdrop_transform": "Трансформация подложки плана",
|
||||
"title.markup_opening": "Проёмы: клик по стене — добавить, клик по проёму — редактировать",
|
||||
"opening.new": "Новый проём",
|
||||
"opening.edit": "Проём",
|
||||
"opening.door": "Дверь",
|
||||
@@ -212,8 +209,6 @@
|
||||
"marker.preview.multiple_sources": "Источников: {n}",
|
||||
"marker.preview.more_sources": "ещё {n}",
|
||||
"marker.preview.scaled": "Предпросмотр уменьшен до {n}%, чтобы поместиться. Сохранённый размер не изменится.",
|
||||
"marker.preview.demo_activity": "Показать пример активности",
|
||||
"marker.preview.demo_notice": "Временный пример активности; фактическое состояние устройства не меняется.",
|
||||
"marker.preview.demo_short": "Показать краткую активность",
|
||||
"marker.preview.demo_continuous": "Показать постоянную активность",
|
||||
"marker.preview.stop_continuous": "Остановить постоянную активность",
|
||||
@@ -294,7 +289,6 @@
|
||||
"confirm.delete_room": "Удалить комнату «{name}»?",
|
||||
"confirm.delete_room_title": "Удалить комнату «{name}»?",
|
||||
"confirm.delete_room_body": "Выберите, нужно ли оставить физические стены, принадлежащие только этой комнате. Общие с другой комнатой стены сохраняются всегда.",
|
||||
"confirm.merge_rooms": "Слить комнаты «{a}» и «{b}»?",
|
||||
"confirm.remove_marker": "Удалить «{name}» с плана? Устройство исчезнет полностью и перестанет участвовать в данных плана. Позже его можно будет добавить заново.",
|
||||
"confirm.erase_decor": "Стереть объект «{kind}»? Действие можно отменить из истории редактора.",
|
||||
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
|
||||
@@ -311,7 +305,6 @@
|
||||
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
|
||||
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
|
||||
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
|
||||
"toast.contour_min_edges": "Чтобы замкнуть контур, сначала нарисуйте минимум две грани",
|
||||
"toast.contour_cannot_close": "Контур нельзя замкнуть: он вырожден или пересекает сам себя",
|
||||
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
|
||||
"toast.rooms_merged": "Комнаты объединены в «{name}»",
|
||||
@@ -321,7 +314,6 @@
|
||||
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
|
||||
"merge.keep": "Оставить",
|
||||
"merge.no_area": "без зоны",
|
||||
"room.split_header": "Новая комната после разделения",
|
||||
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
|
||||
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
|
||||
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
|
||||
@@ -361,8 +353,6 @@
|
||||
"editor.show_signal": "Показывать сигнал zigbee (LQI)",
|
||||
"editor.language": "Язык интерфейса",
|
||||
"editor.lang_auto": "Авто (профиль HA)",
|
||||
"editor.lang_en": "English",
|
||||
"editor.lang_ru": "Русский",
|
||||
"title.icon_rules": "Правила иконок: какая MDI-иконка достаётся устройству по имени",
|
||||
"rules.title": "Правила иконок",
|
||||
"rules.hint": "Правила проверяются сверху вниз по строке «имя устройства + модель» (regex без учёта регистра); срабатывает первое совпадение. Если ничего не подошло — решает device class сущности, затем — иконка-заглушка.",
|
||||
@@ -407,13 +397,7 @@
|
||||
"space.custom_fill": "Цвет заливки",
|
||||
"space.source_file": "У меня есть картинка плана",
|
||||
"space.source_draw": "Нет подложки — нарисую комнаты вручную",
|
||||
"space.orientation": "Холст",
|
||||
"orient.landscape": "Альбомный",
|
||||
"orient.portrait": "Портретный",
|
||||
"orient.square": "Квадрат",
|
||||
"fill.temp": "По температуре",
|
||||
"space.temp_min": "Комфорт от",
|
||||
"space.temp_max": "до",
|
||||
"tip.temp_avg": "средняя температура:",
|
||||
"tip.hum_avg": "средняя влажность:",
|
||||
"space_card.button": "Перейти к пространству",
|
||||
@@ -457,8 +441,6 @@
|
||||
"opening.lock_action": "Закрыть замок",
|
||||
"opening.lock_pending": "Выполняется…",
|
||||
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
|
||||
"devbar.add": "Добавить",
|
||||
"devbar.show_all": "Скрытые и деактивированные",
|
||||
"devbar.rules": "Правила иконок",
|
||||
"device_inbox.button": "Устройства",
|
||||
"device_inbox.title": "Устройства на плане",
|
||||
@@ -734,7 +716,6 @@
|
||||
"marker.ha_registry_limited": "Полный реестр Home Assistant недоступен этому пользователю. Неподтверждённый объект временно нельзя показывать или использовать.",
|
||||
"marker.delete_tip": "Полностью удалить устройство с плана и из всех расчётов. Позже его можно добавить заново.",
|
||||
"tap.run": "Запустить автоматизацию/скрипт/сцену",
|
||||
"tap.cover": "Открыть/закрыть (шторы/жалюзи)",
|
||||
"marker.run_target_label": "Что запускать",
|
||||
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
|
||||
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
|
||||
@@ -772,7 +753,6 @@
|
||||
"run.scene": "сцена",
|
||||
"confirm.tap_run": "Запустить «{name}»?",
|
||||
"confirm.tap_toggle": "Переключить «{name}»?",
|
||||
"confirm.tap_cover": "Открыть/закрыть «{name}»?",
|
||||
"confirm.current_state": "Текущее состояние: {state}",
|
||||
"confirm.expected_state": "После переключения: {state}",
|
||||
"confirm.group_current": "включено {on} из {total}",
|
||||
|
||||
+10
-3
@@ -5,11 +5,13 @@
|
||||
* (no clicks/hover/tooltips/drag/more-info). A footer button opens
|
||||
* the space in the full component via a deep-link (`#space=<id>`).
|
||||
*/
|
||||
import { LitElement, html, nothing, css, type TemplateResult, type PropertyValues } from 'lit';
|
||||
import { LitElement, html, nothing, noChange, css, type TemplateResult, type PropertyValues } from 'lit';
|
||||
import { cardStyles } from './styles';
|
||||
import { buildSpaceDevices, renderSpaceStatic, spaceModels } from './space-render';
|
||||
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
|
||||
import { t, langOf, type Lang } from './i18n';
|
||||
import { LANGUAGE_RUNTIME } from './i18n/registry';
|
||||
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
|
||||
import { ContentSigner } from './signing';
|
||||
import { normalizeDeviceDisplay, openingEntityReferences, referencedContentUrls } from './logic';
|
||||
import { acquireHaRegistries, activeRegistryHass, haRegistrySnapshot } from './ha-binding-status';
|
||||
@@ -758,8 +760,13 @@ class HouseplanSpaceCard extends LitElement {
|
||||
.map((event) => ({ ...event, ...(event.stage ? { stage: [...event.stage] as [number, number] } : {}) }));
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._config) return nothing;
|
||||
protected render(): TemplateResult | typeof nothing | typeof noChange {
|
||||
if (!this._config || !this.hass) return nothing;
|
||||
const localeGate = languageRenderGate(
|
||||
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
|
||||
);
|
||||
if (localeGate === 'cold') return languageLoadingTemplate();
|
||||
if (localeGate === 'warm') return noChange;
|
||||
const cfg = this._snap?.config;
|
||||
if (!cfg) {
|
||||
// still loading and no snapshot yet
|
||||
|
||||
+8
-1
@@ -1,6 +1,8 @@
|
||||
/** Config editor (Lovelace GUI) for houseplan-space-card. */
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import { LitElement, html, nothing, noChange } from 'lit';
|
||||
import { langOf, t, type Lang } from './i18n';
|
||||
import { LANGUAGE_RUNTIME } from './i18n/registry';
|
||||
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
|
||||
|
||||
class HouseplanSpaceCardEditor extends LitElement {
|
||||
public hass?: any;
|
||||
@@ -54,6 +56,11 @@ class HouseplanSpaceCardEditor extends LitElement {
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) return nothing;
|
||||
const localeGate = languageRenderGate(
|
||||
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
|
||||
);
|
||||
if (localeGate === 'cold') return languageLoadingTemplate();
|
||||
if (localeGate === 'warm') return noChange;
|
||||
this._loadSpaces();
|
||||
const L = this._lang;
|
||||
const labels: Record<string, string> = {
|
||||
|
||||
Reference in New Issue
Block a user