492 lines
15 KiB
TypeScript
492 lines
15 KiB
TypeScript
import { atLeastVersion } from "../common/config/version";
|
|
import { fireEvent } from "../common/dom/fire_event";
|
|
import { computeLocalize, LocalizeFunc } from "../common/translations/localize";
|
|
import {
|
|
computeRTLDirection,
|
|
setDirectionStyles,
|
|
} from "../common/util/compute_rtl";
|
|
import { debounce } from "../common/util/debounce";
|
|
import {
|
|
FirstWeekday,
|
|
getHassTranslations,
|
|
getHassTranslationsPre109,
|
|
NumberFormat,
|
|
saveTranslationPreferences,
|
|
TimeFormat,
|
|
DateFormat,
|
|
TranslationCategory,
|
|
TimeZone,
|
|
} from "../data/translation";
|
|
import { translationMetadata } from "../resources/translations-metadata";
|
|
import { Constructor, HomeAssistant } from "../types";
|
|
import {
|
|
getLocalLanguage,
|
|
getTranslation,
|
|
getUserLocale,
|
|
} from "../util/common-translation";
|
|
import { storeState } from "../util/ha-pref-storage";
|
|
import { HassBaseEl } from "./hass-base-mixin";
|
|
|
|
declare global {
|
|
// for fire event
|
|
interface HASSDomEvents {
|
|
"hass-language-select": {
|
|
language: string;
|
|
};
|
|
"hass-number-format-select": {
|
|
number_format: NumberFormat;
|
|
};
|
|
"hass-time-format-select": {
|
|
time_format: TimeFormat;
|
|
};
|
|
"hass-date-format-select": {
|
|
date_format: DateFormat;
|
|
};
|
|
"hass-time-zone-select": {
|
|
time_zone: TimeZone;
|
|
};
|
|
"hass-first-weekday-select": {
|
|
first_weekday: FirstWeekday;
|
|
};
|
|
"translations-updated": undefined;
|
|
}
|
|
}
|
|
|
|
interface LoadedTranslationCategory {
|
|
// individual integrations loaded for this category
|
|
integrations: string[];
|
|
// if integrations that have been set up for this category are loaded
|
|
setup: boolean;
|
|
// if
|
|
configFlow: boolean;
|
|
}
|
|
|
|
let updateResourcesIteration = 0;
|
|
|
|
/*
|
|
* superClass needs to contain `this.hass` and `this._updateHass`.
|
|
*/
|
|
|
|
export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
|
|
class extends superClass {
|
|
// eslint-disable-next-line: variable-name
|
|
private __coreProgress?: string;
|
|
|
|
private __loadedFragmentTranslations: Set<string> = new Set();
|
|
|
|
private __loadedTranslations: {
|
|
// track what things have been loaded
|
|
[category: string]: LoadedTranslationCategory;
|
|
} = {};
|
|
|
|
protected firstUpdated(changedProps) {
|
|
super.firstUpdated(changedProps);
|
|
this.addEventListener("hass-language-select", (e) => {
|
|
this._selectLanguage((e as CustomEvent).detail, true);
|
|
});
|
|
this.addEventListener("hass-number-format-select", (e) => {
|
|
this._selectNumberFormat((e as CustomEvent).detail, true);
|
|
});
|
|
this.addEventListener("hass-time-format-select", (e) => {
|
|
this._selectTimeFormat((e as CustomEvent).detail, true);
|
|
});
|
|
this.addEventListener("hass-date-format-select", (e) => {
|
|
this._selectDateFormat((e as CustomEvent).detail, true);
|
|
});
|
|
this.addEventListener("hass-time-zone-select", (e) => {
|
|
this._selectTimeZone((e as CustomEvent).detail, true);
|
|
});
|
|
this.addEventListener("hass-first-weekday-select", (e) => {
|
|
this._selectFirstWeekday((e as CustomEvent).detail, true);
|
|
});
|
|
this._loadCoreTranslations(getLocalLanguage());
|
|
}
|
|
|
|
protected updated(changedProps) {
|
|
super.updated(changedProps);
|
|
if (!changedProps.has("hass")) {
|
|
return;
|
|
}
|
|
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
|
if (
|
|
this.hass?.panels &&
|
|
(!oldHass || oldHass.panels !== this.hass.panels)
|
|
) {
|
|
this._loadFragmentTranslations(this.hass.language, this.hass.panelUrl);
|
|
}
|
|
}
|
|
|
|
protected hassConnected() {
|
|
super.hassConnected();
|
|
getUserLocale(this.hass!).then((locale) => {
|
|
if (locale?.language && this.hass!.language !== locale.language) {
|
|
// We just got language from backend, no need to save back
|
|
this._selectLanguage(locale.language, false);
|
|
}
|
|
if (
|
|
locale?.number_format &&
|
|
this.hass!.locale.number_format !== locale.number_format
|
|
) {
|
|
// We just got number_format from backend, no need to save back
|
|
this._selectNumberFormat(locale.number_format, false);
|
|
}
|
|
if (
|
|
locale?.time_format &&
|
|
this.hass!.locale.time_format !== locale.time_format
|
|
) {
|
|
// We just got time_format from backend, no need to save back
|
|
this._selectTimeFormat(locale.time_format, false);
|
|
}
|
|
if (
|
|
locale?.date_format &&
|
|
this.hass!.locale.date_format !== locale.date_format
|
|
) {
|
|
// We just got date_format from backend, no need to save back
|
|
this._selectDateFormat(locale.date_format, false);
|
|
}
|
|
if (
|
|
locale?.time_zone &&
|
|
this.hass!.locale.time_zone !== locale.time_zone
|
|
) {
|
|
// We just got time_zone from backend, no need to save back
|
|
this._selectTimeZone(locale.time_zone, false);
|
|
}
|
|
if (
|
|
locale?.first_weekday &&
|
|
this.hass!.locale.first_weekday !== locale.first_weekday
|
|
) {
|
|
// We just got first_weekday from backend, no need to save back
|
|
this._selectFirstWeekday(locale.first_weekday, false);
|
|
}
|
|
});
|
|
|
|
this.hass!.connection.subscribeEvents(
|
|
debounce(() => {
|
|
this._refetchCachedHassTranslations(false, false);
|
|
}, 500),
|
|
"component_loaded"
|
|
);
|
|
this._applyTranslations(this.hass!);
|
|
}
|
|
|
|
protected hassReconnected() {
|
|
super.hassReconnected();
|
|
this._refetchCachedHassTranslations(true, false);
|
|
this._applyTranslations(this.hass!);
|
|
}
|
|
|
|
protected panelUrlChanged(newPanelUrl: string) {
|
|
super.panelUrlChanged(newPanelUrl);
|
|
// this may be triggered before hassConnected
|
|
this._loadFragmentTranslations(
|
|
this.hass ? this.hass.language : getLocalLanguage(),
|
|
newPanelUrl
|
|
);
|
|
}
|
|
|
|
private _selectNumberFormat(
|
|
number_format: NumberFormat,
|
|
saveToBackend: boolean
|
|
) {
|
|
this._updateHass({
|
|
locale: { ...this.hass!.locale, number_format: number_format },
|
|
});
|
|
if (saveToBackend) {
|
|
saveTranslationPreferences(this.hass!, this.hass!.locale);
|
|
}
|
|
}
|
|
|
|
private _selectTimeFormat(time_format: TimeFormat, saveToBackend: boolean) {
|
|
this._updateHass({
|
|
locale: { ...this.hass!.locale, time_format: time_format },
|
|
});
|
|
if (saveToBackend) {
|
|
saveTranslationPreferences(this.hass!, this.hass!.locale);
|
|
}
|
|
}
|
|
|
|
private _selectDateFormat(date_format: DateFormat, saveToBackend: boolean) {
|
|
this._updateHass({
|
|
locale: {
|
|
...this.hass!.locale,
|
|
date_format: date_format,
|
|
},
|
|
});
|
|
if (saveToBackend) {
|
|
saveTranslationPreferences(this.hass!, this.hass!.locale);
|
|
}
|
|
}
|
|
|
|
private _selectTimeZone(time_zone: TimeZone, saveToBackend: boolean) {
|
|
this._updateHass({
|
|
locale: { ...this.hass!.locale, time_zone },
|
|
});
|
|
if (saveToBackend) {
|
|
saveTranslationPreferences(this.hass!, this.hass!.locale);
|
|
}
|
|
}
|
|
|
|
private _selectFirstWeekday(
|
|
first_weekday: FirstWeekday,
|
|
saveToBackend: boolean
|
|
) {
|
|
this._updateHass({
|
|
locale: { ...this.hass!.locale, first_weekday: first_weekday },
|
|
});
|
|
if (saveToBackend) {
|
|
saveTranslationPreferences(this.hass!, this.hass!.locale);
|
|
}
|
|
}
|
|
|
|
private _selectLanguage(language: string, saveToBackend: boolean) {
|
|
if (!this.hass) {
|
|
// should not happen, do it to avoid use this.hass!
|
|
return;
|
|
}
|
|
|
|
// update selectedLanguage so that it can be saved to local storage
|
|
this._updateHass({
|
|
locale: { ...this.hass!.locale, language: language },
|
|
language: language,
|
|
selectedLanguage: language,
|
|
});
|
|
storeState(this.hass);
|
|
if (saveToBackend) {
|
|
saveTranslationPreferences(this.hass, this.hass.locale);
|
|
}
|
|
this._applyTranslations(this.hass);
|
|
this._refetchCachedHassTranslations(true, true);
|
|
}
|
|
|
|
private _applyTranslations(hass: HomeAssistant) {
|
|
document.querySelector("html")!.setAttribute("lang", hass.language);
|
|
this._applyDirection(hass);
|
|
this._loadCoreTranslations(hass.language);
|
|
this.__loadedFragmentTranslations = new Set();
|
|
this._loadFragmentTranslations(hass.language, hass.panelUrl);
|
|
}
|
|
|
|
private _applyDirection(hass: HomeAssistant) {
|
|
const direction = computeRTLDirection(hass);
|
|
setDirectionStyles(direction, this);
|
|
}
|
|
|
|
/**
|
|
* Load translations from the backend
|
|
* @param language language to fetch
|
|
* @param category category to fetch
|
|
* @param integration optional, if having to fetch for specific integration
|
|
* @param configFlow optional, if having to fetch for all integrations with a config flow
|
|
* @param force optional, load even if already cached
|
|
*/
|
|
private async _loadHassTranslations(
|
|
language: string,
|
|
category: Parameters<typeof getHassTranslations>[2],
|
|
integration?: Parameters<typeof getHassTranslations>[3],
|
|
configFlow?: Parameters<typeof getHassTranslations>[4],
|
|
force = false
|
|
): Promise<LocalizeFunc> {
|
|
if (
|
|
__BACKWARDS_COMPAT__ &&
|
|
!atLeastVersion(this.hass!.connection.haVersion, 0, 109)
|
|
) {
|
|
if (category !== "state") {
|
|
return this.hass!.localize;
|
|
}
|
|
const resources = await getHassTranslationsPre109(this.hass!, language);
|
|
|
|
// Ignore the repsonse if user switched languages before we got response
|
|
if (this.hass!.language !== language) {
|
|
return this.hass!.localize;
|
|
}
|
|
|
|
return this._updateResources(language, resources);
|
|
}
|
|
|
|
let alreadyLoaded: LoadedTranslationCategory;
|
|
|
|
if (category in this.__loadedTranslations) {
|
|
alreadyLoaded = this.__loadedTranslations[category];
|
|
} else {
|
|
alreadyLoaded = this.__loadedTranslations[category] = {
|
|
integrations: [],
|
|
setup: false,
|
|
configFlow: false,
|
|
};
|
|
}
|
|
|
|
let integrationsToLoad: string[] = [];
|
|
|
|
// Check if already loaded
|
|
if (!force) {
|
|
if (integration && Array.isArray(integration)) {
|
|
integrationsToLoad = integration.filter(
|
|
(i) => !alreadyLoaded.integrations.includes(i)
|
|
);
|
|
if (!integrationsToLoad.length) {
|
|
return this.hass!.localize;
|
|
}
|
|
} else if (integration) {
|
|
if (alreadyLoaded.integrations.includes(integration)) {
|
|
return this.hass!.localize;
|
|
}
|
|
integrationsToLoad = [integration];
|
|
} else if (
|
|
configFlow ? alreadyLoaded.configFlow : alreadyLoaded.setup
|
|
) {
|
|
return this.hass!.localize;
|
|
}
|
|
}
|
|
|
|
// Add to cache
|
|
if (integrationsToLoad.length) {
|
|
alreadyLoaded.integrations.push(...integrationsToLoad);
|
|
} else {
|
|
alreadyLoaded.setup = true;
|
|
if (configFlow) {
|
|
alreadyLoaded.configFlow = true;
|
|
}
|
|
}
|
|
|
|
const resources = await getHassTranslations(
|
|
this.hass!,
|
|
language,
|
|
category,
|
|
integrationsToLoad.length ? integrationsToLoad : undefined,
|
|
configFlow
|
|
);
|
|
|
|
// Ignore the repsonse if user switched languages before we got response
|
|
if (this.hass!.language !== language) {
|
|
return this.hass!.localize;
|
|
}
|
|
|
|
return this._updateResources(language, resources);
|
|
}
|
|
|
|
private async _loadFragmentTranslations(
|
|
language: string,
|
|
panelUrl: string
|
|
) {
|
|
if (!panelUrl) {
|
|
return undefined;
|
|
}
|
|
|
|
const panelComponent = this.hass?.panels?.[panelUrl]?.component_name;
|
|
|
|
// If it's the first call we don't have panel info yet to check the component.
|
|
const fragment = translationMetadata.fragments.includes(
|
|
panelComponent || panelUrl
|
|
)
|
|
? panelComponent || panelUrl
|
|
: undefined;
|
|
|
|
if (!fragment) {
|
|
return undefined;
|
|
}
|
|
|
|
if (this.__loadedFragmentTranslations.has(fragment)) {
|
|
return this.hass!.localize;
|
|
}
|
|
this.__loadedFragmentTranslations.add(fragment);
|
|
const result = await getTranslation(fragment, language);
|
|
return this._updateResources(language, result.data);
|
|
}
|
|
|
|
private async _loadCoreTranslations(language: string) {
|
|
// Check if already in progress
|
|
// Necessary as we call this in firstUpdated and hassConnected
|
|
if (this.__coreProgress === language) {
|
|
return;
|
|
}
|
|
this.__coreProgress = language;
|
|
try {
|
|
const result = await getTranslation(null, language);
|
|
await this._updateResources(language, result.data);
|
|
} finally {
|
|
this.__coreProgress = undefined;
|
|
}
|
|
}
|
|
|
|
private async _updateResources(
|
|
language: string,
|
|
data: any
|
|
): Promise<LocalizeFunc> {
|
|
updateResourcesIteration++;
|
|
const i = updateResourcesIteration;
|
|
|
|
// Update the language in hass, and update the resources with the newly
|
|
// loaded resources. This merges the new data on top of the old data for
|
|
// this language, so that the full translation set can be loaded across
|
|
// multiple fragments.
|
|
//
|
|
// Beware of a subtle race condition: it is possible to get here twice
|
|
// before this.hass is even created. In this case our base state comes
|
|
// from this._pendingHass instead. Otherwise the first set of strings is
|
|
// overwritten when we call _updateHass the second time!
|
|
|
|
// Allow hass to be updated
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
|
|
if (language !== (this.hass ?? this._pendingHass).language) {
|
|
// the language was changed, abort
|
|
return (this.hass ?? this._pendingHass).localize!;
|
|
}
|
|
|
|
const resources = {
|
|
[language]: {
|
|
...(this.hass ?? this._pendingHass)?.resources?.[language],
|
|
...data,
|
|
},
|
|
};
|
|
|
|
// Update resources immediately, so when a new update comes in we don't miss values
|
|
this._updateHass({ resources });
|
|
|
|
const localize = await computeLocalize(this, language, resources);
|
|
|
|
if (
|
|
updateResourcesIteration !== i ||
|
|
language !== (this.hass ?? this._pendingHass).language
|
|
) {
|
|
// if a new iteration has started or the language changed, abort
|
|
return localize;
|
|
}
|
|
|
|
this._updateHass({
|
|
localize,
|
|
});
|
|
fireEvent(this, "translations-updated");
|
|
|
|
return localize;
|
|
}
|
|
|
|
private _refetchCachedHassTranslations(
|
|
includeConfigFlow: boolean,
|
|
clearIntegrations: boolean
|
|
) {
|
|
for (const [category, cache] of Object.entries(
|
|
this.__loadedTranslations
|
|
)) {
|
|
if (clearIntegrations) {
|
|
cache.integrations = [];
|
|
}
|
|
if (cache.setup) {
|
|
this._loadHassTranslations(
|
|
this.hass!.language,
|
|
category as TranslationCategory,
|
|
undefined,
|
|
includeConfigFlow && cache.configFlow,
|
|
true
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Load selected translation into memory immediately so it is ready when Polymer
|
|
// initializes.
|
|
getTranslation(null, getLocalLanguage());
|