Add core country and language settings (#14478)

This commit is contained in:
Bram Kragten 2022-11-29 20:54:18 +01:00 committed by GitHub
parent ee6f97b802
commit 92d022747b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 495 additions and 57 deletions

View File

@ -112,7 +112,7 @@
"google-timezones-json": "^1.0.2", "google-timezones-json": "^1.0.2",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"hls.js": "^1.2.5", "hls.js": "^1.2.5",
"home-assistant-js-websocket": "^8.0.0", "home-assistant-js-websocket": "^8.0.1",
"idb-keyval": "^5.1.3", "idb-keyval": "^5.1.3",
"intl-messageformat": "^9.9.1", "intl-messageformat": "^9.9.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",

View File

@ -0,0 +1,273 @@
export const countries = [
"AD",
"AE",
"AF",
"AG",
"AI",
"AL",
"AM",
"AO",
"AQ",
"AR",
"AS",
"AT",
"AU",
"AW",
"AX",
"AZ",
"BA",
"BB",
"BD",
"BE",
"BF",
"BG",
"BH",
"BI",
"BJ",
"BL",
"BM",
"BN",
"BO",
"BQ",
"BR",
"BS",
"BT",
"BV",
"BW",
"BY",
"BZ",
"CA",
"CC",
"CD",
"CF",
"CG",
"CH",
"CI",
"CK",
"CL",
"CM",
"CN",
"CO",
"CR",
"CU",
"CV",
"CW",
"CX",
"CY",
"CZ",
"DE",
"DJ",
"DK",
"DM",
"DO",
"DZ",
"EC",
"EE",
"EG",
"EH",
"ER",
"ES",
"ET",
"FI",
"FJ",
"FK",
"FM",
"FO",
"FR",
"GA",
"GB",
"GD",
"GE",
"GF",
"GG",
"GH",
"GI",
"GL",
"GM",
"GN",
"GP",
"GQ",
"GR",
"GS",
"GT",
"GU",
"GW",
"GY",
"HK",
"HM",
"HN",
"HR",
"HT",
"HU",
"ID",
"IE",
"IL",
"IM",
"IN",
"IO",
"IQ",
"IR",
"IS",
"IT",
"JE",
"JM",
"JO",
"JP",
"KE",
"KG",
"KH",
"KI",
"KM",
"KN",
"KP",
"KR",
"KW",
"KY",
"KZ",
"LA",
"LB",
"LC",
"LI",
"LK",
"LR",
"LS",
"LT",
"LU",
"LV",
"LY",
"MA",
"MC",
"MD",
"ME",
"MF",
"MG",
"MH",
"MK",
"ML",
"MM",
"MN",
"MO",
"MP",
"MQ",
"MR",
"MS",
"MT",
"MU",
"MV",
"MW",
"MX",
"MY",
"MZ",
"NA",
"NC",
"NE",
"NF",
"NG",
"NI",
"NL",
"NO",
"NP",
"NR",
"NU",
"NZ",
"OM",
"PA",
"PE",
"PF",
"PG",
"PH",
"PK",
"PL",
"PM",
"PN",
"PR",
"PS",
"PT",
"PW",
"PY",
"QA",
"RE",
"RO",
"RS",
"RU",
"RW",
"SA",
"SB",
"SC",
"SD",
"SE",
"SG",
"SH",
"SI",
"SJ",
"SK",
"SL",
"SM",
"SN",
"SO",
"SR",
"SS",
"ST",
"SV",
"SX",
"SY",
"SZ",
"TC",
"TD",
"TF",
"TG",
"TH",
"TJ",
"TK",
"TL",
"TM",
"TN",
"TO",
"TR",
"TT",
"TV",
"TW",
"TZ",
"UA",
"UG",
"UM",
"US",
"UY",
"UZ",
"VA",
"VC",
"VE",
"VG",
"VI",
"VN",
"VU",
"WF",
"WS",
"YE",
"YT",
"ZA",
"ZM",
"ZW",
];
export const countryDisplayNames =
Intl && "DisplayNames" in Intl
? new Intl.DisplayNames(undefined, {
type: "region",
fallback: "code",
})
: undefined;
export const createCountryListEl = () => {
const list = document.createElement("datalist");
list.id = "countries";
for (const country of countries) {
const option = document.createElement("option");
option.value = country;
option.innerText = countryDisplayNames
? countryDisplayNames.of(country)!
: country;
list.appendChild(option);
}
return list;
};

View File

@ -158,13 +158,23 @@ export const currencies = [
"ZWL", "ZWL",
]; ];
export const currencyDisplayNames =
Intl && "DisplayNames" in Intl
? new Intl.DisplayNames(undefined, {
type: "currency",
fallback: "code",
})
: undefined;
export const createCurrencyListEl = () => { export const createCurrencyListEl = () => {
const list = document.createElement("datalist"); const list = document.createElement("datalist");
list.id = "currencies"; list.id = "currencies";
for (const currency of currencies) { for (const currency of currencies) {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = currency; option.value = currency;
option.innerHTML = currency; option.innerText = currencyDisplayNames
? currencyDisplayNames.of(currency)!
: currency;
list.appendChild(option); list.appendChild(option);
} }
return list; return list;

View File

@ -1,7 +1,7 @@
import { TextFieldBase } from "@material/mwc-textfield/mwc-textfield-base"; import { TextFieldBase } from "@material/mwc-textfield/mwc-textfield-base";
import { styles } from "@material/mwc-textfield/mwc-textfield.css"; import { styles } from "@material/mwc-textfield/mwc-textfield.css";
import { TemplateResult, html, PropertyValues, css } from "lit"; import { TemplateResult, html, PropertyValues, css } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
@customElement("ha-textfield") @customElement("ha-textfield")
export class HaTextField extends TextFieldBase { export class HaTextField extends TextFieldBase {
@ -17,6 +17,8 @@ export class HaTextField extends TextFieldBase {
@property() public autocomplete?: string; @property() public autocomplete?: string;
@query("input") public formElement!: HTMLInputElement;
override updated(changedProperties: PropertyValues) { override updated(changedProperties: PropertyValues) {
super.updated(changedProperties); super.updated(changedProperties);
if ( if (

View File

@ -0,0 +1,15 @@
import { HomeAssistant } from "../types";
export const createLanguageListEl = (hass: HomeAssistant) => {
const list = document.createElement("datalist");
list.id = "languages";
for (const [language, metadata] of Object.entries(
hass.translationMetadata.translations
)) {
const option = document.createElement("option");
option.value = language;
option.innerText = metadata.nativeName;
list.appendChild(option);
}
return list;
};

View File

@ -133,11 +133,11 @@ export class HaMap extends ReactiveElement {
if ( if (
!changedProps.has("darkMode") && !changedProps.has("darkMode") &&
(!changedProps.has("hass") || (!changedProps.has("hass") ||
(oldHass && oldHass.themes.darkMode === this.hass.themes.darkMode)) (oldHass && oldHass.themes?.darkMode === this.hass.themes?.darkMode))
) { ) {
return; return;
} }
const darkMode = this.darkMode ?? this.hass.themes.darkMode; const darkMode = this.darkMode ?? this.hass.themes?.darkMode;
this.shadowRoot!.getElementById("map")!.classList.toggle("dark", darkMode); this.shadowRoot!.getElementById("map")!.classList.toggle("dark", darkMode);
} }

View File

@ -6,7 +6,7 @@ export const createTimezoneListEl = () => {
Object.keys(timezones).forEach((key) => { Object.keys(timezones).forEach((key) => {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = key; option.value = key;
option.innerHTML = timezones[key]; option.innerText = timezones[key];
list.appendChild(option); list.appendChild(option);
}); });
return list; return list;

View File

@ -11,6 +11,8 @@ export interface ConfigUpdateValues {
external_url?: string | null; external_url?: string | null;
internal_url?: string | null; internal_url?: string | null;
currency?: string | null; currency?: string | null;
country?: string | null;
language?: string | null;
} }
export interface CheckConfigResult { export interface CheckConfigResult {

View File

@ -32,4 +32,6 @@ export const demoConfig: HassConfig = {
internal_url: "http://homeassistant.local:8123", internal_url: "http://homeassistant.local:8123",
external_url: null, external_url: null,
currency: "USD", currency: "USD",
language: "en",
country: "NL",
}; };

View File

@ -131,6 +131,17 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
if (window.innerWidth > 450) { if (window.innerWidth > 450) {
import("./particles"); import("./particles");
} }
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("language")) {
document.querySelector("html")!.setAttribute("lang", this.language!);
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
this.hassChanged(this.hass!, oldHass);
if (oldHass?.themes !== this.hass!.themes) {
if (matchMedia("(prefers-color-scheme: dark)").matches) { if (matchMedia("(prefers-color-scheme: dark)").matches) {
applyThemesOnElement( applyThemesOnElement(
document.documentElement, document.documentElement,
@ -147,17 +158,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
); );
} }
} }
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("language")) {
document.querySelector("html")!.setAttribute("lang", this.language!);
}
if (changedProps.has("hass")) {
this.hassChanged(
this.hass!,
changedProps.get("hass") as HomeAssistant | undefined
);
} }
} }

View File

@ -1,6 +1,4 @@
import "@material/mwc-button/mwc-button"; import "@material/mwc-button/mwc-button";
import "@polymer/paper-input/paper-input";
import type { PaperInputElement } from "@polymer/paper-input/paper-input";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
@ -25,6 +23,11 @@ import type { HomeAssistant } from "../types";
import "../components/ha-radio"; import "../components/ha-radio";
import "../components/ha-formfield"; import "../components/ha-formfield";
import type { HaRadio } from "../components/ha-radio"; import type { HaRadio } from "../components/ha-radio";
import type { HaTextField } from "../components/ha-textfield";
import "../components/ha-textfield";
import { getLocalLanguage } from "../util/common-translation";
import { createCountryListEl } from "../components/country-datalist";
import { createLanguageListEl } from "../components/language-datalist";
const amsterdam: [number, number] = [52.3731339, 4.8903147]; const amsterdam: [number, number] = [52.3731339, 4.8903147];
const mql = matchMedia("(prefers-color-scheme: dark)"); const mql = matchMedia("(prefers-color-scheme: dark)");
@ -50,6 +53,10 @@ class OnboardingCoreConfig extends LitElement {
@state() private _timeZone?: string; @state() private _timeZone?: string;
@state() private _language?: ConfigUpdateValues["language"];
@state() private _country?: ConfigUpdateValues["country"];
@query("ha-locations-editor", true) private map!: HaLocationsEditor; @query("ha-locations-editor", true) private map!: HaLocationsEditor;
protected render(): TemplateResult { protected render(): TemplateResult {
@ -62,15 +69,15 @@ class OnboardingCoreConfig extends LitElement {
)} )}
</p> </p>
<paper-input <ha-textfield
.label=${this.onboardingLocalize( .label=${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.location_name" "ui.panel.page-onboarding.core-config.location_name"
)} )}
name="name" name="name"
.disabled=${this._working} .disabled=${this._working}
.value=${this._nameValue} .value=${this._nameValue}
@value-changed=${this._handleChange} @change=${this._handleChange}
></paper-input> ></ha-textfield>
<div class="middle-text"> <div class="middle-text">
<p> <p>
@ -105,19 +112,42 @@ class OnboardingCoreConfig extends LitElement {
</div> </div>
<div class="row"> <div class="row">
<paper-input <ha-textfield
class="flex"
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.country"
)}
name="country"
.disabled=${this._working}
.value=${this._countryValue}
@change=${this._handleChange}
></ha-textfield>
<ha-textfield
class="flex"
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.language"
)}
name="language"
.disabled=${this._working}
.value=${this._languageValue}
@change=${this._handleChange}
></ha-textfield>
</div>
<div class="row">
<ha-textfield
class="flex" class="flex"
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.time_zone" "ui.panel.config.core.section.core.core_config.time_zone"
)} )}
name="timeZone" name="timeZone"
list="timezones"
.disabled=${this._working} .disabled=${this._working}
.value=${this._timeZoneValue} .value=${this._timeZoneValue}
@value-changed=${this._handleChange} @change=${this._handleChange}
></paper-input> ></ha-textfield>
<paper-input <ha-textfield
class="flex" class="flex"
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation" "ui.panel.config.core.section.core.core_config.elevation"
@ -126,14 +156,14 @@ class OnboardingCoreConfig extends LitElement {
type="number" type="number"
.disabled=${this._working} .disabled=${this._working}
.value=${this._elevationValue} .value=${this._elevationValue}
@value-changed=${this._handleChange} @change=${this._handleChange}
> >
<span slot="suffix"> <span slot="suffix">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation_meters" "ui.panel.config.core.section.core.core_config.elevation_meters"
)} )}
</span> </span>
</paper-input> </ha-textfield>
</div> </div>
<div class="row"> <div class="row">
@ -197,17 +227,16 @@ class OnboardingCoreConfig extends LitElement {
> >
</div> </div>
<paper-input <ha-textfield
class="flex" class="flex"
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.currency" "ui.panel.config.core.section.core.core_config.currency"
)} )}
name="currency" name="currency"
list="currencies"
.disabled=${this._working} .disabled=${this._working}
.value=${this._currencyValue} .value=${this._currencyValue}
@value-changed=${this._handleChange} @change=${this._handleChange}
></paper-input> ></ha-textfield>
</div> </div>
</div> </div>
@ -224,7 +253,7 @@ class OnboardingCoreConfig extends LitElement {
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
setTimeout( setTimeout(
() => this.shadowRoot!.querySelector("paper-input")!.focus(), () => this.shadowRoot!.querySelector("ha-textfield")!.focus(),
100 100
); );
this.addEventListener("keypress", (ev) => { this.addEventListener("keypress", (ev) => {
@ -234,13 +263,35 @@ class OnboardingCoreConfig extends LitElement {
}); });
const tzInput = this.shadowRoot!.querySelector( const tzInput = this.shadowRoot!.querySelector(
"[name=timeZone]" "[name=timeZone]"
) as PaperInputElement; ) as HaTextField;
tzInput.inputElement.appendChild(createTimezoneListEl()); tzInput.updateComplete.then(() => {
tzInput.shadowRoot!.appendChild(createTimezoneListEl());
tzInput.formElement.setAttribute("list", "timezones");
});
const cInput = this.shadowRoot!.querySelector( const curInput = this.shadowRoot!.querySelector(
"[name=currency]" "[name=currency]"
) as PaperInputElement; ) as HaTextField;
cInput.inputElement.appendChild(createCurrencyListEl()); curInput.updateComplete.then(() => {
curInput.shadowRoot!.appendChild(createCurrencyListEl());
curInput.formElement.setAttribute("list", "currencies");
});
const countryInput = this.shadowRoot!.querySelector(
"[name=country]"
) as HaTextField;
countryInput.updateComplete.then(() => {
countryInput.shadowRoot!.appendChild(createCountryListEl());
countryInput.formElement.setAttribute("list", "countries");
});
const langInput = this.shadowRoot!.querySelector(
"[name=language]"
) as HaTextField;
langInput.updateComplete.then(() => {
langInput.shadowRoot!.appendChild(createLanguageListEl(this.hass));
langInput.formElement.setAttribute("list", "languages");
});
} }
private get _nameValue() { private get _nameValue() {
@ -260,7 +311,15 @@ class OnboardingCoreConfig extends LitElement {
} }
private get _timeZoneValue() { private get _timeZoneValue() {
return this._timeZone; return this._timeZone || "";
}
private get _languageValue() {
return this._language || "";
}
private get _countryValue() {
return this._country || "";
} }
private get _unitSystemValue() { private get _unitSystemValue() {
@ -283,7 +342,7 @@ class OnboardingCoreConfig extends LitElement {
); );
private _handleChange(ev: PolymerChangedEvent<string>) { private _handleChange(ev: PolymerChangedEvent<string>) {
const target = ev.currentTarget as PaperInputElement; const target = ev.currentTarget as HaTextField;
let value = target.value; let value = target.value;
@ -335,6 +394,10 @@ class OnboardingCoreConfig extends LitElement {
if (values.currency) { if (values.currency) {
this._currency = values.currency; this._currency = values.currency;
} }
if (values.country) {
this._country = values.country;
}
this._language = getLocalLanguage();
} catch (err: any) { } catch (err: any) {
alert(`Failed to detect location information: ${err.message}`); alert(`Failed to detect location information: ${err.message}`);
} finally { } finally {
@ -355,6 +418,8 @@ class OnboardingCoreConfig extends LitElement {
unit_system: this._unitSystemValue, unit_system: this._unitSystemValue,
time_zone: this._timeZoneValue || "UTC", time_zone: this._timeZoneValue || "UTC",
currency: this._currencyValue || "EUR", currency: this._currencyValue || "EUR",
country: this._countryValue,
language: this._languageValue,
}); });
const result = await onboardCoreConfigStep(this.hass); const result = await onboardCoreConfigStep(this.hass);
fireEvent(this, "onboarding-step", { fireEvent(this, "onboarding-step", {
@ -380,6 +445,10 @@ class OnboardingCoreConfig extends LitElement {
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
ha-textfield {
display: block;
}
ha-locations-editor { ha-locations-editor {
height: 200px; height: 200px;
} }
@ -389,7 +458,11 @@ class OnboardingCoreConfig extends LitElement {
} }
.middle-text { .middle-text {
margin: 24px 0; margin: 16px 0;
}
.row {
margin-top: 16px;
} }
.row > * { .row > * {

View File

@ -8,7 +8,14 @@ import { stopPropagation } from "../../../common/dom/stop_propagation";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import "../../../components/buttons/ha-progress-button"; import "../../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button"; import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import { currencies } from "../../../components/currency-datalist"; import {
countries,
countryDisplayNames,
} from "../../../components/country-datalist";
import {
currencies,
currencyDisplayNames,
} from "../../../components/currency-datalist";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-formfield"; import "../../../components/ha-formfield";
import "../../../components/ha-radio"; import "../../../components/ha-radio";
@ -36,6 +43,10 @@ class HaConfigSectionGeneral extends LitElement {
@state() private _currency?: string; @state() private _currency?: string;
@state() private _language?: string;
@state() private _country?: string | null;
@state() private _name?: string; @state() private _name?: string;
@state() private _elevation?: number; @state() private _elevation?: number;
@ -179,7 +190,9 @@ class HaConfigSectionGeneral extends LitElement {
${currencies.map( ${currencies.map(
(currency) => (currency) =>
html`<mwc-list-item .value=${currency} html`<mwc-list-item .value=${currency}
>${currency}</mwc-list-item >${currencyDisplayNames
? currencyDisplayNames.of(currency)
: currency}</mwc-list-item
>` >`
)}</ha-select )}</ha-select
> >
@ -193,6 +206,48 @@ class HaConfigSectionGeneral extends LitElement {
)}</a )}</a
> >
</div> </div>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.country"
)}
name="country"
fixedMenuPosition
naturalMenuWidth
.disabled=${disabled}
.value=${this._country}
@closed=${stopPropagation}
@change=${this._handleChange}
>
${countries.map(
(country) =>
html`<mwc-list-item .value=${country}
>${countryDisplayNames
? countryDisplayNames.of(country)
: country}</mwc-list-item
>`
)}</ha-select
>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.language"
)}
name="language"
fixedMenuPosition
naturalMenuWidth
.disabled=${disabled}
.value=${this._language}
@closed=${stopPropagation}
@change=${this._handleChange}
>
${Object.entries(
this.hass.translationMetadata.translations
).map(
([code, metadata]) =>
html`<mwc-list-item .value=${code}
>${metadata.nativeName}</mwc-list-item
>`
)}</ha-select
>
</div> </div>
${this.narrow ${this.narrow
? html` ? html`
@ -240,6 +295,8 @@ class HaConfigSectionGeneral extends LitElement {
? "metric" ? "metric"
: "us_customary"; : "us_customary";
this._currency = this.hass.config.currency; this._currency = this.hass.config.currency;
this._country = this.hass.config.country;
this._language = this.hass.config.language;
this._elevation = this.hass.config.elevation; this._elevation = this.hass.config.elevation;
this._timeZone = this.hass.config.time_zone; this._timeZone = this.hass.config.time_zone;
this._name = this.hass.config.location_name; this._name = this.hass.config.location_name;
@ -291,6 +348,8 @@ class HaConfigSectionGeneral extends LitElement {
unit_system: this._unitSystem, unit_system: this._unitSystem,
time_zone: this._timeZone, time_zone: this._timeZone,
location_name: this._name, location_name: this._name,
language: this._language,
country: this._country,
...locationConfig, ...locationConfig,
}); });
button.actionSuccess(); button.actionSuccess();

View File

@ -1654,6 +1654,8 @@
"elevation": "Elevation", "elevation": "Elevation",
"elevation_meters": "meters", "elevation_meters": "meters",
"time_zone": "Time Zone", "time_zone": "Time Zone",
"language": "Language",
"country": "Country",
"unit_system": "Unit System", "unit_system": "Unit System",
"unit_system_us_customary": "US customary", "unit_system_us_customary": "US customary",
"unit_system_metric": "Metric", "unit_system_metric": "Metric",

View File

@ -9419,7 +9419,7 @@ fsevents@^1.2.7:
gulp-zopfli-green: ^3.0.1 gulp-zopfli-green: ^3.0.1
hammerjs: ^2.0.8 hammerjs: ^2.0.8
hls.js: ^1.2.5 hls.js: ^1.2.5
home-assistant-js-websocket: ^8.0.0 home-assistant-js-websocket: ^8.0.1
html-minifier: ^4.0.0 html-minifier: ^4.0.0
husky: ^8.0.1 husky: ^8.0.1
idb-keyval: ^5.1.3 idb-keyval: ^5.1.3
@ -9492,10 +9492,10 @@ fsevents@^1.2.7:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"home-assistant-js-websocket@npm:^8.0.0": "home-assistant-js-websocket@npm:^8.0.1":
version: 8.0.0 version: 8.0.1
resolution: "home-assistant-js-websocket@npm:8.0.0" resolution: "home-assistant-js-websocket@npm:8.0.1"
checksum: 55fecd70e10ab3c74b4c6c78dbf11d56e22e9799e050096a24c74c3844c48d458f42f43c353b9b050d49acecb323a137df8667428206a07a426f030d339a42e7 checksum: e8b2204d58b2b1fbdf26ca1ad196fcc02ec5d18e6d867179f27246a9f2d4fe5f91de9dbbe7b82806c19dcb0af0e2b77fb48d393668b5c8c0844c201a16832023
languageName: node languageName: node
linkType: hard linkType: hard