From f809bf05508499d99ea1b57885a1d5cb374a7f42 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 8 Mar 2019 02:49:58 -0800 Subject: [PATCH] Save user language setting to backend (#2784) * Save user language setting to backend * Remove hass.selectedLanguage * Lint * Address code review comment * Refactoring translation * Code review * Add back selectedLanguage and local app storage * Move getTranslations to data/frontend.ts * Fix mock hass * Rewrite translations-mixin * revert no need changes * Final tweak --- src/data/frontend.ts | 38 ++++ src/fake_data/provide_hass.ts | 7 +- src/layouts/app/connection-mixin.js | 4 +- src/layouts/app/sidebar-mixin.ts | 2 +- .../app/{themes-mixin.js => themes-mixin.ts} | 26 ++- src/layouts/app/translations-mixin.ts | 87 ++++++---- src/mixins/lit-localize-lite-mixin.ts | 5 +- src/mixins/localize-lite-base-mixin.ts | 5 +- src/mixins/localize-lite-mixin.ts | 5 +- src/types.ts | 8 +- ...{ha-pref-storage.js => ha-pref-storage.ts} | 18 +- src/util/hass-translation.ts | 162 +++++++++++------- 12 files changed, 245 insertions(+), 122 deletions(-) create mode 100644 src/data/frontend.ts rename src/layouts/app/{themes-mixin.js => themes-mixin.ts} (50%) rename src/util/{ha-pref-storage.js => ha-pref-storage.ts} (63%) diff --git a/src/data/frontend.ts b/src/data/frontend.ts new file mode 100644 index 0000000000..bc8d512614 --- /dev/null +++ b/src/data/frontend.ts @@ -0,0 +1,38 @@ +import { HomeAssistant } from "../types"; + +export interface FrontendUserData { + language: string; +} + +export const fetchFrontendUserData = async ( + hass: HomeAssistant, + key: string +): Promise => { + const result = await hass.callWS<{ value: FrontendUserData }>({ + type: "frontend/get_user_data", + key, + }); + return result.value; +}; + +export const saveFrontendUserData = async ( + hass: HomeAssistant, + key: string, + value: FrontendUserData +): Promise => + hass.callWS({ + type: "frontend/set_user_data", + key, + value, + }); + +export const getHassTranslations = async ( + hass: HomeAssistant, + language: string +): Promise<{}> => { + const result = await hass.callWS<{ resources: {} }>({ + type: "frontend/get_translations", + language, + }); + return result.resources; +}; diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index 2e7ce665bd..b8f9edd169 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -7,7 +7,7 @@ import { demoPanels } from "./demo_panels"; import { getEntity, Entity } from "./entity"; import { HomeAssistant } from "../types"; import { HassEntities } from "home-assistant-js-websocket"; -import { getActiveTranslation } from "../util/hass-translation"; +import { getLocalLanguage } from "../util/hass-translation"; import { translationMetadata } from "../resources/translations-metadata"; const ensureArray = (val: T | T[]): T[] => @@ -87,6 +87,8 @@ export const provideHass = ( ); }); + const localLanguage = getLocalLanguage(); + const hassObj: MockHomeAssistant = { // Home Assistant properties auth: {} as any, @@ -134,7 +136,8 @@ export const provideHass = ( }, panelUrl: "lovelace", - language: getActiveTranslation(), + language: localLanguage, + selectedLanguage: localLanguage, resources: null as any, localize: () => "", diff --git a/src/layouts/app/connection-mixin.js b/src/layouts/app/connection-mixin.js index 3093ce1619..6eb72d47b4 100644 --- a/src/layouts/app/connection-mixin.js +++ b/src/layouts/app/connection-mixin.js @@ -12,7 +12,7 @@ import LocalizeMixin from "../../mixins/localize-mixin"; import EventsMixin from "../../mixins/events-mixin"; import { getState } from "../../util/ha-pref-storage"; -import { getActiveTranslation } from "../../util/hass-translation"; +import { getLocalLanguage } from "../../util/hass-translation"; import { fetchWithAuth } from "../../util/fetch-with-auth"; import hassCallApi from "../../util/hass-call-api"; import { subscribePanels } from "../../data/ws-panels"; @@ -49,7 +49,7 @@ export default (superClass) => user: null, panelUrl: this._panelUrl, - language: getActiveTranslation(), + language: getLocalLanguage(), // If resources are already loaded, don't discard them resources: (this.hass && this.hass.resources) || null, localize: () => "", diff --git a/src/layouts/app/sidebar-mixin.ts b/src/layouts/app/sidebar-mixin.ts index 3d0f4f5946..b38efb3792 100644 --- a/src/layouts/app/sidebar-mixin.ts +++ b/src/layouts/app/sidebar-mixin.ts @@ -24,7 +24,7 @@ export default (superClass: Constructor) => super.firstUpdated(changedProps); this.addEventListener("hass-dock-sidebar", (ev) => { this._updateHass({ dockedSidebar: ev.detail.dock }); - storeState(this.hass); + storeState(this.hass!); }); } }; diff --git a/src/layouts/app/themes-mixin.js b/src/layouts/app/themes-mixin.ts similarity index 50% rename from src/layouts/app/themes-mixin.js rename to src/layouts/app/themes-mixin.ts index 3e4cf513c8..af6685ba97 100644 --- a/src/layouts/app/themes-mixin.js +++ b/src/layouts/app/themes-mixin.ts @@ -1,32 +1,42 @@ import applyThemesOnElement from "../../common/dom/apply_themes_on_element"; import { storeState } from "../../util/ha-pref-storage"; import { subscribeThemes } from "../../data/ws-themes"; +import { Constructor, LitElement } from "lit-element"; +import { HassBaseEl } from "./hass-base-mixin"; +import { HASSDomEvent } from "../../common/dom/fire_event"; -export default (superClass) => +declare global { + // for add event listener + interface HTMLElementEventMap { + settheme: HASSDomEvent; + } +} + +export default (superClass: Constructor) => class extends superClass { - firstUpdated(changedProps) { + protected firstUpdated(changedProps) { super.firstUpdated(changedProps); this.addEventListener("settheme", (ev) => { this._updateHass({ selectedTheme: ev.detail }); this._applyTheme(); - storeState(this.hass); + storeState(this.hass!); }); } - hassConnected() { + protected hassConnected() { super.hassConnected(); - subscribeThemes(this.hass.connection, (themes) => { + subscribeThemes(this.hass!.connection, (themes) => { this._updateHass({ themes }); this._applyTheme(); }); } - _applyTheme() { + private _applyTheme() { applyThemesOnElement( document.documentElement, - this.hass.themes, - this.hass.selectedTheme, + this.hass!.themes, + this.hass!.selectedTheme, true ); } diff --git a/src/layouts/app/translations-mixin.ts b/src/layouts/app/translations-mixin.ts index cac66a1728..c3cb127593 100644 --- a/src/layouts/app/translations-mixin.ts +++ b/src/layouts/app/translations-mixin.ts @@ -1,11 +1,16 @@ import { translationMetadata } from "../../resources/translations-metadata"; -import { getTranslation } from "../../util/hass-translation"; -import { storeState } from "../../util/ha-pref-storage"; +import { + getTranslation, + getLocalLanguage, + getUserLanguage, +} from "../../util/hass-translation"; import { Constructor, LitElement } from "lit-element"; import { HassBaseEl } from "./hass-base-mixin"; import { computeLocalize } from "../../common/translations/localize"; import { computeRTL } from "../../common/util/compute_rtl"; import { HomeAssistant } from "../../types"; +import { saveFrontendUserData, getHassTranslations } from "../../data/frontend"; +import { storeState } from "../../util/ha-pref-storage"; /* * superClass needs to contain `this.hass` and `this._updateHass`. @@ -16,60 +21,86 @@ export default (superClass: Constructor) => protected firstUpdated(changedProps) { super.firstUpdated(changedProps); this.addEventListener("hass-language-select", (e) => - this._selectLanguage(e) + this._selectLanguage((e as CustomEvent).detail.language, true) ); - this._loadResources(); + this._loadCoreTranslations(getLocalLanguage()); } protected hassConnected() { super.hassConnected(); - this._loadBackendTranslations(); - this.style.direction = computeRTL(this.hass!) ? "rtl" : "ltr"; + getUserLanguage(this.hass!).then((language) => { + if (language && this.hass!.language !== language) { + // We just get language from backend, no need to save back + this._selectLanguage(language, false); + } + }); + this._applyTranslations(this.hass!); } protected hassReconnected() { super.hassReconnected(); - this._loadBackendTranslations(); + this._applyTranslations(this.hass!); } protected panelUrlChanged(newPanelUrl) { super.panelUrlChanged(newPanelUrl); - this._loadTranslationFragment(newPanelUrl); + // this may be triggered before hassConnected + this._loadFragmentTranslations( + this.hass ? this.hass.language : getLocalLanguage(), + newPanelUrl + ); } - private async _loadBackendTranslations() { - const hass = this.hass; - if (!hass || !hass.language) { + private _selectLanguage(language: string, saveToBackend: boolean) { + if (!this.hass) { + // should not happen, do it to avoid use this.hass! return; } - const language = hass.selectedLanguage || hass.language; + // update selectedLanguage so that it can be saved to local storage + this._updateHass({ language, selectedLanguage: language }); + storeState(this.hass); + if (saveToBackend) { + saveFrontendUserData(this.hass, "language", { language }); + } - const { resources } = await hass.callWS({ - type: "frontend/get_translations", - language, - }); + this._applyTranslations(this.hass); + } - // If we've switched selected languages just ignore this response - if ((hass.selectedLanguage || hass.language) !== language) { + private _applyTranslations(hass: HomeAssistant) { + this.style.direction = computeRTL(hass) ? "rtl" : "ltr"; + this._loadCoreTranslations(hass.language); + this._loadHassTranslations(hass.language); + this._loadFragmentTranslations(hass.language, hass.panelUrl); + } + + private async _loadHassTranslations(language: string) { + const resources = await getHassTranslations(this.hass!, language); + + // Ignore the repsonse if user switched languages before we got response + if (this.hass!.language !== language) { return; } this._updateResources(language, resources); } - private _loadTranslationFragment(panelUrl) { + private async _loadFragmentTranslations( + language: string, + panelUrl: string + ) { if (translationMetadata.fragments.includes(panelUrl)) { - this._loadResources(panelUrl); + const result = await getTranslation(panelUrl, language); + this._updateResources(result.language, result.data); } } - private async _loadResources(fragment?) { - const result = await getTranslation(fragment); + private async _loadCoreTranslations(language: string) { + const result = await getTranslation(null, language); this._updateResources(result.language, result.data); } - private _updateResources(language, data) { + private _updateResources(language: string, data: any) { // 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 @@ -88,14 +119,4 @@ export default (superClass: Constructor) => } this._updateHass(changes); } - - private _selectLanguage(event) { - const language: string = event.detail.language; - this._updateHass({ language, selectedLanguage: language }); - this.style.direction = computeRTL(this.hass!) ? "rtl" : "ltr"; - storeState(this.hass); - this._loadResources(); - this._loadBackendTranslations(); - this._loadTranslationFragment(this.hass!.panelUrl); - } }; diff --git a/src/mixins/lit-localize-lite-mixin.ts b/src/mixins/lit-localize-lite-mixin.ts index c80555d7b3..ba35d7649b 100644 --- a/src/mixins/lit-localize-lite-mixin.ts +++ b/src/mixins/lit-localize-lite-mixin.ts @@ -4,7 +4,7 @@ import { PropertyDeclarations, PropertyValues, } from "lit-element"; -import { getActiveTranslation } from "../util/hass-translation"; +import { getLocalLanguage } from "../util/hass-translation"; import { localizeLiteBaseMixin } from "./localize-lite-base-mixin"; import { computeLocalize, LocalizeFunc } from "../common/translations/localize"; @@ -35,7 +35,8 @@ export const litLocalizeLiteMixin = ( super(); // This will prevent undefined errors if called before connected to DOM. this.localize = empty; - this.language = getActiveTranslation(); + // Use browser language setup before login. + this.language = getLocalLanguage(); } public connectedCallback(): void { diff --git a/src/mixins/localize-lite-base-mixin.ts b/src/mixins/localize-lite-base-mixin.ts index e257eb4fef..91a81ef5b6 100644 --- a/src/mixins/localize-lite-base-mixin.ts +++ b/src/mixins/localize-lite-base-mixin.ts @@ -35,7 +35,10 @@ export const localizeLiteBaseMixin = (superClass) => } private async _updateResources() { - const { language, data } = await getTranslation(this.translationFragment); + const { language, data } = await getTranslation( + this.translationFragment, + this.language + ); this.resources = { [language]: data, }; diff --git a/src/mixins/localize-lite-mixin.ts b/src/mixins/localize-lite-mixin.ts index f406fab134..8fcf112972 100644 --- a/src/mixins/localize-lite-mixin.ts +++ b/src/mixins/localize-lite-mixin.ts @@ -2,7 +2,7 @@ * Lite mixin to add localization without depending on the Hass object. */ import { dedupingMixin } from "@polymer/polymer/lib/utils/mixin"; -import { getActiveTranslation } from "../util/hass-translation"; +import { getLocalLanguage } from "../util/hass-translation"; import { localizeLiteBaseMixin } from "./localize-lite-base-mixin"; import { computeLocalize } from "../common/translations/localize"; @@ -16,7 +16,8 @@ export const localizeLiteMixin = dedupingMixin( return { language: { type: String, - value: getActiveTranslation(), + // Use browser language setup before login. + value: getLocalLanguage(), }, resources: Object, // The fragment to load. diff --git a/src/types.ts b/src/types.ts index a2c3b29f9c..946f5e8137 100644 --- a/src/types.ts +++ b/src/types.ts @@ -117,8 +117,14 @@ export interface HomeAssistant { panelUrl: string; // i18n + // current effective language, in that order: + // - backend saved user selected lanugage + // - language in local appstorage + // - browser language + // - english (en) language: string; - selectedLanguage?: string; + // local stored language, keep that name for backward compability + selectedLanguage: string; resources: Resources; localize: LocalizeFunc; translationMetadata: { diff --git a/src/util/ha-pref-storage.js b/src/util/ha-pref-storage.ts similarity index 63% rename from src/util/ha-pref-storage.js rename to src/util/ha-pref-storage.ts index 4152b8cf5c..3a57320a2b 100644 --- a/src/util/ha-pref-storage.js +++ b/src/util/ha-pref-storage.ts @@ -1,11 +1,12 @@ +import { HomeAssistant } from "../types"; + const STORED_STATE = ["dockedSidebar", "selectedTheme", "selectedLanguage"]; const STORAGE = window.localStorage || {}; -export function storeState(hass) { +export function storeState(hass: HomeAssistant) { try { - for (var i = 0; i < STORED_STATE.length; i++) { - var key = STORED_STATE[i]; - var value = hass[key]; + for (const key of STORED_STATE) { + const value = hass[key]; STORAGE[key] = JSON.stringify(value === undefined ? null : value); } } catch (err) { @@ -14,10 +15,9 @@ export function storeState(hass) { } export function getState() { - var state = {}; + const state = {}; - for (var i = 0; i < STORED_STATE.length; i++) { - var key = STORED_STATE[i]; + for (const key of STORED_STATE) { if (key in STORAGE) { state[key] = JSON.parse(STORAGE[key]); } @@ -28,5 +28,7 @@ export function getState() { export function clearState() { // STORAGE is an object if localStorage not available. - if (STORAGE.clear) STORAGE.clear(); + if (STORAGE.clear) { + STORAGE.clear(); + } } diff --git a/src/util/hass-translation.ts b/src/util/hass-translation.ts index 289c8df2df..f2680e50d5 100644 --- a/src/util/hass-translation.ts +++ b/src/util/hass-translation.ts @@ -1,58 +1,89 @@ import { translationMetadata } from "../resources/translations-metadata"; +import { fetchFrontendUserData } from "../data/frontend"; +import { HomeAssistant } from "../types"; -export function getActiveTranslation() { - // Perform case-insenstive comparison since browser isn't required to - // report languages with specific cases. - const lookup = {}; - /* eslint-disable no-undef */ - Object.keys(translationMetadata.translations).forEach((tr) => { - lookup[tr.toLowerCase()] = tr; - }); +const STORAGE = window.localStorage || {}; - // Search for a matching translation from most specific to general - function languageGetTranslation(language) { - const lang = language.toLowerCase(); +// Chinese locales need map to Simplified or Traditional Chinese +const LOCALE_LOOKUP = { + "zh-cn": "zh-Hans", + "zh-sg": "zh-Hans", + "zh-my": "zh-Hans", + "zh-tw": "zh-Hant", + "zh-hk": "zh-Hant", + "zh-mo": "zh-Hant", + zh: "zh-Hant", // all other Chinese locales map to Traditional Chinese +}; - if (lookup[lang]) { - return lookup[lang]; - } - if (lang.split("-")[0] === "zh") { - return lang === "zh-cn" || lang === "zh-sg" ? "zh-Hans" : "zh-Hant"; - } - return null; +/** + * Search for a matching translation from most specific to general + */ +function findAvailableLanguage(language: string) { + // In most case, the language has the same format with our translation meta data + if (language in translationMetadata.translations) { + return language; } - let translation = null; - let selectedLanguage; - if (window.localStorage.selectedLanguage) { + // Perform case-insenstive comparison since browser isn't required to + // report languages with specific cases. + const langLower = language.toLowerCase(); + + if (langLower in LOCALE_LOOKUP) { + return LOCALE_LOOKUP[langLower]; + } + + for (const lang in Object.keys(translationMetadata.translations)) { + if (lang.toLowerCase() === langLower) { + return lang; + } + } +} + +/** + * Get user selected language from backend + */ +export async function getUserLanguage(hass: HomeAssistant) { + const { language } = await fetchFrontendUserData(hass, "language"); + if (language) { + const availableLanguage = findAvailableLanguage(language); + if (availableLanguage) { + return availableLanguage; + } + } + return null; +} + +/** + * Get browser specific language + */ +export function getLocalLanguage() { + let language = null; + if (STORAGE.selectedLanguage) { try { - selectedLanguage = JSON.parse(window.localStorage.selectedLanguage); + language = findAvailableLanguage(JSON.parse(STORAGE.selectedLanguage)); + if (language) { + return language; + } } catch (e) { // Ignore parsing error. } } - if (selectedLanguage) { - translation = languageGetTranslation(selectedLanguage); - if (translation) { - return translation; - } - } if (navigator.languages) { for (const locale of navigator.languages) { - translation = languageGetTranslation(locale); - if (translation) { - return translation; + language = findAvailableLanguage(locale); + if (language) { + return language; } } } - translation = languageGetTranslation(navigator.language); - if (translation) { - return translation; + language = findAvailableLanguage(navigator.language); + if (language) { + return language; } - if (navigator.language.includes("-")) { - translation = languageGetTranslation(navigator.language.split("-")[0]); - if (translation) { - return translation; + if (navigator.language && navigator.language.includes("-")) { + language = findAvailableLanguage(navigator.language.split("-")[0]); + if (language) { + return language; } } @@ -64,43 +95,50 @@ export function getActiveTranslation() { // when DOM is created in Polymer. Even a cache lookup creates noticeable latency. const translations = {}; -export function getTranslation(fragment?, translationInput?) { - const translation = translationInput || getActiveTranslation(); - const metadata = translationMetadata.translations[translation]; +async function fetchTranslation(fingerprint) { + const response = await fetch(`/static/translations/${fingerprint}`, { + credentials: "same-origin", + }); + if (!response.ok) { + throw new Error( + `Fail to fetch translation ${fingerprint}: HTTP response status is ${ + response.status + }` + ); + } + return response.json(); +} + +export async function getTranslation( + fragment: string | null, + language: string +) { + const metadata = translationMetadata.translations[language]; if (!metadata) { - if (translationInput !== "en") { + if (language !== "en") { return getTranslation(fragment, "en"); } - return Promise.reject(new Error("Language en not found in metadata")); + throw new Error("Language en is not found in metadata"); } - const translationFingerprint = - metadata.fingerprints[ - fragment ? `${fragment}/${translation}` : translation - ]; + const fingerprint = + metadata.fingerprints[fragment ? `${fragment}/${language}` : language]; - // Create a promise to fetch translation from the server - if (!translations[translationFingerprint]) { - translations[translationFingerprint] = fetch( - `/static/translations/${translationFingerprint}`, - { credentials: "same-origin" } - ) - .then((response) => response.json()) - .then((data) => ({ - language: translation, - data, - })) + // Fetch translation from the server + if (!translations[fingerprint]) { + translations[fingerprint] = fetchTranslation(fingerprint) + .then((data) => ({ language, data })) .catch((error) => { - delete translations[translationFingerprint]; - if (translationInput !== "en") { + delete translations[fingerprint]; + if (language !== "en") { // Couldn't load selected translation. Try a fall back to en before failing. return getTranslation(fragment, "en"); } return Promise.reject(error); }); } - return translations[translationFingerprint]; + return translations[fingerprint]; } // Load selected translation into memory immediately so it is ready when Polymer // initializes. -getTranslation(); +getTranslation(null, getLocalLanguage());