Newer
Older
import { Injectable, isDevMode } from "@angular/core";
francois.grand
committed
import { Message, MessageCode, Observable, Observer, Nub, CalculatorType } from "jalhyd";
francois.grand
committed
import { StringMap } from "../stringmap";
import { ApplicationSetupService } from "./app-setup.service";
import { HttpService } from "./http.service";
Mathias Chouet
committed
import { fv, decodeHtml } from "../util";
import { ServiceFactory } from "./service-factory";
import { FormulaireService } from "./formulaire.service";
francois.grand
committed
@Injectable()
export class I18nService extends Observable implements Observer {
/** 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;
francois.grand
committed
/** localized messages in fallback language (the one in the config file) */
private _fallbackMessages: StringMap;
/** to avoid loading language files multiple times */
private _languageCache = {};
constructor(
private applicationSetupService: ApplicationSetupService,
this._availableLanguages = {
fr: "Français",
en: "English"
};
// load fallback language messages once for all
this.httpGetMessages(this.applicationSetupService.fallbackLanguage).then((res: any) => {
this._fallbackMessages = res;
});
// add language preferences observer
this.applicationSetupService.addObserver(this);
francois.grand
committed
}
francois.grand
committed
public get languages() {
francois.grand
committed
}
francois.grand
committed
francois.grand
committed
public get currentLanguage() {
francois.grand
committed
}
francois.grand
committed
public get currentMap() {
return this._Messages;
}
public get languageCache() {
return this._languageCache;
}
/**
* Defines the current language code from its ISO 639-1 code (2 characters) or locale code
* (ex: "fr", "en", "fr_FR", "en-US")
*
* @param code ISO 639-1 language code
*/
public setLanguage(code: string) {
// ensure 2-letter language code
code = code.substring(0, 2);
// is language supported ?
if (! Object.keys(this._availableLanguages).includes(code)) {
throw new Error(`LANGUAGE_UNSUPPORTED "${code}"`);
// did language change ?
if (this._currentLanguage !== code) {
this._currentLanguage = code;
François
committed
this._Messages = undefined;
// reload all messages: global lang files, plus lang files for all calculators !
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
console.log("> promise.all !");
const promisesList: Promise<any>[] = [];
for (const ct in CalculatorType) {
const calcType = Number(ct);
if (!isNaN(calcType)) {
promisesList.push(this.loadLocalisation(calcType).catch((err) => { /* silent fail */ }));
}
}
Promise.all(promisesList).then(() => {
console.log(">> get global messages !");
this.httpGetMessages(code).then((res: any) => {
that._Messages = res;
// propagate language change to all application
that.notifyObservers(undefined);
});
});
}
}
/**
* Loads the localisation file dedicated to calculator type ct; tries the current
* language then the fallback language; uses cache if available
*/
public loadLocalisation(calc: CalculatorType): Promise<any> {
const lang = this.currentLanguage;
return this.loadLocalisationForLang(calc, lang).then((localisation) => {
return localisation as StringMap;
}).catch((e) => {
// try default lang (the one in the config file) ?
const fallbackLang = this.applicationSetupService.fallbackLanguage;
if (lang !== fallbackLang) {
console.error(`localisation for ${CalculatorType[calc]} not found, trying fallback language: ${fallbackLang}`);
return this.loadLocalisationForLang(calc, fallbackLang);
}
});
}
/**
* 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 = FormulaireService.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}"`);
François
committed
}
* Loads localized messages from JSON files for the given language
* (general messages files, not calculator-specific ones)
private httpGetMessages(lang: string): Promise<void> {
const fileName = "messages." + lang + ".json";
const filePath = "locale/" + fileName;
return this.httpService.httpGetRequestPromise(filePath).then((res: any) => {
return res;
});
francois.grand
committed
}
private getMessageFromCode(c: MessageCode): string {
return `*** messages not loaded yet ***`;
if (this._Messages[MessageCode[c]] === undefined) {
return `*** message not found ${MessageCode[c]} ***`;
return this._Messages[MessageCode[c]];
francois.grand
committed
}
* Translates a text from its key.
*
* In production mode, looks in different messages collections :
* 1. ${msg} if provided
* 2. messages for current language
* 3. messages for fallback language
*
* In dev mode, looks only in 1. if provided, else only in 2. which makes missing
* translations easier to detect
*
* @param textKey id du texte (ex: "ERROR_PARAM_NULL")
*/
public localizeText(textKey: string, msg?: StringMap) {
const messages = msg || this._Messages;
if (! messages) {
return `*** messages not loaded: ${this._currentLanguage} ***`;
}
if (messages[textKey] !== undefined) {
return decodeHtml(messages[textKey]);
// try general message
if (msg !== undefined && this._Messages["INFO_LIB_" + textKey.toUpperCase()] !== undefined) {
return decodeHtml(this._Messages["INFO_LIB_" + textKey.toUpperCase()]);
// try fallback language before giving up
if (this._fallbackMessages[textKey] !== undefined) {
return decodeHtml(this._fallbackMessages[textKey]);
return `*** message not found: ${textKey} ***`;
francois.grand
committed
}
francois.grand
committed
/**
* Traduit un Message (classe Message de JaLHyd, pour les logs de calcul par exemple)
* @param r Message
francois.grand
committed
* @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 {
let text: string;
let m: string = this.getMessageFromCode(r.code);
for (const k in r.extraVar) {
if (r.extraVar.hasOwnProperty(k)) {
const v: any = r.extraVar[k];
let s: string;
Mathias Chouet
committed
// detect variable names to translate
if (k === "variables" && Array.isArray(v)) {
// array of variable names
s = v.map((e) => {
return this.localizeText("INFO_LIB_" + e.toUpperCase());
}).join(", ");
} else if (k.substring(0, 4) === "var_") {
// single variable name
s = this.localizeText("INFO_LIB_" + v.toUpperCase());
Mathias Chouet
committed
if (typeof v === "number") {
s = fv(v);
} else {
s = v;
}
}
m = this.replaceAll(m, "%" + k + "%", s);
}
francois.grand
committed
}
// replace "ENUM_X_Y" by translated enum value;
// example for lang "fr" : "ENUM_SPECIES_6" => "INFO_ENUM_SPECIES_6" => "Ombre commun"
m = m.replace(/(ENUM_[^ ,;\.]+)/g, (match, p1) => {
return this.localizeText("INFO_" + p1);
});
// replace "FORM_ID_X" by form name in current session, if any
m = m.replace(/FORM_ID_(\w{6})/g, (match, p1) => {
const form = ServiceFactory.formulaireService.getFormulaireFromNubId(p1);
let formName = "**UNKNOWN_FORM**";
if (form !== undefined) {
formName = form.calculatorName;
}
return formName;
});
francois.grand
committed
Mathias Chouet
committed
// replace "ENUM_X_Y" by translated enum value;
// example for lang "fr" : "ENUM_SPECIES_6" => "INFO_ENUM_SPECIES_6" => "Ombre commun"
// (partly redundant with MSG_* but shorter to write)
m = m.replace(/(ENUM_[^ ,;\.]+)/g, (match, p1) => {
return this.localizeText("INFO_" + p1);
});
// replace "FORM_ID_X" by form name in current session, if any
m = m.replace(/FORM_ID_(\w{6})/g, (match, p1) => {
// cannot inject FormulaireService => cyclic dependency :/
const form = ServiceFactory.formulaireService.getFormulaireFromNubId(p1);
Mathias Chouet
committed
let formName = "**UNKNOWN_FORM**";
if (form !== undefined) {
formName = form.calculatorName;
}
return formName;
});
// replace MSG_* with the translation of * ; allows
// to inject any text translation in a message
m = m.replace(/MSG_([^ ,;\.]+)/g, (match, p1) => {
return this.localizeText(p1);
});
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
text = decodeHtml(m);
// prefix message if needed
if (r.parent && r.parent.parent && r.parent.parent.sourceNub) {
text = this.prefix(r.parent.parent.sourceNub, text);
}
return text;
}
/**
* Prefix given text message with context read from given Nub
* @param n Nub associated to original Message object, to read context from
* @param text text message to prefix
*/
private prefix(n: Nub, text: string): string {
let prefixed: string = text;
if (n.parent) {
// get child name and position from Nub's parent
const pos = String(n.findPositionInParent() + 1);
const name = this.childName(n.parent);
let m: Message;
// Detect downwalls
if (n.calcType === CalculatorType.CloisonAval) {
m = new Message(MessageCode.INFO_PARENT_PREFIX_DOWNWALL);
} else {
m = new Message(MessageCode.INFO_PARENT_PREFIX);
m.extraVar.name = name;
m.extraVar.position = pos;
}
const prefix = this.localizeMessage(m);
prefixed =
prefix.substring(0, 1).toUpperCase() + prefix.substring(1)
+ " "
+ prefixed.substring(0, 1).toLowerCase() + prefixed.substring(1);
// recursivity
prefixed = this.prefix(n.parent, prefixed);
}
return prefixed;
francois.grand
committed
}
private replaceAll(str: string, find: string, replace: string) {
return str.replace(new RegExp(find, "g"), replace);
francois.grand
committed
/**
* Met en forme un result en fonction du libellé qui l'accompagne
* Les result avec le terme "ENUM_" sont traduit avec le message INFO_ENUM_[Nom de la variable après ENUM_]
* Exemple pour la langue "fr" : "ENUM_STRUCTUREFLOWREGIME_1" => "INFO_ENUM_STRUCTUREFLOWREGIME_1" => "Partiellement noyé"
francois.grand
committed
*/
public formatResult(label: string, value: number): string {
mathias.chouet
committed
if (value === undefined) {
return "";
}
const match = label.indexOf("ENUM_");
if (match > -1) {
return this.localizeText(`INFO_${label.substring(match).toUpperCase()}_${value}`);
David Dorchies
committed
}
francois.grand
committed
}
David Dorchies
committed
/**
* Returns the localized name for the children type of the current Nub
* @param plural if true, will return plural name
*/
public childName(nub: Nub, plural: boolean = false) {
const type: string = nub.childrenType;
let k = "INFO_CHILD_TYPE_" + type.toUpperCase();
if (plural) {
k += "_PLUR";
}
return this.localizeText(k);
}
* Should only be triggered once at app startup, when setup service tries loading language,
* then everytime language is changed through Preferences screen
* @param sender should always be ApplicationSetupService
* @param data object {
* action: should always be "languagePreferenceChanged"
* languages: languages codes to try until one works
* }
*/
public update(sender: any, data: any): void {
if (sender instanceof ApplicationSetupService) {
if (data.action === "languagePreferenceChanged") {
let languageEventuallyUsed: string;
for (let i = 0; i < data.languages.length && languageEventuallyUsed === undefined; i++) {
const l = data.languages[i];
if (l !== undefined) {
try {
languageEventuallyUsed = l;
} catch (e) {
console.error(e.toString());
}
}
}
// confirm to setup service which language was eventually used at app startup (triggers nothing more)
this.applicationSetupService.language = languageEventuallyUsed;
}
}
}