mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +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:
+11
-2
@@ -16,8 +16,8 @@ A shipped UI language has three matching parts:
|
||||
1. `src/i18n/<code>.json` for the card;
|
||||
2. `custom_components/houseplan/translations/<code>.json` for the Home Assistant
|
||||
integration;
|
||||
3. one static entry (dictionary import, code and native label) in
|
||||
`src/i18n/registry.ts`.
|
||||
3. one registry entry (code, native label and eager dictionary or lazy loader)
|
||||
in `src/i18n/registry.ts`.
|
||||
|
||||
Use the canonical Home Assistant/BCP 47 language tag as `<code>` (for example,
|
||||
`fr` or `pt-BR`) and use that exact spelling for both JSON filenames. Lookup is
|
||||
@@ -29,6 +29,15 @@ also fail on mismatched keys, empty values and changed placeholders.
|
||||
Placeholders such as `{name}` and `{n}` are a contract: do not translate, add
|
||||
or remove them.
|
||||
|
||||
English and Russian are synchronous fallback/legacy locales. German is the
|
||||
reference lazy third-locale implementation: its module carries the same build
|
||||
fingerprint as the entry bundle, has one content-hashed retry URL and is listed
|
||||
under `lazyLocaleFiles` in `houseplan-assets.json`. New sizeable locales should
|
||||
follow that path unless a measured initial-bundle budget explicitly justifies
|
||||
an eager import. Extend the runtime, manifest, file/key/placeholder parity and
|
||||
regional-locale tests together; never bypass the registry by importing a locale
|
||||
directly in a component.
|
||||
|
||||
The current `subst()` helper does not implement plural rules. Phrase strings so
|
||||
their grammar does not depend on the numeric value (for example, use a neutral
|
||||
label followed by `{n}` rather than an English singular/plural pair).
|
||||
|
||||
@@ -1,96 +1,111 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479",
|
||||
"fingerprint": "fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-OQuczTJf.js",
|
||||
"houseplan-assets/houseplan-card-r12Gbmbf.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 255908,
|
||||
"initialViewGzipBytes": 256014,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/editor-DMlizeQy.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
|
||||
"houseplan-assets/space-deletion-7mvB_XVT.js"
|
||||
"houseplan-assets/de-D1v4NXmi.js",
|
||||
"houseplan-assets/editor-DCM9zOUG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
|
||||
"houseplan-assets/space-deletion-C17YDpT4.js"
|
||||
],
|
||||
"lazyGzipBytes": 138428,
|
||||
"lazyGzipBytes": 160371,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/editor-DMlizeQy.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
|
||||
"houseplan-assets/space-deletion-7mvB_XVT.js"
|
||||
"houseplan-assets/editor-DCM9zOUG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
|
||||
"houseplan-assets/space-deletion-C17YDpT4.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 131779,
|
||||
"lazyEditorGzipBytes": 131840,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
|
||||
"houseplan-assets/space-deletion-7mvB_XVT.js"
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
|
||||
"houseplan-assets/space-deletion-C17YDpT4.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 7203,
|
||||
"lazyOnboardingGzipBytes": 7204,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-D1v4NXmi.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 21882,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/editor-DMlizeQy.js",
|
||||
"sha256": "7aeb38573980efdf90749280c7be4150f41bec4939919c9d84ecdf028185fd6a",
|
||||
"rawBytes": 3699,
|
||||
"gzipBytes": 1530,
|
||||
"path": "houseplan-assets/de-D1v4NXmi.js",
|
||||
"sha256": "22e1ceddb969fc41613eda6e46ef23f5bdc78e2cf2ebc54dd9e034afd0e4179d",
|
||||
"rawBytes": 71477,
|
||||
"gzipBytes": 21882,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-DCM9zOUG.js",
|
||||
"sha256": "ceb7440e98fe3d1a0e03df973ffa54885be47a5f583d96ca88adb7f9af0f126b",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1585,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-OQuczTJf.js"
|
||||
"houseplan-assets/houseplan-card-r12Gbmbf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-OQuczTJf.js",
|
||||
"sha256": "bbff295ebc0def9401caac0001245371490977fe612cc08c2402cfca5b827faf",
|
||||
"rawBytes": 910654,
|
||||
"gzipBytes": 255726,
|
||||
"path": "houseplan-assets/houseplan-card-r12Gbmbf.js",
|
||||
"sha256": "d7a481d2c8436ff1304cb5a57a9dacf4f33152c6bcea10a3890b2b50fc315366",
|
||||
"rawBytes": 910015,
|
||||
"gzipBytes": 255832,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/editor-DMlizeQy.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js"
|
||||
"houseplan-assets/de-D1v4NXmi.js",
|
||||
"houseplan-assets/editor-DCM9zOUG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
|
||||
"sha256": "616d30f0d4a61a14f8c149c55e6a8d12647aa80bdd31c32b57d2803ba9c5653b",
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
|
||||
"sha256": "96bc8b839a02422bc5581e21a8e23252273638f35cc1156fb50c611195b3c408",
|
||||
"rawBytes": 504952,
|
||||
"gzipBytes": 129695,
|
||||
"gzipBytes": 129700,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-OQuczTJf.js",
|
||||
"houseplan-assets/space-deletion-7mvB_XVT.js"
|
||||
"houseplan-assets/houseplan-card-r12Gbmbf.js",
|
||||
"houseplan-assets/space-deletion-C17YDpT4.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
|
||||
"sha256": "218d3b7f139f12bb842f7f888ba2e2fa97ca2f11b8bbfd6c2b9e16eff0647eef",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
|
||||
"sha256": "0d1c720e24b006cd3db29f5a3e0f6731c9bd6a0131b7ad9e095e4defed693e08",
|
||||
"rawBytes": 26356,
|
||||
"gzipBytes": 6649,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-OQuczTJf.js",
|
||||
"houseplan-assets/space-deletion-7mvB_XVT.js"
|
||||
"houseplan-assets/houseplan-card-r12Gbmbf.js",
|
||||
"houseplan-assets/space-deletion-C17YDpT4.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/space-deletion-7mvB_XVT.js",
|
||||
"sha256": "ca7cdbe51f937d2cd53385204d3b7917ec18f50c51b5aef4eb3e273516b08747",
|
||||
"path": "houseplan-assets/space-deletion-C17YDpT4.js",
|
||||
"sha256": "5c0033022fdaeb456ef7e400a7771b2130009cb3ac46b4e5498600ebb7d45cd8",
|
||||
"rawBytes": 911,
|
||||
"gzipBytes": 554,
|
||||
"gzipBytes": 555,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "45da1371d0ca2713a6a8f6108599c614bed0907f2c80556192375dbfdc741e2e",
|
||||
"sha256": "30e3307c785417cac8bbbc756100d2593150de8519ae559e60d169603c83afc7",
|
||||
"rawBytes": 190,
|
||||
"gzipBytes": 182,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-OQuczTJf.js"
|
||||
"houseplan-assets/houseplan-card-r12Gbmbf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-r12Gbmbf.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-OQuczTJf.js";class i extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],l=this._floorToken;l&&a.push({value:l,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const i="string"==typeof this._config?.floor?this._config.floor:"";i&&!e.some(e=>e.value===i)&&a.push({value:i,label:i}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;this._loadSpaces();const e=this._lang,o={title:t(e,"editor.title"),floor:t(e,"editor.floor"),default_floor:t(e,"editor.default_floor"),language:t(e,"editor.language"),icon_size:t(e,"editor.icon_size"),show_temperature:t(e,"editor.show_temperature"),live_states:t(e,"editor.live_states"),show_signal:t(e,"editor.show_signal"),kiosk:t(e,"editor.kiosk"),cycle:t(e,"editor.cycle")},s=this._schema,i=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),n=e=>l`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>o[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return l`
|
||||
${n(s.slice(0,3))}
|
||||
${i?l`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(e,"editor.default_floor_missing",{id:i})}
|
||||
</div>`:a}
|
||||
${n(s.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}i.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",i);
|
||||
+152
-152
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";import{bU as t,bV as s,bW as e,bX as o,aq as a,bY as l,c1 as i,bZ as h,b_ as n,b$ as c,c0 as r,b as p,A as _,cV as d,ap as u,ar as g,cW as m}from"./houseplan-card-OQuczTJf.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-7mvB_XVT.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";class C{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),n=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,c="none"===h.fill?{...n||e,a:0}:n;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:c,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:c,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:n,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;const o={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[e.type]||(e.name.toLowerCase().endsWith(".svg")?"svg":"");if(!o)return void this.host._showToast(this.host._t("toast.plan_formats"));const a=new Uint8Array(await e.arrayBuffer());let l="";for(let t=0;t<a.length;t+=32768)l+=String.fromCharCode(...a.subarray(t,t+32768));const i=btoa(l),h=URL.createObjectURL(e),n=await new Promise(t=>{const s=new Image;s.onload=()=>t(s.naturalWidth&&s.naturalHeight?s.naturalWidth/s.naturalHeight:1.414),s.onerror=()=>t(1.414),s.src=h});URL.revokeObjectURL(h),this.host._spaceDialog={...this.host._spaceDialog,planFile:{ext:o,b64:i,aspect:n,name:e.name}}}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return p`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return p`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return p`<div class="savedplans">
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";import{bU as t,bV as s,bW as e,bX as o,aq as a,bY as l,c1 as i,bZ as h,b_ as n,b$ as c,c0 as r,b as p,A as _,cV as d,ap as u,ar as g,cW as m}from"./houseplan-card-r12Gbmbf.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-C17YDpT4.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";class C{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),n=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,c="none"===h.fill?{...n||e,a:0}:n;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:c,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:c,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:n,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;const o={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[e.type]||(e.name.toLowerCase().endsWith(".svg")?"svg":"");if(!o)return void this.host._showToast(this.host._t("toast.plan_formats"));const a=new Uint8Array(await e.arrayBuffer());let l="";for(let t=0;t<a.length;t+=32768)l+=String.fromCharCode(...a.subarray(t,t+32768));const i=btoa(l),h=URL.createObjectURL(e),n=await new Promise(t=>{const s=new Image;s.onload=()=>t(s.naturalWidth&&s.naturalHeight?s.naturalWidth/s.naturalHeight:1.414),s.onerror=()=>t(1.414),s.src=h});URL.revokeObjectURL(h),this.host._spaceDialog={...this.host._spaceDialog,planFile:{ext:o,b64:i,aspect:n,name:e.name}}}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return p`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return p`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return p`<div class="savedplans">
|
||||
${s.map(s=>{return p`
|
||||
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
|
||||
<img src=${this.host._display(s.url)} alt="" loading="lazy" decoding="async" />
|
||||
+1
-1
@@ -1 +1 @@
|
||||
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";export{t as a,e as c,o as i,r as s,s as t};
|
||||
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";export{t as a,e as c,o as i,r as s,s as t};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";export{d3 as HouseplanCard}from"./houseplan-assets/houseplan-card-OQuczTJf.js";
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-r12Gbmbf.js";
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": {
|
||||
"admin_only": "Nur Administratoren dürfen den Grundriss bearbeiten"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Bereits konfiguriert – es ist nur eine Instanz zulässig."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"admin_only": "Nur Administratoren dürfen den Grundriss bearbeiten"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"broken_plan": {
|
||||
"title": "Grundrissbild fehlt",
|
||||
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 48;
|
||||
export const GOLDEN_MATRIX_VERSION = 49;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -663,6 +663,13 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
|
||||
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
|
||||
{ id: 'device-dialog-desktop-de', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
deviceName: 'Wohnzimmer-Lichtsteuerung mit absichtlich sehr langem Namen',
|
||||
language: 'de', theme: 'light', viewport: { width: 1180, height: 1200 }, ...page },
|
||||
{ id: 'german-view-mobile-light', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'view', language: 'de', theme: 'light',
|
||||
viewport: { width: 390, height: 900 }, ...page },
|
||||
{ id: 'toggle-entity-dialog-desktop-en', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-appliance', dialog: 'device', deviceId: 'golden-washer',
|
||||
deviceToggleEntity: 'selected', markerOverrides: [{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// #348: German is a page-shared lazy locale with a neutral render gate and bounded fallback.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launchColdView, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
|
||||
const localePath = manifest.lazyLocaleFiles?.[0];
|
||||
if (!localePath) throw new Error('German locale is absent from the bundle manifest');
|
||||
const localeName = localePath.split('/').at(-1);
|
||||
const localePattern = `**/${localeName}*`;
|
||||
|
||||
const replaceWithGermanCard = (suffix = '') => {
|
||||
const old = window.__card;
|
||||
const host = document.getElementById('host');
|
||||
const config = { ...old._config, title: `Deutsch${suffix}`, language: 'de' };
|
||||
const hass = { ...old.hass, language: 'de-DE', locale: { language: 'de-DE' } };
|
||||
old.remove();
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig(config);
|
||||
card.hass = hass;
|
||||
host.append(card);
|
||||
window.__card = card;
|
||||
};
|
||||
|
||||
const cold = await launchColdView();
|
||||
const requested = [];
|
||||
cold.page.on('request', (request) => requested.push(new URL(request.url()).pathname));
|
||||
const resourcesBefore = await cold.page.evaluate(() => performance.getEntriesByType('resource')
|
||||
.map((entry) => new URL(entry.name).pathname));
|
||||
await cold.page.route(localePattern, async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
await route.fallback();
|
||||
});
|
||||
await cold.page.evaluate(replaceWithGermanCard);
|
||||
await cold.page.waitForTimeout(60);
|
||||
const neutral = await cold.page.locator('houseplan-card').evaluate((card) => ({
|
||||
inert: card.inert,
|
||||
busy: card.getAttribute('aria-busy'),
|
||||
progress: !!card.renderRoot.querySelector('ha-circular-progress'),
|
||||
text: card.renderRoot.textContent.trim(),
|
||||
}));
|
||||
await cold.page.waitForFunction(() => window.__card?._model?.length > 0
|
||||
&& window.__card._t('btn.save') === 'Speichern'
|
||||
&& !window.__card.hasAttribute('aria-busy'));
|
||||
const out = {
|
||||
englishColdViewDoesNotRequestGerman: !resourcesBefore.some((path) => path.endsWith(`/${localeName}`)),
|
||||
germanColdFrameIsNeutral: neutral.inert && neutral.busy === 'true'
|
||||
&& neutral.progress && neutral.text === '',
|
||||
germanLoadsExactlyOnce: requested.filter((path) => path.endsWith(`/${localeName}`)).length === 1,
|
||||
germanCopyCommitsAtomically: await cold.page.evaluate(() =>
|
||||
window.__card._t('btn.save') === 'Speichern'
|
||||
&& window.__card._t('btn.cancel') === 'Abbrechen'
|
||||
&& window.__card._t('space.header') === 'Bereich'
|
||||
&& window.__card.getAttribute('lang') === 'de'),
|
||||
};
|
||||
|
||||
const beforeSecond = requested.length;
|
||||
await cold.page.evaluate(replaceWithGermanCard, ' 2');
|
||||
await cold.page.waitForFunction(() => window.__card?._model?.length > 0
|
||||
&& window.__card._t('btn.save') === 'Speichern');
|
||||
out.secondCardReusesPageLocale = requested.length === beforeSecond;
|
||||
|
||||
const failed = await launchColdView();
|
||||
let failedRequests = 0;
|
||||
let warnings = 0;
|
||||
failed.page.on('console', (message) => {
|
||||
if (message.type() === 'warning' && message.text().includes('unable to load de locale')) warnings++;
|
||||
});
|
||||
await failed.page.route(localePattern, async (route) => {
|
||||
failedRequests++;
|
||||
await route.abort('failed');
|
||||
});
|
||||
await failed.page.evaluate(replaceWithGermanCard);
|
||||
await failed.page.waitForFunction(() => window.__card?._model?.length > 0
|
||||
&& !window.__card.hasAttribute('aria-busy')
|
||||
&& window.__card._t('btn.save') === 'Save');
|
||||
out.failureRetriesExactlyOnce = failedRequests === 2;
|
||||
out.failureFallsBackAndUnblocks = await failed.page.evaluate(() =>
|
||||
!window.__card.inert && window.__card._t('btn.cancel') === 'Cancel'
|
||||
&& window.__card.getAttribute('lang') === 'en');
|
||||
out.failureWarnsOnce = warnings === 1;
|
||||
|
||||
await failed.browser.close();
|
||||
checkAll(out);
|
||||
await finish(cold.browser, out);
|
||||
Vendored
+56
-41
@@ -1,96 +1,111 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479",
|
||||
"fingerprint": "fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-OQuczTJf.js",
|
||||
"houseplan-assets/houseplan-card-r12Gbmbf.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 255908,
|
||||
"initialViewGzipBytes": 256014,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/editor-DMlizeQy.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
|
||||
"houseplan-assets/space-deletion-7mvB_XVT.js"
|
||||
"houseplan-assets/de-D1v4NXmi.js",
|
||||
"houseplan-assets/editor-DCM9zOUG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
|
||||
"houseplan-assets/space-deletion-C17YDpT4.js"
|
||||
],
|
||||
"lazyGzipBytes": 138428,
|
||||
"lazyGzipBytes": 160371,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/editor-DMlizeQy.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
|
||||
"houseplan-assets/space-deletion-7mvB_XVT.js"
|
||||
"houseplan-assets/editor-DCM9zOUG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
|
||||
"houseplan-assets/space-deletion-C17YDpT4.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 131779,
|
||||
"lazyEditorGzipBytes": 131840,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
|
||||
"houseplan-assets/space-deletion-7mvB_XVT.js"
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
|
||||
"houseplan-assets/space-deletion-C17YDpT4.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 7203,
|
||||
"lazyOnboardingGzipBytes": 7204,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-D1v4NXmi.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 21882,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/editor-DMlizeQy.js",
|
||||
"sha256": "7aeb38573980efdf90749280c7be4150f41bec4939919c9d84ecdf028185fd6a",
|
||||
"rawBytes": 3699,
|
||||
"gzipBytes": 1530,
|
||||
"path": "houseplan-assets/de-D1v4NXmi.js",
|
||||
"sha256": "22e1ceddb969fc41613eda6e46ef23f5bdc78e2cf2ebc54dd9e034afd0e4179d",
|
||||
"rawBytes": 71477,
|
||||
"gzipBytes": 21882,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-DCM9zOUG.js",
|
||||
"sha256": "ceb7440e98fe3d1a0e03df973ffa54885be47a5f583d96ca88adb7f9af0f126b",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1585,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-OQuczTJf.js"
|
||||
"houseplan-assets/houseplan-card-r12Gbmbf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-OQuczTJf.js",
|
||||
"sha256": "bbff295ebc0def9401caac0001245371490977fe612cc08c2402cfca5b827faf",
|
||||
"rawBytes": 910654,
|
||||
"gzipBytes": 255726,
|
||||
"path": "houseplan-assets/houseplan-card-r12Gbmbf.js",
|
||||
"sha256": "d7a481d2c8436ff1304cb5a57a9dacf4f33152c6bcea10a3890b2b50fc315366",
|
||||
"rawBytes": 910015,
|
||||
"gzipBytes": 255832,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/editor-DMlizeQy.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js"
|
||||
"houseplan-assets/de-D1v4NXmi.js",
|
||||
"houseplan-assets/editor-DCM9zOUG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
|
||||
"sha256": "616d30f0d4a61a14f8c149c55e6a8d12647aa80bdd31c32b57d2803ba9c5653b",
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
|
||||
"sha256": "96bc8b839a02422bc5581e21a8e23252273638f35cc1156fb50c611195b3c408",
|
||||
"rawBytes": 504952,
|
||||
"gzipBytes": 129695,
|
||||
"gzipBytes": 129700,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-OQuczTJf.js",
|
||||
"houseplan-assets/space-deletion-7mvB_XVT.js"
|
||||
"houseplan-assets/houseplan-card-r12Gbmbf.js",
|
||||
"houseplan-assets/space-deletion-C17YDpT4.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
|
||||
"sha256": "218d3b7f139f12bb842f7f888ba2e2fa97ca2f11b8bbfd6c2b9e16eff0647eef",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
|
||||
"sha256": "0d1c720e24b006cd3db29f5a3e0f6731c9bd6a0131b7ad9e095e4defed693e08",
|
||||
"rawBytes": 26356,
|
||||
"gzipBytes": 6649,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-OQuczTJf.js",
|
||||
"houseplan-assets/space-deletion-7mvB_XVT.js"
|
||||
"houseplan-assets/houseplan-card-r12Gbmbf.js",
|
||||
"houseplan-assets/space-deletion-C17YDpT4.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/space-deletion-7mvB_XVT.js",
|
||||
"sha256": "ca7cdbe51f937d2cd53385204d3b7917ec18f50c51b5aef4eb3e273516b08747",
|
||||
"path": "houseplan-assets/space-deletion-C17YDpT4.js",
|
||||
"sha256": "5c0033022fdaeb456ef7e400a7771b2130009cb3ac46b4e5498600ebb7d45cd8",
|
||||
"rawBytes": 911,
|
||||
"gzipBytes": 554,
|
||||
"gzipBytes": 555,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "45da1371d0ca2713a6a8f6108599c614bed0907f2c80556192375dbfdc741e2e",
|
||||
"sha256": "30e3307c785417cac8bbbc756100d2593150de8519ae559e60d169603c83afc7",
|
||||
"rawBytes": 190,
|
||||
"gzipBytes": 182,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-OQuczTJf.js"
|
||||
"houseplan-assets/houseplan-card-r12Gbmbf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+14
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-r12Gbmbf.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-OQuczTJf.js";class i extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],l=this._floorToken;l&&a.push({value:l,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const i="string"==typeof this._config?.floor?this._config.floor:"";i&&!e.some(e=>e.value===i)&&a.push({value:i,label:i}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;this._loadSpaces();const e=this._lang,o={title:t(e,"editor.title"),floor:t(e,"editor.floor"),default_floor:t(e,"editor.default_floor"),language:t(e,"editor.language"),icon_size:t(e,"editor.icon_size"),show_temperature:t(e,"editor.show_temperature"),live_states:t(e,"editor.live_states"),show_signal:t(e,"editor.show_signal"),kiosk:t(e,"editor.kiosk"),cycle:t(e,"editor.cycle")},s=this._schema,i=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),n=e=>l`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>o[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return l`
|
||||
${n(s.slice(0,3))}
|
||||
${i?l`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(e,"editor.default_floor_missing",{id:i})}
|
||||
</div>`:a}
|
||||
${n(s.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}i.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",i);
|
||||
+152
-152
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";import{bU as t,bV as s,bW as e,bX as o,aq as a,bY as l,c1 as i,bZ as h,b_ as n,b$ as c,c0 as r,b as p,A as _,cV as d,ap as u,ar as g,cW as m}from"./houseplan-card-OQuczTJf.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-7mvB_XVT.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";class C{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),n=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,c="none"===h.fill?{...n||e,a:0}:n;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:c,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:c,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:n,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;const o={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[e.type]||(e.name.toLowerCase().endsWith(".svg")?"svg":"");if(!o)return void this.host._showToast(this.host._t("toast.plan_formats"));const a=new Uint8Array(await e.arrayBuffer());let l="";for(let t=0;t<a.length;t+=32768)l+=String.fromCharCode(...a.subarray(t,t+32768));const i=btoa(l),h=URL.createObjectURL(e),n=await new Promise(t=>{const s=new Image;s.onload=()=>t(s.naturalWidth&&s.naturalHeight?s.naturalWidth/s.naturalHeight:1.414),s.onerror=()=>t(1.414),s.src=h});URL.revokeObjectURL(h),this.host._spaceDialog={...this.host._spaceDialog,planFile:{ext:o,b64:i,aspect:n,name:e.name}}}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return p`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return p`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return p`<div class="savedplans">
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";import{bU as t,bV as s,bW as e,bX as o,aq as a,bY as l,c1 as i,bZ as h,b_ as n,b$ as c,c0 as r,b as p,A as _,cV as d,ap as u,ar as g,cW as m}from"./houseplan-card-r12Gbmbf.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-C17YDpT4.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";class C{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),n=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,c="none"===h.fill?{...n||e,a:0}:n;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:c,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:c,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:n,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;const o={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[e.type]||(e.name.toLowerCase().endsWith(".svg")?"svg":"");if(!o)return void this.host._showToast(this.host._t("toast.plan_formats"));const a=new Uint8Array(await e.arrayBuffer());let l="";for(let t=0;t<a.length;t+=32768)l+=String.fromCharCode(...a.subarray(t,t+32768));const i=btoa(l),h=URL.createObjectURL(e),n=await new Promise(t=>{const s=new Image;s.onload=()=>t(s.naturalWidth&&s.naturalHeight?s.naturalWidth/s.naturalHeight:1.414),s.onerror=()=>t(1.414),s.src=h});URL.revokeObjectURL(h),this.host._spaceDialog={...this.host._spaceDialog,planFile:{ext:o,b64:i,aspect:n,name:e.name}}}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return p`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return p`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return p`<div class="savedplans">
|
||||
${s.map(s=>{return p`
|
||||
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
|
||||
<img src=${this.host._display(s.url)} alt="" loading="lazy" decoding="async" />
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";export{t as a,e as c,o as i,r as s,s as t};
|
||||
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";export{t as a,e as c,o as i,r as s,s as t};
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";export{d3 as HouseplanCard}from"./houseplan-assets/houseplan-card-OQuczTJf.js";
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-r12Gbmbf.js";
|
||||
|
||||
@@ -135,6 +135,13 @@ the same profiler available between stable promotions.
|
||||
affordance exists only when both its localized body and complete accessible
|
||||
label are non-empty; the card factory and `hp-help` enforce this independently,
|
||||
so incomplete content cannot leave a dead focus target or a layout gap.
|
||||
English and Russian dictionaries remain in the synchronous graph. German is
|
||||
a fingerprint-checked lazy locale shared page-wide: every root card/editor
|
||||
uses the same render gate, keeps a previously committed frame stable during
|
||||
a language switch, and shows a language-neutral busy frame on a German cold
|
||||
start. Two failed content-hashed attempts settle on English rather than
|
||||
leaving an inert surface. The bundle manifest classifies this graph as
|
||||
`lazyLocaleFiles`, separate from editor and onboarding graphs.
|
||||
8. **One four-phase environment resolver.** `resolveDayCycle()` in `src/sun.ts`
|
||||
atomically chooses a strict real `sun.sun` sample or browser-local clock
|
||||
fallback and returns only phase/source/light tokens. `src/day-cycle-render.ts`
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- House Plan now has a complete German interface. `Deutsch` can be selected
|
||||
explicitly and is chosen automatically for German Home Assistant profiles;
|
||||
the locale loads on demand without making the ordinary View bundle larger
|
||||
([#348](https://github.com/Matysh/houseplan-card/issues/348)).
|
||||
|
||||
- View now starts without downloading the three editors; their shared runtime
|
||||
is loaded on first use and protected by one retry and a build-version check.
|
||||
Release and test tooling now verify the complete multi-file frontend asset
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- В House Plan появилась полная немецкая локализация. `Deutsch` можно выбрать
|
||||
вручную, а для немецкого профиля Home Assistant она включается автоматически;
|
||||
словарь загружается по требованию и не утяжеляет обычный режим просмотра
|
||||
([#348](https://github.com/Matysh/houseplan-card/issues/348)).
|
||||
|
||||
- Режим просмотра теперь запускается без загрузки трёх редакторов: их общий
|
||||
runtime подгружается при первом обращении и защищён повторной попыткой и
|
||||
проверкой версии сборки. Релизные и тестовые инструменты проверяют всё дерево
|
||||
|
||||
+12
-1
@@ -781,7 +781,18 @@ separately promised workflows:
|
||||
- [ ] Tablet in kiosk/panel mode — View/kiosk, landscape, touch gestures
|
||||
- [ ] Phone portrait, narrow ≤400 px — View and View dialogs/actions
|
||||
- [ ] Dark theme and light theme (badges, dialogs, plan contrast)
|
||||
- [ ] RU profile locale and EN profile locale (+ `language:` card option forcing each)
|
||||
- [ ] RU, EN and DE profile locales (+ `language:` card option forcing each);
|
||||
`de-DE`, `de-AT` and `de-CH` resolve to German, while an unknown locale
|
||||
falls back to English [unit: i18n, i18n-runtime]
|
||||
- [ ] German cold start requests exactly one locale chunk, shows only a neutral
|
||||
busy frame before commit and never flashes English; a second card reuses
|
||||
the page cache. EN/RU request no locale chunk [auto: German locale smoke]
|
||||
- [ ] German locale download failure retries the content-hashed asset once and
|
||||
then unblocks the card in English with one warning [unit: i18n-runtime;
|
||||
auto: German locale smoke fault injection]
|
||||
- [ ] German View and a representative settings/device dialog fit at desktop
|
||||
and 390 px without horizontal overflow or clipped actions [golden: German
|
||||
desktop/mobile scenarios]
|
||||
|
||||
## Installation / upgrade / removal
|
||||
|
||||
|
||||
+7
-1
@@ -131,7 +131,7 @@ title: House plan
|
||||
| `title` | empty | Card title |
|
||||
| `default_floor` | first/last opened | Initial/fallback space for an unpinned card |
|
||||
| `floor` | not pinned | Keep this card on one stable space ID or zero-based YAML index |
|
||||
| `language` | HA language | `auto`, `ru` or `en` |
|
||||
| `language` | HA language | `auto`, `en`, `ru` or `de` |
|
||||
| `icon_size` | `2.5` | Base marker size, 1–6% of plan width |
|
||||
| `show_temperature` | `true` | Compact temperature and humidity values |
|
||||
| `live_states` | `true` | Work/open/unavailable presentation and activity; alarms remain visible |
|
||||
@@ -141,6 +141,12 @@ title: House plan
|
||||
|
||||
The legacy card-level `tap_action` is ignored. Each marker owns its action.
|
||||
|
||||
`auto` recognizes the primary Home Assistant locale: `de`, `de-DE`, `de-AT`
|
||||
and `de-CH` use German. German is downloaded once on first use and shared by
|
||||
all House Plan cards on the page. Until it is ready, a neutral busy surface is
|
||||
shown instead of briefly flashing English; if both bounded download attempts
|
||||
fail, the card becomes usable in English.
|
||||
|
||||
<!-- docs-section: first-run -->
|
||||
|
||||
## 4. Quick start
|
||||
|
||||
@@ -135,7 +135,7 @@ title: План дома
|
||||
| `title` | пусто | Заголовок карточки |
|
||||
| `default_floor` | первое/последнее открытое | Стартовое/резервное пространство незакреплённой карточки |
|
||||
| `floor` | не закреплено | Всегда показывать один stable ID пространства или числовой индекс YAML с нуля |
|
||||
| `language` | язык HA | `auto`, `ru` или `en` |
|
||||
| `language` | язык HA | `auto`, `en`, `ru` или `de` |
|
||||
| `icon_size` | `2.5` | Базовый размер маркеров, 1–6% ширины плана |
|
||||
| `show_temperature` | `true` | Показывает компактные значения температуры **и влажности** у подходящих устройств |
|
||||
| `live_states` | `true` | Включает рабочие/открытые/недоступные состояния, смену иконок и активность; аварии остаются видимыми всегда |
|
||||
@@ -145,6 +145,13 @@ title: План дома
|
||||
|
||||
Карточечный `tap_action` из старых конфигураций игнорируется. Действие задаётся отдельно для каждого устройства.
|
||||
|
||||
`auto` учитывает основной язык профиля Home Assistant: `de`, `de-DE`,
|
||||
`de-AT` и `de-CH` включают немецкий интерфейс. Немецкий словарь один раз
|
||||
загружается при первом использовании и разделяется всеми карточками House Plan
|
||||
на странице. До готовности показывается нейтральный индикатор без краткой
|
||||
вспышки английского текста; если обе ограниченные попытки загрузки не удались,
|
||||
карточка остаётся работоспособной на английском.
|
||||
|
||||
<!-- docs-section: first-run -->
|
||||
|
||||
## 4. Быстрый старт
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"oxipng": "oxipng 10.2.0",
|
||||
"sourceFingerprint": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceFingerprint": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"captureScriptSha256": "fe76c8e2e27501e9a377d7aa15dd6cba6a018d55c07deb602dbee097f0a0a5ba",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"imageSha256": "f8c9541c9d7fde462d0efba63dff70944dc03df76ce988eecaf2b269e2863a96"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"imageSha256": "69cc6aa03948282feab1c623da418487cb9b8ebc3e57175b031cc40e5f9a88af"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"imageSha256": "f6d190f0b2ac9125cea68aea2ebbcdf9a7081ae2a3565ff253fb1ed0c4a56483"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"imageSha256": "ca8bb143d0d6679ff09f8e9c1f5ad68b2ca7cfd96a4740a4a650c251f55dfba5"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"imageSha256": "06b67c5108f9ee29a226cc2920484ccffe0c79840aec09721072b25b91ef8f03"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"imageSha256": "0b84d425c14389fb3f04779a1f553ebab1b84f801b23b119b3810770500d171f"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"imageSha256": "143bda35ac596ce6d72ad7399e5d26238cccae577f2d8cff398cd1efcaf1661f"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"imageSha256": "7e308a2b105fa6a9f788449ca30b8183375a32f0d0f8726f4a131e4f35527f57"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -103,7 +103,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"imageSha256": "f1bc10b478f7afc68e70cb4c37148f680b0bbd41d50a7d63a21067fc952ff134"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
|
||||
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
|
||||
"imageSha256": "2634cd7ead5720ce46ea4d6fa8f31006b740c520a73ddc97afe9a6d9d3b49778"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/348
|
||||
- **Зависимость:** #62 — единый реестр языков и автоматические i18n-gates
|
||||
- **Статус документа:** готово к ревью ТЗ
|
||||
- **Статус документа:** реализовано, передано на код-ревью
|
||||
- **Приоритет / тип:** P1 / feature
|
||||
- **Трек:** полный: задача меняет публичный языковой контракт, несколько
|
||||
frontend/backend-поверхностей, первый render и performance budget
|
||||
@@ -382,4 +382,3 @@ config не нужны.
|
||||
5. Добавить semantic/layout/network smoke и German golden scenarios.
|
||||
6. Обновить docs/changelogs, собрать и синхронизировать bundle.
|
||||
7. Прогнать gates, передать точный SHA на независимое code review.
|
||||
|
||||
|
||||
@@ -12,9 +12,19 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
|
||||
if (!manifest.lazyEditorFiles?.length) {
|
||||
throw new Error('bundle has no lazy editor graph');
|
||||
}
|
||||
if (!manifest.lazyLocaleFiles?.length) {
|
||||
throw new Error('bundle has no lazy locale graph');
|
||||
}
|
||||
if (manifest.initialViewFiles.some((path) => manifest.lazyEditorFiles.includes(path))) {
|
||||
throw new Error('initial View graph overlaps lazy editor graph');
|
||||
}
|
||||
if (manifest.initialViewFiles.some((path) => manifest.lazyLocaleFiles.includes(path))) {
|
||||
throw new Error('initial View graph overlaps lazy locale graph');
|
||||
}
|
||||
if (manifest.lazyLocaleFiles.some((path) => manifest.lazyEditorFiles.includes(path)
|
||||
|| manifest.lazyOnboardingFiles?.includes(path))) {
|
||||
throw new Error('lazy locale graph overlaps an editor graph');
|
||||
}
|
||||
if (manifest.initialViewGzipBytes > budget) {
|
||||
throw new Error(
|
||||
`initial View graph ${manifest.initialViewGzipBytes} B gzip exceeds ${budget} B budget`,
|
||||
@@ -23,6 +33,7 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
|
||||
return {
|
||||
initialViewGzipBytes: manifest.initialViewGzipBytes,
|
||||
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
|
||||
lazyLocaleGzipBytes: manifest.lazyLocaleGzipBytes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +43,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
const result = assertBundleBudget(manifest);
|
||||
console.log(`initial View: ${result.initialViewGzipBytes} B gzip`);
|
||||
console.log(`lazy editor: ${result.lazyEditorGzipBytes} B gzip`);
|
||||
console.log(`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { gzipSync } from 'node:zlib';
|
||||
const BUILD_FINGERPRINT_TOKEN = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
const EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
|
||||
const ONBOARDING_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ONBOARDING_RETRY_ASSET__';
|
||||
const DE_RETRY_ASSET_TOKEN = '__HOUSEPLAN_DE_RETRY_ASSET__';
|
||||
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
@@ -27,6 +28,14 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
.filter((item) => item.type === 'chunk')
|
||||
.map((chunk) => {
|
||||
const contents = Buffer.from(chunk.code, 'utf8');
|
||||
const modules = Object.keys(chunk.modules || {}).map((id) => id.replaceAll('\\', '/'));
|
||||
const role = modules.some((id) => id.endsWith('/src/i18n/de.ts'))
|
||||
? 'locale'
|
||||
: modules.some((id) => id.endsWith('/src/houseplan-onboarding-runtime.ts'))
|
||||
? 'onboarding'
|
||||
: modules.some((id) => id.endsWith('/src/houseplan-editor-runtime.ts'))
|
||||
? 'editor'
|
||||
: undefined;
|
||||
return {
|
||||
path: chunk.fileName.replaceAll('\\', '/'),
|
||||
sha256: sha256(contents),
|
||||
@@ -35,6 +44,7 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
isEntry: chunk.isEntry,
|
||||
imports: [...chunk.imports].sort(),
|
||||
dynamicImports: [...chunk.dynamicImports].sort(),
|
||||
_role: role,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
@@ -48,8 +58,13 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
for (const root of dynamicRoots) {
|
||||
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) lazy.add(path);
|
||||
}
|
||||
const onboardingRoots = dynamicRoots.filter((path) => path.includes('houseplan-onboarding-runtime-'));
|
||||
const editorRoots = dynamicRoots.filter((path) => !onboardingRoots.includes(path));
|
||||
const localeRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'locale'
|
||||
|| /(?:^|\/)de-[^/]+\.js$/.test(path));
|
||||
const onboardingRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'onboarding'
|
||||
|| path.includes('houseplan-onboarding-runtime-'));
|
||||
const editorRoots = dynamicRoots.filter((path) => (byPath.get(path)?._role === 'editor'
|
||||
|| /(?:^|\/)editor(?:-[^/]+)?\.js$/.test(path))
|
||||
&& !localeRoots.includes(path) && !onboardingRoots.includes(path));
|
||||
const graphFrom = (roots) => {
|
||||
const graph = new Set();
|
||||
for (const root of roots) {
|
||||
@@ -59,6 +74,7 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
};
|
||||
const lazyEditor = graphFrom(editorRoots);
|
||||
const lazyOnboarding = graphFrom(onboardingRoots);
|
||||
const lazyLocale = graphFrom(localeRoots);
|
||||
const sum = (paths) => [...paths]
|
||||
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
|
||||
return {
|
||||
@@ -73,7 +89,9 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
lazyEditorGzipBytes: sum(lazyEditor),
|
||||
lazyOnboardingFiles: [...lazyOnboarding].sort(),
|
||||
lazyOnboardingGzipBytes: sum(lazyOnboarding),
|
||||
files,
|
||||
lazyLocaleFiles: [...lazyLocale].sort(),
|
||||
lazyLocaleGzipBytes: sum(lazyLocale),
|
||||
files: files.map(({ _role, ...file }) => file),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,10 +138,14 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-editor-runtime.ts')));
|
||||
const onboarding = chunks.find((chunk) => Object.keys(chunk.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-onboarding-runtime.ts')));
|
||||
const german = chunks.find((chunk) => Object.keys(chunk.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/de.ts')));
|
||||
if (!editor) throw new Error('editor runtime chunk was not emitted');
|
||||
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
|
||||
if (!german) throw new Error('German locale chunk was not emitted');
|
||||
let editorReplacements = 0;
|
||||
let onboardingReplacements = 0;
|
||||
let germanReplacements = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
|
||||
@@ -137,10 +159,16 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
onboardingReplacements += chunk.code.split(ONBOARDING_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(ONBOARDING_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
if (chunk.code.includes(DE_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), german.fileName);
|
||||
if (!asset.startsWith('.')) asset = `./${asset}`;
|
||||
germanReplacements += chunk.code.split(DE_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(DE_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
}
|
||||
if (editorReplacements !== 1 || onboardingReplacements !== 1) {
|
||||
if (editorReplacements !== 1 || onboardingReplacements !== 1 || germanReplacements !== 1) {
|
||||
throw new Error('lazy retry URL placeholder counts are '
|
||||
+ `${editorReplacements}/${onboardingReplacements}, expected 1/1`);
|
||||
+ `${editorReplacements}/${onboardingReplacements}/${germanReplacements}, expected 1/1/1`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +27,16 @@
|
||||
|
||||
/** @type {SmokeLink[]} */
|
||||
export const SMOKE_LINKS = [
|
||||
{
|
||||
symbols: [
|
||||
'LanguageRuntime', 'LanguageRuntimeContract', 'languageRenderGate',
|
||||
'dictionaryFor', 'ensureLanguage',
|
||||
],
|
||||
smokes: ['smoke_german_locale.mjs'],
|
||||
because: 'the production-bundle scenario observes the lazy German chunk through network '
|
||||
+ 'requests and four root render gates: neutral cold frame, atomic German commit, page-cache '
|
||||
+ 'reuse and the bounded retry-to-English path cannot be proven from pure registry tests (#348)',
|
||||
},
|
||||
{
|
||||
symbols: ['quantizeKeyCoord', 'INCIDENT_EPS', 'KEY_FACTOR'],
|
||||
smokes: ['smoke_junction_limits.mjs'],
|
||||
|
||||
+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> = {
|
||||
|
||||
@@ -45,6 +45,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
|
||||
type: 'chunk', fileName: 'houseplan-card.js', code: 'entry', isEntry: true,
|
||||
imports: ['shared.js'], dynamicImports: [
|
||||
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
|
||||
'houseplan-assets/de-HASH.js',
|
||||
],
|
||||
},
|
||||
'shared.js': {
|
||||
@@ -60,14 +61,21 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
|
||||
code: 'onboarding', isEntry: false,
|
||||
imports: ['shared.js'], dynamicImports: [],
|
||||
},
|
||||
'houseplan-assets/de-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/de-HASH.js',
|
||||
code: 'German locale', isEntry: false, imports: [], dynamicImports: [],
|
||||
modules: { '/repo/src/i18n/de.ts': {} },
|
||||
},
|
||||
}, 'fingerprint');
|
||||
assert.deepEqual(manifest.initialViewFiles, ['houseplan-card.js', 'shared.js']);
|
||||
assert.deepEqual(manifest.lazyEditorFiles, ['houseplan-assets/editor.js']);
|
||||
assert.deepEqual(manifest.lazyOnboardingFiles, [
|
||||
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
|
||||
]);
|
||||
assert.deepEqual(manifest.lazyLocaleFiles, ['houseplan-assets/de-HASH.js']);
|
||||
assert.deepEqual(manifest.lazyFiles, [
|
||||
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
|
||||
'houseplan-assets/de-HASH.js', 'houseplan-assets/editor.js',
|
||||
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
|
||||
]);
|
||||
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000));
|
||||
assert.throws(() => assertBundleBudget(manifest, 1), /exceeds/);
|
||||
@@ -89,7 +97,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
|
||||
'houseplan-assets/houseplan-card.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js',
|
||||
code: 'new URL("__HOUSEPLAN_EDITOR_RETRY_ASSET__", import.meta.url);'
|
||||
+ 'new URL("__HOUSEPLAN_ONBOARDING_RETRY_ASSET__", import.meta.url)', modules: {},
|
||||
+ 'new URL("__HOUSEPLAN_ONBOARDING_RETRY_ASSET__", import.meta.url);'
|
||||
+ 'new URL("__HOUSEPLAN_DE_RETRY_ASSET__", import.meta.url)', modules: {},
|
||||
},
|
||||
'houseplan-assets/houseplan-editor-runtime-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/houseplan-editor-runtime-HASH.js', code: '',
|
||||
@@ -99,12 +108,17 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
|
||||
type: 'chunk', fileName: 'houseplan-assets/houseplan-onboarding-runtime-HASH.js', code: '',
|
||||
modules: { '/repo/src/houseplan-onboarding-runtime.ts': {} },
|
||||
},
|
||||
'houseplan-assets/de-HASH.js': {
|
||||
type: 'chunk', fileName: 'houseplan-assets/de-HASH.js', code: '',
|
||||
modules: { '/repo/src/i18n/de.ts': {} },
|
||||
},
|
||||
};
|
||||
plugin.generateBundle({}, bundle);
|
||||
assert.equal(
|
||||
bundle['houseplan-assets/houseplan-card.js'].code,
|
||||
'new URL("./houseplan-editor-runtime-HASH.js", import.meta.url);'
|
||||
+ 'new URL("./houseplan-onboarding-runtime-HASH.js", import.meta.url)',
|
||||
+ 'new URL("./houseplan-onboarding-runtime-HASH.js", import.meta.url);'
|
||||
+ 'new URL("./de-HASH.js", import.meta.url)',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 48);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 49);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
@@ -807,8 +807,8 @@ test('editor tray golden contract covers every adaptive width in English and Rus
|
||||
|
||||
test('device dialog goldens expose the complete light-source controls at desktop and mobile widths', () => {
|
||||
const dialogs = GOLDEN_SCENARIOS.filter((scenario) => scenario.id.startsWith('device-dialog-'));
|
||||
assert.equal(dialogs.length, 2);
|
||||
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.language)), new Set(['en', 'ru']));
|
||||
assert.equal(dialogs.length, 3);
|
||||
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.language)), new Set(['en', 'ru', 'de']));
|
||||
for (const scenario of dialogs) {
|
||||
assert.equal(scenario.deviceLightControls, true, scenario.id);
|
||||
assert.equal(scenario.deviceId, 'golden-light-two', scenario.id);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
LanguageRuntime, languageRenderGate,
|
||||
} from '../test-build/i18n/language-runtime.js';
|
||||
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
test('lazy locale runtime deduplicates hosts and accepts the matching build', async () => {
|
||||
const load = deferred();
|
||||
let calls = 0;
|
||||
const runtime = new LanguageRuntime([
|
||||
{ code: 'en', dictionary: { save: 'Save' } },
|
||||
{ code: 'de', loadDictionary: async () => { calls++; return load.promise; } },
|
||||
], 'same-build');
|
||||
|
||||
const first = runtime.ensure('de');
|
||||
const second = runtime.ensure('de');
|
||||
assert.equal(first, second);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(runtime.state('de'), 'pending');
|
||||
load.resolve({ dictionary: { save: 'Speichern' }, fingerprint: 'same-build' });
|
||||
await first;
|
||||
assert.equal(runtime.state('de'), 'ready');
|
||||
assert.equal(runtime.dictionary('de').save, 'Speichern');
|
||||
await runtime.ensure('de');
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('lazy locale runtime retries once, warns once and settles on English fallback', async () => {
|
||||
const attempts = [];
|
||||
const warnings = [];
|
||||
const runtime = new LanguageRuntime([
|
||||
{
|
||||
code: 'de',
|
||||
loadDictionary: async (attempt) => {
|
||||
attempts.push(attempt);
|
||||
throw new Error(`failure ${attempt}`);
|
||||
},
|
||||
},
|
||||
], 'build', (message, error) => warnings.push([message, error]));
|
||||
|
||||
await runtime.ensure('de');
|
||||
assert.deepEqual(attempts, [0, 1]);
|
||||
assert.equal(runtime.state('de'), 'fallback');
|
||||
assert.equal(runtime.dictionary('de'), undefined);
|
||||
assert.equal(warnings.length, 1);
|
||||
await runtime.ensure('de');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.deepEqual(attempts, [0, 1]);
|
||||
});
|
||||
|
||||
test('lazy locale runtime rejects a stale fingerprint before accepting retry', async () => {
|
||||
const runtime = new LanguageRuntime([{
|
||||
code: 'de',
|
||||
loadDictionary: async (attempt) => ({
|
||||
dictionary: { save: attempt ? 'Speichern' : 'Stale' },
|
||||
fingerprint: attempt ? 'current' : 'old',
|
||||
}),
|
||||
}], 'current');
|
||||
await runtime.ensure('de');
|
||||
assert.equal(runtime.dictionary('de').save, 'Speichern');
|
||||
});
|
||||
|
||||
class FakeHost {
|
||||
controllers = [];
|
||||
inert = false;
|
||||
isConnected = true;
|
||||
attrs = new Map();
|
||||
updates = 0;
|
||||
addController(controller) { this.controllers.push(controller); }
|
||||
requestUpdate() { this.updates++; }
|
||||
hasAttribute(name) { return this.attrs.has(name); }
|
||||
setAttribute(name, value) { this.attrs.set(name, value); }
|
||||
removeAttribute(name) { this.attrs.delete(name); }
|
||||
}
|
||||
|
||||
test('locale render gate exposes cold/warm states, inertness and ignores disconnected hosts', async () => {
|
||||
const load = deferred();
|
||||
const runtime = new LanguageRuntime([
|
||||
{ code: 'en', dictionary: {} },
|
||||
{ code: 'de', loadDictionary: async () => load.promise },
|
||||
], 'build');
|
||||
const host = new FakeHost();
|
||||
let language = 'en';
|
||||
assert.equal(languageRenderGate(host, runtime, language), 'ready');
|
||||
assert.equal(host.attrs.get('lang'), 'en');
|
||||
language = 'de';
|
||||
assert.equal(languageRenderGate(host, runtime, language), 'warm');
|
||||
assert.equal(host.inert, true);
|
||||
assert.equal(host.attrs.get('aria-busy'), 'true');
|
||||
host.isConnected = false;
|
||||
load.resolve({ dictionary: {}, fingerprint: 'build' });
|
||||
await runtime.ensure('de');
|
||||
await Promise.resolve();
|
||||
assert.equal(host.updates, 0);
|
||||
assert.equal(languageRenderGate(host, runtime, language), 'ready');
|
||||
assert.equal(host.inert, false);
|
||||
assert.equal(host.hasAttribute('aria-busy'), false);
|
||||
assert.equal(host.attrs.get('lang'), 'de');
|
||||
|
||||
const coldLoad = deferred();
|
||||
const coldRuntime = new LanguageRuntime([{
|
||||
code: 'de', loadDictionary: async () => coldLoad.promise,
|
||||
}], 'build');
|
||||
const coldHost = new FakeHost();
|
||||
assert.equal(languageRenderGate(coldHost, coldRuntime, 'de'), 'cold');
|
||||
coldLoad.resolve({ dictionary: {}, fingerprint: 'build' });
|
||||
await coldRuntime.ensure('de');
|
||||
await Promise.resolve();
|
||||
assert.equal(coldHost.updates, 1);
|
||||
assert.equal(languageRenderGate(coldHost, coldRuntime, 'de'), 'ready');
|
||||
assert.equal(coldHost.attrs.get('lang'), 'de');
|
||||
});
|
||||
|
||||
test('locale render gate exposes the fallback language to assistive technology', async () => {
|
||||
const runtime = new LanguageRuntime([{
|
||||
code: 'de', loadDictionary: async () => { throw new Error('offline'); },
|
||||
}], 'build', () => {});
|
||||
const host = new FakeHost();
|
||||
assert.equal(languageRenderGate(host, runtime, 'de'), 'cold');
|
||||
await runtime.ensure('de');
|
||||
assert.equal(languageRenderGate(host, runtime, 'de'), 'ready');
|
||||
assert.equal(host.attrs.get('lang'), 'en');
|
||||
});
|
||||
+102
-14
@@ -6,6 +6,8 @@ import {
|
||||
FALLBACK_LANGUAGE_CODE,
|
||||
LANGUAGE_REGISTRY,
|
||||
buildLanguageLookup,
|
||||
dictionaryFor,
|
||||
ensureLanguage,
|
||||
languageEntry,
|
||||
languageOptions,
|
||||
normalizeLanguageTag,
|
||||
@@ -14,11 +16,11 @@ import {
|
||||
import { langOf } from '../test-build/i18n.js';
|
||||
import { readHouseplanProductionSource } from './houseplan-source.mjs';
|
||||
|
||||
const dictionaries = new Map(
|
||||
LANGUAGE_REGISTRY.map(({ code, dictionary }) => [code, dictionary]),
|
||||
);
|
||||
await Promise.all(LANGUAGE_REGISTRY.map(({ code }) => ensureLanguage(code)));
|
||||
const dictionaries = new Map(LANGUAGE_REGISTRY.map(({ code }) => [code, dictionaryFor(code)]));
|
||||
const en = dictionaries.get('en');
|
||||
const ru = dictionaries.get('ru');
|
||||
const de = dictionaries.get('de');
|
||||
const cardSource = readHouseplanProductionSource();
|
||||
|
||||
test('i18n: registry codes and English fallback are valid', () => {
|
||||
@@ -32,12 +34,15 @@ test('i18n: registry codes and English fallback are valid', () => {
|
||||
normalizedCodes.length,
|
||||
'registry codes must be unique after locale normalization',
|
||||
);
|
||||
for (const { code, nativeLabel, dictionary } of LANGUAGE_REGISTRY) {
|
||||
for (const { code, nativeLabel, dictionary, loadDictionary } of LANGUAGE_REGISTRY) {
|
||||
assert.equal(code, code.trim(), `${code} has surrounding whitespace`);
|
||||
assert.doesNotMatch(code, /_/u, `${code} must use BCP 47 hyphens`);
|
||||
assert.match(code, /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u);
|
||||
assert.ok(nativeLabel.trim(), `${code} has no native label`);
|
||||
assert.ok(dictionary && typeof dictionary === 'object', `${code} has no dictionary`);
|
||||
assert.ok(
|
||||
(dictionary && typeof dictionary === 'object') || typeof loadDictionary === 'function',
|
||||
`${code} has neither an eager dictionary nor a lazy loader`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,15 +74,48 @@ test('i18n: registry matches frontend and backend locale files', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('i18n: backend dictionaries preserve the English structure and placeholders', () => {
|
||||
const flatten = (value, prefix = '', result = new Map()) => {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (child && typeof child === 'object' && !Array.isArray(child)) flatten(child, path, result);
|
||||
else result.set(path, child);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const placeholders = (value) => (String(value).match(/\{\w+\}/gu) || []).sort();
|
||||
const backend = new Map(LANGUAGE_REGISTRY.map(({ code }) => [
|
||||
code,
|
||||
flatten(JSON.parse(readFileSync(
|
||||
new URL(`../custom_components/houseplan/translations/${code}.json`, import.meta.url),
|
||||
'utf8',
|
||||
))),
|
||||
]));
|
||||
const english = backend.get('en');
|
||||
for (const { code } of LANGUAGE_REGISTRY) {
|
||||
const dictionary = backend.get(code);
|
||||
assert.deepEqual([...dictionary.keys()].sort(), [...english.keys()].sort(), `${code} backend key set differs`);
|
||||
for (const [key, value] of english) {
|
||||
assert.deepEqual(
|
||||
placeholders(dictionary.get(key)),
|
||||
placeholders(value),
|
||||
`${code} backend placeholder mismatch at ${key}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('i18n: every registered dictionary carries the English key set', () => {
|
||||
const enKeys = Object.keys(en).sort();
|
||||
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
|
||||
for (const { code } of LANGUAGE_REGISTRY) {
|
||||
const dictionary = dictionaries.get(code);
|
||||
assert.deepEqual(Object.keys(dictionary).sort(), enKeys, `${code} key set differs`);
|
||||
}
|
||||
});
|
||||
|
||||
test('i18n: no empty values', () => {
|
||||
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
|
||||
for (const { code } of LANGUAGE_REGISTRY) {
|
||||
const dictionary = dictionaries.get(code);
|
||||
for (const [k, v] of Object.entries(dictionary)) {
|
||||
assert.ok(typeof v === 'string' && v.length > 0, `${code}:${k} is empty`);
|
||||
}
|
||||
@@ -87,14 +125,15 @@ test('i18n: no empty values', () => {
|
||||
test('i18n: placeholders match between languages', () => {
|
||||
const ph = (s) => (s.match(/\{\w+\}/g) || []).sort().join(',');
|
||||
for (const k of Object.keys(en)) {
|
||||
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
|
||||
for (const { code } of LANGUAGE_REGISTRY) {
|
||||
const dictionary = dictionaries.get(code);
|
||||
assert.equal(ph(en[k]), ph(dictionary[k]), `placeholder mismatch in ${code}:${k}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('i18n: resolver supports exact, primary, explicit and fallback paths', () => {
|
||||
const supported = ['en', 'ru', 'pt-BR'];
|
||||
const supported = ['en', 'ru', 'de', 'pt-BR'];
|
||||
const cases = [
|
||||
{ explicit: 'RU', ha: 'en-US', expected: 'ru' },
|
||||
{ explicit: 'pt_BR', ha: 'ru-RU', expected: 'pt-BR' },
|
||||
@@ -103,7 +142,9 @@ test('i18n: resolver supports exact, primary, explicit and fallback paths', () =
|
||||
{ explicit: null, ha: 'ru-RU', expected: 'ru' },
|
||||
{ explicit: 'ru-RU', ha: 'en-GB', expected: 'en' },
|
||||
{ explicit: undefined, ha: 'pt-PT', expected: 'en' },
|
||||
{ explicit: undefined, ha: 'de-DE', expected: 'en' },
|
||||
{ explicit: undefined, ha: 'de-DE', expected: 'de' },
|
||||
{ explicit: undefined, ha: 'de-AT', expected: 'de' },
|
||||
{ explicit: undefined, ha: 'de-CH', expected: 'de' },
|
||||
];
|
||||
for (const { explicit, ha, expected } of cases) {
|
||||
assert.equal(resolveLanguageCode(explicit, ha, supported, 'en'), expected);
|
||||
@@ -114,7 +155,7 @@ test('i18n: langOf wires card config and both HA locale shapes to the registry',
|
||||
assert.equal(langOf({ locale: { language: 'ru_RU' }, language: 'en' }), 'ru');
|
||||
assert.equal(langOf({ locale: { language: 'en-GB' }, language: 'ru' }, 'RU'), 'ru');
|
||||
assert.equal(langOf({ language: 'ru-RU' }, 'unknown'), 'ru');
|
||||
assert.equal(langOf({ locale: { language: 'de-DE' } }, 'unknown'), 'en');
|
||||
assert.equal(langOf({ locale: { language: 'de-DE' } }, 'unknown'), 'de');
|
||||
});
|
||||
|
||||
test('i18n: editor options follow registry order and preserve unknown raw values', () => {
|
||||
@@ -123,7 +164,7 @@ test('i18n: editor options follow registry order and preserve unknown raw values
|
||||
...LANGUAGE_REGISTRY.map(({ code, nativeLabel }) => ({ value: code, label: nativeLabel })),
|
||||
]);
|
||||
assert.deepEqual(languageOptions('Auto', 'ru'), languageOptions('Auto'));
|
||||
assert.deepEqual(languageOptions('Auto', 'de').at(-1), { value: 'de', label: 'de' });
|
||||
assert.deepEqual(languageOptions('Auto', 'de'), languageOptions('Auto'));
|
||||
assert.deepEqual(
|
||||
languageOptions('Auto', ' RU ').at(-1),
|
||||
{ value: ' RU ', label: 'Русский' },
|
||||
@@ -242,7 +283,8 @@ test('i18n: every literal help call has body and full aria keys in both language
|
||||
assert.equal(helpKeys.length, allCalls.length, 'every _help call must use one string literal ending in .help');
|
||||
assert.ok(helpKeys.length > 0, 'the help affordance pilot disappeared');
|
||||
for (const key of helpKeys) {
|
||||
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
|
||||
for (const { code } of LANGUAGE_REGISTRY) {
|
||||
const dictionary = dictionaries.get(code);
|
||||
assert.ok(dictionary[key]?.trim(), `${code}:${key} is missing or empty`);
|
||||
assert.ok(dictionary[`${key}.aria`]?.trim(), `${code}:${key}.aria is missing or empty`);
|
||||
}
|
||||
@@ -274,9 +316,55 @@ test('help hosts do not duplicate their explanation in a title attribute', () =>
|
||||
});
|
||||
|
||||
test('vac toasts never mention the removed point calibration (HP-1540-06)', () => {
|
||||
for (const { code: lang, dictionary: d } of LANGUAGE_REGISTRY) {
|
||||
for (const { code: lang } of LANGUAGE_REGISTRY) {
|
||||
const d = dictionaries.get(lang);
|
||||
for (const k of ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.residual_message']) {
|
||||
assert.ok(!/point|точк/i.test(d[k]), `${lang}:${k} still points at point calibration`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('i18n: German catalog keeps the product glossary and has no translation sentinels', () => {
|
||||
assert.equal(de['btn.save'], 'Speichern');
|
||||
assert.equal(de['btn.cancel'], 'Abbrechen');
|
||||
assert.equal(de['btn.delete'], 'Löschen');
|
||||
assert.equal(de['space.header'], 'Bereich');
|
||||
assert.equal(de['physical.partition_properties'], 'Eigenschaften der Trennwand');
|
||||
assert.equal(de['opening.passage'], 'Offener Durchgang');
|
||||
for (const [key, value] of Object.entries(de)) {
|
||||
assert.doesNotMatch(value, /ZXQPH|QXZ|⟦HP/u, `de:${key} contains a translator sentinel`);
|
||||
assert.doesNotMatch(value, /[А-Яа-яЁё]/u, `de:${key} contains Cyrillic text`);
|
||||
}
|
||||
});
|
||||
|
||||
test('i18n: German values equal to English are explicitly reviewed', () => {
|
||||
const allowed = new Set([
|
||||
'editor.palette',
|
||||
'confirm.delete_partition_openings_item',
|
||||
'marker.name_ph',
|
||||
'marker.pulse_a11y_alarm',
|
||||
'marker.state_a11y_alarm',
|
||||
'marker.link_label',
|
||||
'rules.icon_ph',
|
||||
'decor.ellipse',
|
||||
'decor.text',
|
||||
'decor.text_label',
|
||||
'gs.unit_m',
|
||||
'gs.unit_ft',
|
||||
'wallthick.unit_cm',
|
||||
'wallthick.unit_in',
|
||||
'marker.value_badge_position',
|
||||
'marker.value_badge_attr_current_position',
|
||||
'gs.north_letter',
|
||||
'gs.optimize_live_names',
|
||||
'gs.optimize_details',
|
||||
'furn.symbol',
|
||||
'furn.sym_sofa',
|
||||
'furn.sym_bidet',
|
||||
'vac.diag_platform',
|
||||
'vac.diag_status',
|
||||
'vac.diag_position',
|
||||
]);
|
||||
const equalKeys = Object.keys(en).filter((key) => en[key] === de[key]);
|
||||
assert.deepEqual(new Set(equalKeys), allowed);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import test from 'node:test';
|
||||
const cardSource = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url), 'utf8'));
|
||||
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url), 'utf8'));
|
||||
const de = JSON.parse(readFileSync(new URL('../src/i18n/de.json', import.meta.url), 'utf8'));
|
||||
|
||||
test('runtime exposes only the unified Walls tool state', () => {
|
||||
const toolType = cardSource.match(/type MarkupTool = ([^;]+);/)?.[1] || '';
|
||||
@@ -18,7 +19,7 @@ test('runtime exposes only the unified Walls tool state', () => {
|
||||
});
|
||||
|
||||
test('only old tool copy is removed while persisted partition copy remains', () => {
|
||||
for (const locale of [en, ru]) {
|
||||
for (const locale of [en, ru, de]) {
|
||||
for (const key of [
|
||||
'title.markup_partition',
|
||||
'markup.hint_partition',
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"tsBuildInfoFile": "test-build/.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
|
||||
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/i18n/language-runtime.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
|
||||
"src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts",
|
||||
"src/ha-binding-status.ts",
|
||||
"src/integration-provider.ts", "src/vacuum.ts",
|
||||
|
||||
Reference in New Issue
Block a user