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 ...@@ -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. 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 : Ajouter un champ pour le titre du module de calcul. Par exemple :
_"INFO_MACALC_TITRE": "Ma calculette"_ _"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.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 @@ ...@@ -78,8 +78,8 @@
<!-- langue --> <!-- langue -->
<mat-form-field> <mat-form-field>
<mat-select placeholder="Language" [(value)]="currentLanguageCode" data-testid="language-select"> <mat-select placeholder="Language" [(value)]="currentLanguageCode" data-testid="language-select">
<mat-option *ngFor="let l of availableLanguages" [value]="l.code"> <mat-option *ngFor="let l of availableLanguages | keyvalue" [value]="l.key">
{{ l.label }} {{ l.value }}
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
......
...@@ -3,7 +3,7 @@ import { Component, OnInit } from "@angular/core"; ...@@ -3,7 +3,7 @@ import { Component, OnInit } from "@angular/core";
import { ParamDomainValue, Observer } from "jalhyd"; import { ParamDomainValue, Observer } from "jalhyd";
import { ApplicationSetupService } from "../../services/app-setup/app-setup.service"; 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 { NgBaseParam } from "../base-param-input/base-param-input.component";
import { BaseComponent } from "../base/base.component"; import { BaseComponent } from "../base/base.component";
import { ErrorStateMatcher, MatSnackBar } from "@angular/material"; import { ErrorStateMatcher, MatSnackBar } from "@angular/material";
...@@ -42,14 +42,14 @@ export class ApplicationSetupComponent extends BaseComponent implements Observer ...@@ -42,14 +42,14 @@ export class ApplicationSetupComponent extends BaseComponent implements Observer
public get currentLanguageCode() { public get currentLanguageCode() {
if (this.intlService.currentLanguage) { if (this.intlService.currentLanguage) {
return this.intlService.currentLanguage.code; return this.intlService.currentLanguage;
} }
} }
public set currentLanguageCode(lc: LanguageCode) { public set currentLanguageCode(lc: string) {
this.intlService.setLocale(lc); this.intlService.setLanguage(lc);
// keep language in sync in app-wide parameters service // 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 { public get uitextTitle(): string {
......
...@@ -155,10 +155,11 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit, ...@@ -155,10 +155,11 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit,
return this.intlService.localizeText("INFO_CALCULATOR_RESULTS_TITLE"); return this.intlService.localizeText("INFO_CALCULATOR_RESULTS_TITLE");
} }
/**
* Triggered at calculator instanciation
*/
ngOnInit() { ngOnInit() {
this.intlService.addObserver(this);
this.formulaireService.addObserver(this); this.formulaireService.addObserver(this);
this.formulaireService.updateLocalisation();
this.subscribeRouter(); this.subscribeRouter();
} }
...@@ -192,7 +193,6 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit, ...@@ -192,7 +193,6 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit,
* gestion des événements clic sur les radios * gestion des événements clic sur les radios
*/ */
private onRadioClick(info: any) { private onRadioClick(info: any) {
// console.log("on radio click");
this.updateLinkedParameters(); this.updateLinkedParameters();
this._pendingRadioClick = true; this._pendingRadioClick = true;
this._pendingRadioClickInfo = info; this._pendingRadioClickInfo = info;
...@@ -200,7 +200,6 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit, ...@@ -200,7 +200,6 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit,
public ngAfterViewChecked() { public ngAfterViewChecked() {
if (this._pendingRadioClick) { if (this._pendingRadioClick) {
// console.log("ng after view checked");
this._pendingRadioClick = false; this._pendingRadioClick = false;
this._formulaire.onRadioClick(this._pendingRadioClickInfo); this._formulaire.onRadioClick(this._pendingRadioClickInfo);
this._pendingRadioClickInfo = undefined; this._pendingRadioClickInfo = undefined;
...@@ -271,16 +270,15 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit, ...@@ -271,16 +270,15 @@ export class GenericCalculatorComponent extends BaseComponent implements OnInit,
// interface Observer // interface Observer
update(sender: any, data: any): void { update(sender: any, data: any): void {
if (sender instanceof I18nService) { if (sender instanceof FormulaireService) {
// update display if language changed
this.formulaireService.updateLocalisation();
} else if (sender instanceof FormulaireService) {
switch (data["action"]) { switch (data["action"]) {
case "currentFormChanged": case "currentFormChanged":
const uid: string = data["formId"]; const uid: string = data["formId"];
this.setForm(this.formulaireService.getFormulaireFromId(uid)); this.setForm(this.formulaireService.getFormulaireFromId(uid));
this.resultsComponent.formulaire = this._formulaire; this.resultsComponent.formulaire = this._formulaire;
this._calculatorNameComponent.model = this._formulaire; this._calculatorNameComponent.model = this._formulaire;
// reload localisation in all cases
this.formulaireService.loadUpdateFormulaireLocalisation(this._formulaire);
break; break;
} }
} else if (sender instanceof FormulaireDefinition) { } else if (sender instanceof FormulaireDefinition) {
......
...@@ -180,7 +180,11 @@ export abstract class FormulaireElement extends FormulaireNode { ...@@ -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) { public updateLocalisation(loc: StringMap, key?: string) {
if (!key) { if (!key) {
key = this._confId; key = this._confId;
......
...@@ -12,6 +12,9 @@ export class ApplicationSetupService extends Observable { ...@@ -12,6 +12,9 @@ export class ApplicationSetupService extends Observable {
private CONFIG_FILE_PATH = "app/config.json"; private CONFIG_FILE_PATH = "app/config.json";
private LOCAL_STORAGE_PREFIX = "nghyd_"; private LOCAL_STORAGE_PREFIX = "nghyd_";
/** ultimate fallback language (read from config) */
private _fallbackLanguage = "fr";
// default builtin values // default builtin values
public displayPrecision = 0.001; public displayPrecision = 0.001;
public computePrecision = 0.0001; public computePrecision = 0.0001;
...@@ -38,9 +41,10 @@ export class ApplicationSetupService extends Observable { ...@@ -38,9 +41,10 @@ export class ApplicationSetupService extends Observable {
// load JSON config // load JSON config
this.readValuesFromConfig().then((data) => { this.readValuesFromConfig().then((data) => {
const configLanguage = this.language; const configLanguage = this.language;
this._fallbackLanguage = configLanguage;
// guess browser's language // guess browser's language
this.language = navigator.language.substring(0, 2); // @TODO clodo trick, check validity this.language = navigator.language;
const browserLanguage = this.language; const browserLanguage = this.language;
// load saved preferences // load saved preferences
...@@ -62,6 +66,10 @@ export class ApplicationSetupService extends Observable { ...@@ -62,6 +66,10 @@ export class ApplicationSetupService extends Observable {
return -Math.log10(this.displayPrecision); return -Math.log10(this.displayPrecision);
} }
public get fallbackLanguage() {
return this._fallbackLanguage;
}
/** /**
* Save configuration values into local storage * Save configuration values into local storage
*/ */
......
...@@ -21,17 +21,23 @@ import { FormulaireRegimeUniforme } from "../../formulaire/definition/concrete/f ...@@ -21,17 +21,23 @@ import { FormulaireRegimeUniforme } from "../../formulaire/definition/concrete/f
import { FormulaireParallelStructure } from "../../formulaire/definition/concrete/form-parallel-structures"; import { FormulaireParallelStructure } from "../../formulaire/definition/concrete/form-parallel-structures";
import { NgParameter } from "../../formulaire/ngparam"; import { NgParameter } from "../../formulaire/ngparam";
import { FieldsetContainer } from "../..//formulaire/fieldset-container"; import { FieldsetContainer } from "../..//formulaire/fieldset-container";
import { ApplicationSetupService } from "../app-setup/app-setup.service";
@Injectable() @Injectable()
export class FormulaireService extends Observable { export class FormulaireService extends Observable {
/** list of known forms */
private _formulaires: FormulaireDefinition[]; private _formulaires: FormulaireDefinition[];
private _currentFormId: string = null; private _currentFormId: string = null;
/** to avoid loading language files multiple times */
private languageCache = {};
constructor( constructor(
private i18nService: I18nService, private i18nService: I18nService,
private httpService: HttpService) { private appSetupService: ApplicationSetupService,
private httpService: HttpService
) {
super(); super();
this._formulaires = []; this._formulaires = [];
} }
...@@ -48,17 +54,50 @@ export class FormulaireService extends Observable { ...@@ -48,17 +54,50 @@ export class FormulaireService extends Observable {
return this._formulaires; 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> { private loadLocalisation(calc: CalculatorType): Promise<any> {
const f: string = this.getConfigPathPrefix(calc) + this._intlService.currentLanguage.tag + ".json"; const lang = this._intlService.currentLanguage;
const prom = this._httpService.httpGetRequestPromise(f); return this.loadLocalisationForLang(calc, lang).then((localisation) => {
return localisation as StringMap;
return prom.then((j) => { }).catch((e) => {
return j as StringMap; 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 formId id unique du formulaire
* @param localisation ensemble id-message traduit * @param localisation ensemble id-message traduit
*/ */
...@@ -74,26 +113,11 @@ export class FormulaireService extends Observable { ...@@ -74,26 +113,11 @@ export class FormulaireService extends Observable {
/** /**
* charge la localisation et met à jour la langue du formulaire * charge la localisation et met à jour la langue du formulaire
*/ */
private loadUpdateFormulaireLocalisation(f: FormulaireDefinition): Promise<FormulaireDefinition> { public loadUpdateFormulaireLocalisation(f: FormulaireDefinition): Promise<FormulaireDefinition> {
return this.loadLocalisation(f.calculatorType) return this.loadLocalisation(f.calculatorType).then(localisation => {
.then(localisation => { this.updateFormulaireLocalisation(f.uid, localisation);
this.updateFormulaireLocalisation(f.uid, localisation); return f;
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);
}
}
}
);
}
} }
/** /**
...@@ -238,7 +262,8 @@ export class FormulaireService extends Observable { ...@@ -238,7 +262,8 @@ export class FormulaireService extends Observable {
} }
} }
} }
return this.loadUpdateFormulaireLocalisation(f); return f;
}).then(fi => { }).then(fi => {
fi.applyDependencies(); fi.applyDependencies();
this.notifyObservers({ this.notifyObservers({
...@@ -329,7 +354,7 @@ export class FormulaireService extends Observable { ...@@ -329,7 +354,7 @@ export class FormulaireService extends Observable {
public getConfigPathPrefix(ct: CalculatorType): string { public getConfigPathPrefix(ct: CalculatorType): string {
if (ct === undefined) { if (ct === undefined) {
throw new Error("FormulaireService.getConfigPathPrefix() : invalid undefined CalculatorType"); throw new Error("FormulaireService.getConfigPathPrefix() : CalculatorType is undefined");
} }
switch (ct) { switch (ct) {
......
...@@ -6,138 +6,78 @@ import { StringMap } from "../../stringmap"; ...@@ -6,138 +6,78 @@ import { StringMap } from "../../stringmap";
import { ApplicationSetupService } from "../app-setup/app-setup.service"; import { ApplicationSetupService } from "../app-setup/app-setup.service";
import { HttpService } from "../http/http.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() @Injectable()
export class I18nService extends Observable implements Observer { 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 _Messages: StringMap;
private _languages: Language[];
constructor( constructor(
private applicationSetupService: ApplicationSetupService, private applicationSetupService: ApplicationSetupService,
private httpService: HttpService) { private httpService: HttpService
) {
super(); super();
this._languages = []; this._availableLanguages = {
this._languages.push(new Language(LanguageCode.FRENCH, "fr", "Français")); fr: "Français",
this._languages.push(new Language(LanguageCode.ENGLISH, "en", "English")); en: "English"
};
// add language preferences observer // add language preferences observer
this.applicationSetupService.addObserver(this); this.applicationSetupService.addObserver(this);
} }
public get languages() { public get languages() {
return this._languages; return this._availableLanguages;
} }
public get currentLanguage() { public get currentLanguage() {
return this._currLang; return this._currentLanguage;
} }
public get currentMap() { public get currentMap() {
return this._Messages; return this._Messages;
} }
private getLanguageFromCode(lc: LanguageCode) { /**
for (const l of this._languages) { * Defines the current language code from its ISO 639-1 code (2 characters) or locale code
if (l.code === lc) { * (ex: "fr", "en", "fr_FR", "en-US")
return l; * @see this.languageCodeFromLocaleCode()
} *
} * @param code ISO 639-1 language code
throw new Message(MessageCode.ERROR_LANG_UNSUPPORTED); */
} public setLanguage(code: string) {
// is language supported ?
private getLanguageFromTag(tag: string) { if (! Object.keys(this._availableLanguages).includes(code)) {
for (const l of this._languages) { throw new Error(`ERROR_LANGUAGE_UNSUPPORTED "${code}"`);
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);
} }
// did language change ?
if (this._currLang.code !== oldLang) { if (this._currentLanguage !== code) {
this._currentLanguage = code;
// @TODO keep old messages for backup-language mechanisms ?
this._Messages = undefined; this._Messages = undefined;
const prom = this.httpGetMessages(); // reload all messages
const that = this;
const is: I18nService = this; this.httpGetMessages().then((res) => {
prom.then((res) => {
// propagate language change to all application // 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> { private httpGetMessages(): Promise<void> {
const is: I18nService = this; const that = this;
const processData = function (s: string) { const fileName = "messages." + this._currentLanguage + ".json";
// fermeture nécessaire pour capturer la valeur de this (undefined sinon) return this.httpService.httpGetRequestPromise("locale/" + fileName).then(
is._Messages = JSON.parse(s); (res: any) => { that._Messages = res; }
};
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; }
); );
} }
...@@ -151,13 +91,23 @@ export class I18nService extends Observable implements Observer { ...@@ -151,13 +91,23 @@ export class I18nService extends Observable implements Observer {