import { Injectable, isDevMode } from "@angular/core"; import { Message, MessageCode, Observable, Observer, Nub, CalculatorType, PreBarrage, PbCloison, PbBassin } from "jalhyd"; import { StringMap } from "../stringmap"; import { ApplicationSetupService } from "./app-setup.service"; import { HttpService } from "./http.service"; import { fv, decodeHtml } from "../util"; import { ServiceFactory } from "./service-factory"; import { FormulaireService } from "./formulaire.service"; @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; /** to avoid loading language files multiple times */ private _languageCache = {}; constructor( private applicationSetupService: ApplicationSetupService, private httpService: HttpService ) { super(); this._availableLanguages = { fr: "Français", en: "English" }; // add language preferences observer this.applicationSetupService.addObserver(this); } public get languages() { return this._availableLanguages; } public get currentLanguage() { return this._currentLanguage; } 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 async setLanguage(code: string) { /** excluded calculators */ const childCalculatorType: CalculatorType[] = [ CalculatorType.Section, CalculatorType.Structure, CalculatorType.CloisonAval, CalculatorType.YAXN ]; // 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; this._Messages = undefined; // reload all messages: global lang files, plus lang files for all calculators ! const that = this; const promisesList: Promise<any>[] = []; for (const ct in CalculatorType) { const calcType = Number(ct); if (!isNaN(calcType) && !childCalculatorType.includes(calcType)) { promisesList.push(this.loadLocalisation(calcType).catch((err) => { /* silent fail */ })); } } await Promise.all(promisesList); const res = await this.httpGetMessages(code); that._Messages = res; // propagate language change to all application that.notifyObservers(undefined); } } /** * Loads the localisation file dedicated to calculator type ct; uses cache if available */ public async loadLocalisation(calc: CalculatorType): Promise<any> { const lang = this.currentLanguage; try { return await this.loadLocalisationForLang(calc, lang) as StringMap; } catch (e) { return ""; } } /** * Loads the localisation file dedicated to calculator type ct for language lang; * keeps it in cache for subsequent calls () */ private async loadLocalisationForLang(calc: CalculatorType, lang: string): Promise<any> { const ct = String(calc); // if not already in cache if (! Object.keys(this._languageCache).includes(ct) || ! Object.keys(this._languageCache[calc]).includes(lang)) { const f: string = FormulaireService.getConfigPathPrefix(calc) + lang + ".json"; try { const localisation = await this.httpService.httpGetRequestPromise(f); 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}"`); } } else { return new Promise((resolve, reject) => { resolve(undefined); // does nothing but complies with Promise expectation }); } } /** * Loads localized messages from JSON files for the given language * (general messages files, not calculator-specific ones) */ private async httpGetMessages(lang: string): Promise<any> { const fileName = "messages." + lang + ".json"; const filePath = "locale/" + fileName; return await this.httpService.httpGetRequestPromise(filePath); } private getMessageFromCode(c: MessageCode): string { if (! this._Messages) { return `*** messages not loaded yet ***`; } if (this._Messages[MessageCode[c]] === undefined) { return `*** message not found ${MessageCode[c]} ***`; } return this._Messages[MessageCode[c]]; } /** * Translates a text from its key. * * In production mode, looks in different messages collections : * 1. ${msg} if provided * 2. messages for current 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) { if (! this._Messages) { return `*** messages not loaded: ${this._currentLanguage} ***`; } if (this._Messages[textKey] !== undefined) { return decodeHtml(this._Messages[textKey]); } else { // try general message if (this._Messages !== undefined && this._Messages["INFO_LIB_" + textKey.toUpperCase()] !== undefined) { return decodeHtml(this._Messages["INFO_LIB_" + textKey.toUpperCase()]); } return `*** message not found: ${textKey} ***`; } } /** * 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 { let text: string; let m: string = this.getMessageFromCode(r.code); // replace %X% by formatted value of extraVar.X for (const k in r.extraVar) { if (r.extraVar.hasOwnProperty(k)) { const v: any = r.extraVar[k]; let s: string; // 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()); } else { if (typeof v === "number") { s = fv(v); } else { s = v; } } m = this.replaceAll(m, "%" + k + "%", s); } } // 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) => { // cannot inject FormulaireService => cyclic dependency :/ const form = ServiceFactory.formulaireService.getFormulaireFromNubId(p1); let formName = "**UNKNOWN_FORM**"; if (form !== undefined) { formName = form.calculatorName; } return formName; }); // 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); 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); }); 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 * @param short if true, will use abbreviations */ public prefix(n: Nub, text: string, short?: boolean): string { let prefixed: string = text; if (n.parent) { let prefix: string; if (n instanceof PbCloison || n instanceof PbBassin) { prefix = this.localizeMessage(n.description); if (! short) { if (n instanceof PbCloison) { prefix = this.localizeText("INFO_CHILD_TYPE_CLOISON") + " " + prefix; } else { // PbBassin prefix = this.localizeText("INFO_CHILD_TYPE_BASSIN") + " " + prefix; } prefix = prefix.substring(0, 1).toUpperCase() + prefix.substring(1) + " : "; } } else { // get child name and position from Nub's parent const pos = String(n.findPositionInParent() + 1); const name = this.childName(n, false, short); // Detect downwalls let m: Message; if (n.calcType === CalculatorType.CloisonAval) { m = new Message(MessageCode.INFO_PARENT_PREFIX_DOWNWALL); } else { m = short ? new Message(MessageCode.INFO_PARENT_PREFIX_SHORT) : new Message(MessageCode.INFO_PARENT_PREFIX); m.extraVar.name = name; m.extraVar.position = pos; } prefix = this.localizeMessage(m); } prefixed = prefix.substring(0, 1).toUpperCase() + prefix.substring(1) + (short ? "." : " ") + (short ? prefixed : prefixed.substring(0, 1).toLowerCase() + prefixed.substring(1) ); // recursivity prefixed = this.prefix(n.parent, prefixed, short); } return prefixed; } private replaceAll(str: string, find: string, replace: string) { return str.replace(new RegExp(find, "g"), replace); } /** * 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é" */ public formatResult(label: string, value: number): string { if (value === undefined) { return ""; } const match = label.indexOf("ENUM_"); if (match > -1) { return this.localizeText(`INFO_${label.toUpperCase()}_${value}`); } return fv(value); } /** * Returns the localized name for the children type of the current Nub's parent; * "short" and "plural" options are mutually exclusive * @param plural if true, will return plural name * @param short if true, will return short name */ public childName(nub: Nub, plural: boolean = false, short: boolean = false) { const type: string = nub.parent.childrenType; let k = "INFO_CHILD_TYPE_" + type.toUpperCase(); if (short) { k += "_SHORT"; } else if (plural) { k += "_PLUR"; } return this.localizeText(k); } // interface Observer /** * 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 { this.setLanguage(l); 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; } } } }