From a498e3b33091630fe651309dbfb754b7c4b85ede Mon Sep 17 00:00:00 2001 From: "mathias.chouet" <mathias.chouet@irstea.fr> Date: Thu, 7 Mar 2019 16:06:08 +0100 Subject: [PATCH] Gestion des langues #114 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simplification des codes de langues chaque module ne charge que son fichier de traduction les fichiers de traductions des modules sont gardés en cache on peut instancier un module dans une langue non gérée (mécanisme de langue de secours) --- README.md | 4 +- .../app-setup/app-setup.component.html | 4 +- .../app-setup/app-setup.component.ts | 10 +- .../calculator.component.ts | 14 +- src/app/formulaire/formulaire-element.ts | 6 +- .../services/app-setup/app-setup.service.ts | 10 +- .../services/formulaire/formulaire.service.ts | 85 +++++--- .../internationalisation.service.ts | 181 ++++++------------ src/locale/messages.en.json | 1 - src/locale/messages.fr.json | 1 - 10 files changed, 145 insertions(+), 171 deletions(-) diff --git a/README.md b/README.md index 199a1aa20..38b3b040b 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,10 @@ Custom Material SVG Icons will only show up when the application is deployed on On peut soit composer la classe concrète directement avec ces classes, soient dériver ces dernières et composer avec. -* _src/locale/error_messages.<langue>.json_ : +* _src/locale/messages.<langue>.json_ : Ajouter un champ pour le titre du module de calcul. Par exemple : _"INFO_MACALC_TITRE": "Ma calculette"_ * Dans la méthode _FormulaireService.getConfigPathPrefix()_, compléter le _switch_ pour fournir le préfixe des fichiers de configuration/internationalisation. + +* Dans la méthode _FormulaireService.newFormulaire()_, compléter le _switch_ pour fournir la classe à instancier. diff --git a/src/app/components/app-setup/app-setup.component.html b/src/app/components/app-setup/app-setup.component.html index 86c85ad02..448b38977 100644 --- a/src/app/components/app-setup/app-setup.component.html +++ b/src/app/components/app-setup/app-setup.component.html @@ -78,8 +78,8 @@ <!-- langue --> <mat-form-field> <mat-select placeholder="Language" [(value)]="currentLanguageCode" data-testid="language-select"> - <mat-option *ngFor="let l of availableLanguages" [value]="l.code"> - {{ l.label }} + <mat-option *ngFor="let l of availableLanguages | keyvalue" [value]="l.key"> + {{ l.value }} </mat-option> </mat-select> </mat-form-field> diff --git a/src/app/components/app-setup/app-setup.component.ts b/src/app/components/app-setup/app-setup.component.ts index a2ada3146..0b373a0d2 100644 --- a/src/app/components/app-setup/app-setup.component.ts +++ b/src/app/components/app-setup/app-setup.component.ts @@ -3,7 +3,7 @@ import { Component, OnInit } from "@angular/core"; import { ParamDomainValue, Observer } from "jalhyd"; import { ApplicationSetupService } from "../../services/app-setup/app-setup.service"; -import { I18nService, LanguageCode } from "../../services/internationalisation/internationalisation.service"; +import { I18nService } from "../../services/internationalisation/internationalisation.service"; import { NgBaseParam } from "../base-param-input/base-param-input.component"; import { BaseComponent } from "../base/base.component"; import { ErrorStateMatcher, MatSnackBar } from "@angular/material"; @@ -42,14 +42,14 @@ export class ApplicationSetupComponent extends BaseComponent implements Observer public get currentLanguageCode() { if (this.intlService.currentLanguage) { - return this.intlService.currentLanguage.code; + return this.intlService.currentLanguage; } } - public set currentLanguageCode(lc: LanguageCode) { - this.intlService.setLocale(lc); + public set currentLanguageCode(lc: string) { + this.intlService.setLanguage(lc); // keep language in sync in app-wide parameters service - this.appSetupService.language = this.intlService.currentLanguage.tag; + this.appSetupService.language = this.intlService.currentLanguage; } public get uitextTitle(): string { diff --git a/src/app/components/generic-calculator/calculator.component.ts b/src/app/components/generic-calculator/calculator.component.ts index 54141da0a..af807c109 100644 --- a/src/app/components/generic-calculator/calculator.component.ts +++ b/src/app/components/generic-calculator/calculator.component.ts @@ -155,10 +155,11 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit, return this.intlService.localizeText("INFO_CALCULATOR_RESULTS_TITLE"); } + /** + * Triggered at calculator instanciation + */ ngOnInit() { - this.intlService.addObserver(this); this.formulaireService.addObserver(this); - this.formulaireService.updateLocalisation(); this.subscribeRouter(); } @@ -192,7 +193,6 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit, * gestion des événements clic sur les radios */ private onRadioClick(info: any) { - // console.log("on radio click"); this.updateLinkedParameters(); this._pendingRadioClick = true; this._pendingRadioClickInfo = info; @@ -200,7 +200,6 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit, public ngAfterViewChecked() { if (this._pendingRadioClick) { - // console.log("ng after view checked"); this._pendingRadioClick = false; this._formulaire.onRadioClick(this._pendingRadioClickInfo); this._pendingRadioClickInfo = undefined; @@ -271,16 +270,15 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit, // interface Observer update(sender: any, data: any): void { - if (sender instanceof I18nService) { - // update display if language changed - this.formulaireService.updateLocalisation(); - } else if (sender instanceof FormulaireService) { + if (sender instanceof FormulaireService) { switch (data["action"]) { case "currentFormChanged": const uid: string = data["formId"]; this.setForm(this.formulaireService.getFormulaireFromId(uid)); this.resultsComponent.formulaire = this._formulaire; this._calculatorNameComponent.model = this._formulaire; + // reload localisation in all cases + this.formulaireService.loadUpdateFormulaireLocalisation(this._formulaire); break; } } else if (sender instanceof FormulaireDefinition) { diff --git a/src/app/formulaire/formulaire-element.ts b/src/app/formulaire/formulaire-element.ts index fd3f87b74..c186feea6 100644 --- a/src/app/formulaire/formulaire-element.ts +++ b/src/app/formulaire/formulaire-element.ts @@ -180,7 +180,11 @@ export abstract class FormulaireElement extends FormulaireNode { } } - + /** + * Updates localisation for this element: first the label then all the element's children + * @param loc calculator-specific localised messages map + * @param key Element label key + */ public updateLocalisation(loc: StringMap, key?: string) { if (!key) { key = this._confId; diff --git a/src/app/services/app-setup/app-setup.service.ts b/src/app/services/app-setup/app-setup.service.ts index afde5826c..d05c7a174 100644 --- a/src/app/services/app-setup/app-setup.service.ts +++ b/src/app/services/app-setup/app-setup.service.ts @@ -12,6 +12,9 @@ export class ApplicationSetupService extends Observable { private CONFIG_FILE_PATH = "app/config.json"; private LOCAL_STORAGE_PREFIX = "nghyd_"; + /** ultimate fallback language (read from config) */ + private _fallbackLanguage = "fr"; + // default builtin values public displayPrecision = 0.001; public computePrecision = 0.0001; @@ -38,9 +41,10 @@ export class ApplicationSetupService extends Observable { // load JSON config this.readValuesFromConfig().then((data) => { const configLanguage = this.language; + this._fallbackLanguage = configLanguage; // guess browser's language - this.language = navigator.language.substring(0, 2); // @TODO clodo trick, check validity + this.language = navigator.language; const browserLanguage = this.language; // load saved preferences @@ -62,6 +66,10 @@ export class ApplicationSetupService extends Observable { return -Math.log10(this.displayPrecision); } + public get fallbackLanguage() { + return this._fallbackLanguage; + } + /** * Save configuration values into local storage */ diff --git a/src/app/services/formulaire/formulaire.service.ts b/src/app/services/formulaire/formulaire.service.ts index 327a22c08..066e82f7a 100644 --- a/src/app/services/formulaire/formulaire.service.ts +++ b/src/app/services/formulaire/formulaire.service.ts @@ -21,17 +21,23 @@ import { FormulaireRegimeUniforme } from "../../formulaire/definition/concrete/f import { FormulaireParallelStructure } from "../../formulaire/definition/concrete/form-parallel-structures"; import { NgParameter } from "../../formulaire/ngparam"; import { FieldsetContainer } from "../..//formulaire/fieldset-container"; +import { ApplicationSetupService } from "../app-setup/app-setup.service"; @Injectable() export class FormulaireService extends Observable { + /** list of known forms */ private _formulaires: FormulaireDefinition[]; private _currentFormId: string = null; + /** to avoid loading language files multiple times */ + private languageCache = {}; + constructor( private i18nService: I18nService, - private httpService: HttpService) { - + private appSetupService: ApplicationSetupService, + private httpService: HttpService + ) { super(); this._formulaires = []; } @@ -48,17 +54,50 @@ export class FormulaireService extends Observable { return this._formulaires; } + /** + * Loads the localisation file dedicated to calculator type ct; tries the current + * language then the fallback language + */ private loadLocalisation(calc: CalculatorType): Promise<any> { - const f: string = this.getConfigPathPrefix(calc) + this._intlService.currentLanguage.tag + ".json"; - const prom = this._httpService.httpGetRequestPromise(f); - - return prom.then((j) => { - return j as StringMap; + const lang = this._intlService.currentLanguage; + return this.loadLocalisationForLang(calc, lang).then((localisation) => { + return localisation as StringMap; + }).catch((e) => { + console.error(e); + // try default lang (the one in the config file) ? + const fallbackLang = this.appSetupService.fallbackLanguage; + if (lang !== fallbackLang) { + console.error(`trying fallback language: ${fallbackLang}`); + return this.loadLocalisationForLang(calc, fallbackLang); + } }); } /** - * met à jour la langue du formulaire + * Loads the localisation file dedicated to calculator type ct for language lang; + * keeps it in cache for subsequent calls () + */ + private loadLocalisationForLang(calc: CalculatorType, lang: string): Promise<any> { + const ct = String(calc); + // already in cache ? + if (Object.keys(this.languageCache).includes(ct) && Object.keys(this.languageCache[calc]).includes(lang)) { + return new Promise((resolve) => { + resolve(this.languageCache[ct][lang]); + }); + } else { + const f: string = this.getConfigPathPrefix(calc) + lang + ".json"; + return this._httpService.httpGetRequestPromise(f).then((localisation) => { + this.languageCache[ct] = this.languageCache[ct] || {}; + this.languageCache[ct][lang] = localisation; + return localisation as StringMap; + }).catch((e) => { + throw new Error(`LOCALISATION_FILE_NOT_FOUND "${f}"`); + }); + } + } + + /** + * Met à jour la langue du formulaire * @param formId id unique du formulaire * @param localisation ensemble id-message traduit */ @@ -74,26 +113,11 @@ export class FormulaireService extends Observable { /** * charge la localisation et met à jour la langue du formulaire */ - private loadUpdateFormulaireLocalisation(f: FormulaireDefinition): Promise<FormulaireDefinition> { - return this.loadLocalisation(f.calculatorType) - .then(localisation => { - this.updateFormulaireLocalisation(f.uid, localisation); - return f; - }); - } - - public updateLocalisation() { - for (const c of EnumEx.getValues(CalculatorType)) { - const prom: Promise<StringMap> = this.loadLocalisation(c); - prom.then(loc => { - for (const f of this._formulaires) { - if (f.calculatorType === c) { - this.updateFormulaireLocalisation(f.uid, loc); - } - } - } - ); - } + public loadUpdateFormulaireLocalisation(f: FormulaireDefinition): Promise<FormulaireDefinition> { + return this.loadLocalisation(f.calculatorType).then(localisation => { + this.updateFormulaireLocalisation(f.uid, localisation); + return f; + }); } /** @@ -238,7 +262,8 @@ export class FormulaireService extends Observable { } } } - return this.loadUpdateFormulaireLocalisation(f); + return f; + }).then(fi => { fi.applyDependencies(); this.notifyObservers({ @@ -329,7 +354,7 @@ export class FormulaireService extends Observable { public getConfigPathPrefix(ct: CalculatorType): string { if (ct === undefined) { - throw new Error("FormulaireService.getConfigPathPrefix() : invalid undefined CalculatorType"); + throw new Error("FormulaireService.getConfigPathPrefix() : CalculatorType is undefined"); } switch (ct) { diff --git a/src/app/services/internationalisation/internationalisation.service.ts b/src/app/services/internationalisation/internationalisation.service.ts index bde70b144..9700324ca 100644 --- a/src/app/services/internationalisation/internationalisation.service.ts +++ b/src/app/services/internationalisation/internationalisation.service.ts @@ -6,138 +6,78 @@ import { StringMap } from "../../stringmap"; import { ApplicationSetupService } from "../app-setup/app-setup.service"; import { HttpService } from "../http/http.service"; -/* - language tag : fr-FR - primary subcode : fr - optional subcode : FR - */ -export enum LanguageCode { - FRENCH, - - ENGLISH, -} - - -export class Language { - private _code: LanguageCode; - private _tag: string; - private _label: string; - - constructor(c: LanguageCode, t: string, l: string) { - this._code = c; - this._tag = t; - this._label = l; - } - - get code(): LanguageCode { - return this._code; - } - - get tag(): string { - return this._tag; - } - - get label(): string { - return this._label; - } -} - @Injectable() export class I18nService extends Observable implements Observer { - private _currLang: Language; + /** current ISO 639-1 language code */ + private _currentLanguage: string; + + /** current available languages as ISO 639-1 codes => native name */ + private _availableLanguages: any; + + /** localized messages */ private _Messages: StringMap; - private _languages: Language[]; constructor( private applicationSetupService: ApplicationSetupService, - private httpService: HttpService) { - + private httpService: HttpService + ) { super(); - this._languages = []; - this._languages.push(new Language(LanguageCode.FRENCH, "fr", "Français")); - this._languages.push(new Language(LanguageCode.ENGLISH, "en", "English")); + this._availableLanguages = { + fr: "Français", + en: "English" + }; // add language preferences observer this.applicationSetupService.addObserver(this); } public get languages() { - return this._languages; + return this._availableLanguages; } public get currentLanguage() { - return this._currLang; + return this._currentLanguage; } public get currentMap() { return this._Messages; } - private getLanguageFromCode(lc: LanguageCode) { - for (const l of this._languages) { - if (l.code === lc) { - return l; - } - } - throw new Message(MessageCode.ERROR_LANG_UNSUPPORTED); - } - - private getLanguageFromTag(tag: string) { - for (const l of this._languages) { - if (l.tag === tag) { - return l; - } - } - const e = new Message(MessageCode.ERROR_LANG_UNSUPPORTED); - e.extraVar["locale"] = tag; - throw e; - } - - public setLocale(lng: string | LanguageCode) { - let oldLang; - if (this._currLang !== undefined) { - oldLang = this._currLang.code; - } - - if (typeof lng === "string") { - const t: string = lng.substr(0, 2).toLowerCase(); - this._currLang = this.getLanguageFromTag(t); - } else { - this._currLang = this.getLanguageFromCode(lng); + /** + * Defines the current language code from its ISO 639-1 code (2 characters) or locale code + * (ex: "fr", "en", "fr_FR", "en-US") + * @see this.languageCodeFromLocaleCode() + * + * @param code ISO 639-1 language code + */ + public setLanguage(code: string) { + // is language supported ? + if (! Object.keys(this._availableLanguages).includes(code)) { + throw new Error(`ERROR_LANGUAGE_UNSUPPORTED "${code}"`); } - - if (this._currLang.code !== oldLang) { + // did language change ? + if (this._currentLanguage !== code) { + this._currentLanguage = code; + // @TODO keep old messages for backup-language mechanisms ? this._Messages = undefined; - const prom = this.httpGetMessages(); - - const is: I18nService = this; - prom.then((res) => { + // reload all messages + const that = this; + this.httpGetMessages().then((res) => { // propagate language change to all application - is.notifyObservers(undefined); + that.notifyObservers(undefined); }); } } + /** + * Loads localized messages from JSON files, for the current language + * (general message file, not calculator-specific ones) + */ private httpGetMessages(): Promise<void> { - const is: I18nService = this; - const processData = function (s: string) { - // fermeture nécessaire pour capturer la valeur de this (undefined sinon) - is._Messages = JSON.parse(s); - }; - - let l: string; - switch (this._currLang.code) { - case LanguageCode.FRENCH: - l = "fr"; - break; - - default: - l = "en"; - } - - const f: string = "messages." + l + ".json"; - return this.httpService.httpGetRequestPromise("locale/" + f).then( - (res: any) => { is._Messages = res; } + const that = this; + const fileName = "messages." + this._currentLanguage + ".json"; + return this.httpService.httpGetRequestPromise("locale/" + fileName).then( + (res: any) => { that._Messages = res; } ); } @@ -151,13 +91,23 @@ export class I18nService extends Observable implements Observer { return this._Messages[MessageCode[c]]; } - private replaceAll(str: string, find: string, replace: string) { - return str.replace(new RegExp(find, "g"), replace); + /** + * Traduit un texte défini dans un fichier de langue, à partir de sa clé + * @param textKey id du texte (ex: "ERROR_PARAM_NULL") + */ + public localizeText(textKey: string, messages = this._Messages) { + if (messages === undefined) { + return `*** messages not loaded: ${this._currentLanguage} ***`; + } + if (messages[textKey] === undefined) { + return `*** message not found: ${textKey} ***`; + } + return messages[textKey]; } /** - * traduit un message - * @param r message + * Traduit un Message (classe Message de JaLHyd, pour les logs de calcul par exemple) + * @param r Message * @param nDigits nombre de chiffres à utiliser pour l'arrondi dans le cas de données numériques */ public localizeMessage(r: Message, nDigits: number = 3): string { @@ -173,6 +123,7 @@ export class I18nService extends Observable implements Observer { } else { s = v; } + // @TODO use sprintf() with named parameters instead ? m = this.replaceAll(m, "%" + k + "%", s); } } @@ -180,20 +131,8 @@ export class I18nService extends Observable implements Observer { return m; } - /** - * Traduit un texte défini dans un fichier de langue (locale/error_message.xx.json par défaut) - * Les ids dans ces fichiers sont soit un enum de JalHyd, soit une chaine libre correspondant au code passé à localizeText() - * @param code id du texte - * @param messages Contenu du fichier de langua à utiliser (locale/error_message.xx.json par défaut) - */ - public localizeText(code: string, messages = this._Messages) { - if (messages === undefined) { - return `*** messages not loaded: ${code} ***`; - } - if (messages[code] === undefined) { - return `*** message not exists: ${code} ***`; - } - return messages[code]; + private replaceAll(str: string, find: string, replace: string) { + return str.replace(new RegExp(find, "g"), replace); } /** @@ -252,7 +191,7 @@ export class I18nService extends Observable implements Observer { const l = data.languages[i]; if (l !== undefined) { try { - this.setLocale(l); + this.setLanguage(l); languageEventuallyUsed = l; } catch (e) { console.error(e.toString()); diff --git a/src/locale/messages.en.json b/src/locale/messages.en.json index 249f391b9..0fbca2261 100644 --- a/src/locale/messages.en.json +++ b/src/locale/messages.en.json @@ -9,7 +9,6 @@ "ERROR_INTERVAL_OUTSIDE": "Interval: value %value% is outside of %interval", "ERROR_INTERVAL_UNDEF": "Interval: invalid 'undefined' value", "ERROR_INVALID_AT_POSITION": "Position %s:", - "ERROR_LANG_UNSUPPORTED": "internationalisation: unsupported '%locale%' locale", "ERROR_MINMAXSTEP_MIN": "Value is not in [%s,%s[", "ERROR_MINMAXSTEP_MAX": "Value is not in ]%s,%s]", "ERROR_MINMAXSTEP_STEP": "Value is not in %s", diff --git a/src/locale/messages.fr.json b/src/locale/messages.fr.json index f3fbdb6bc..fc557bf8c 100644 --- a/src/locale/messages.fr.json +++ b/src/locale/messages.fr.json @@ -9,7 +9,6 @@ "ERROR_INTERVAL_OUTSIDE": "Interval : la valeur %value% est hors de l'intervalle %interval", "ERROR_INTERVAL_UNDEF": "Interval : valeur 'undefined' incorrecte", "ERROR_INVALID_AT_POSITION": "Position %s :", - "ERROR_LANG_UNSUPPORTED": "Internationalisation : locale '%locale%' non prise en charge", "ERROR_MINMAXSTEP_MIN": "La valeur n'est pas dans [%s,%s[", "ERROR_MINMAXSTEP_MAX": "La valeur n'est pas dans ]%s,%s]", "ERROR_MINMAXSTEP_STEP": "La valeur n'est pas dans %s", -- GitLab