diff --git a/README.md b/README.md index 199a1aa20c08c1452898607158ec2bef1bb26c57..38b3b040b5f98d47f680aa78ee0a3ffb4d550c18 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 86c85ad02c7515c87c94e58f91b8a83991a98f95..448b389777548bf061c9044bdfc3c25ce34eec2f 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 a2ada31467568d4fa331bc1f28879d3c0a393f11..0b373a0d2b671309579c175a9c31e5f0a750b199 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 54141da0a8f22cd6165204e6ae30cc2f5aaaf4cb..af807c10911ab4ab8b0269dd1066b9cc02d6d9b6 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 fd3f87b746074a49131d56bb16e3630a79e212c5..c186feea6ef745ab63aa3c29ea28ab168880729d 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 afde5826c0758b79e5a9cf1f729eeb04b53302e4..d05c7a17400880060cae3e35d460400e54d45675 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 327a22c081baebdb018d01673ed50ebef6348138..066e82f7abe338d63e5ab8f387841dc84e66e679 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 bde70b1443709027e013ba4151fd51c42a79c724..9700324cae9e628aa393af5d6579db3d3640b681 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 249f391b9f53741f0f7ad534ef11d34f2cf0d862..0fbca2261ab263cc44aaed9dd4c879ffadb5dea2 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 f3fbdb6bc1f1f0c0ec3667f3400285ad63dda1f5..fc557bf8c50e247fde85031781a150bfec04b131 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",