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
This commit is contained in:
Jason Hu 2019-03-08 02:49:58 -08:00 committed by GitHub
parent ed9dff99d3
commit f809bf0550
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 245 additions and 122 deletions

38
src/data/frontend.ts Normal file
View File

@ -0,0 +1,38 @@
import { HomeAssistant } from "../types";
export interface FrontendUserData {
language: string;
}
export const fetchFrontendUserData = async (
hass: HomeAssistant,
key: string
): Promise<FrontendUserData> => {
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<void> =>
hass.callWS<void>({
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;
};

View File

@ -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 = <T>(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: () => "",

View File

@ -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: () => "",

View File

@ -24,7 +24,7 @@ export default (superClass: Constructor<LitElement & HassBaseEl>) =>
super.firstUpdated(changedProps);
this.addEventListener("hass-dock-sidebar", (ev) => {
this._updateHass({ dockedSidebar: ev.detail.dock });
storeState(this.hass);
storeState(this.hass!);
});
}
};

View File

@ -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<string>;
}
}
export default (superClass: Constructor<LitElement & HassBaseEl>) =>
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
);
}

View File

@ -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<LitElement & HassBaseEl>) =>
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<LitElement & HassBaseEl>) =>
}
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);
}
};

View File

@ -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 = <T extends LitElement>(
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 {

View File

@ -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,
};

View File

@ -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.

View File

@ -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: {

View File

@ -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();
}
}

View File

@ -1,58 +1,89 @@
import { translationMetadata } from "../resources/translations-metadata";
import { fetchFrontendUserData } from "../data/frontend";
import { HomeAssistant } from "../types";
const STORAGE = window.localStorage || {};
// 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
};
/**
* 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;
}
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 langLower = language.toLowerCase();
// Search for a matching translation from most specific to general
function languageGetTranslation(language) {
const lang = language.toLowerCase();
if (lookup[lang]) {
return lookup[lang];
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;
}
if (lang.split("-")[0] === "zh") {
return lang === "zh-cn" || lang === "zh-sg" ? "zh-Hans" : "zh-Hant";
}
return null;
}
let translation = null;
let selectedLanguage;
if (window.localStorage.selectedLanguage) {
/**
* 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());