Commit a498e3b3 authored by Mathias Chouet's avatar Mathias Chouet 🍝
Browse files

Gestion des langues #114

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)
parent f1f15888
......@@ -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.
......@@ -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>
......
......@@ -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 {
......
......@@ -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) {
......
......@@ -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;
......
......@@ -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
*/
......
......@@ -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) {
......
......@@ -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());
......
......@@ -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",
......
......@@ -9,7 +9,6 @@
"ERROR_INTERVAL_OUTSIDE": "Interval&nbsp;: la valeur %value% est hors de l'intervalle %interval",
"ERROR_INTERVAL_UNDEF": "Interval&nbsp;: valeur 'undefined' incorrecte",
"ERROR_INVALID_AT_POSITION": "Position %s :",
"ERROR_LANG_UNSUPPORTED": "Internationalisation&nbsp;: 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",
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment