Skip to content
Snippets Groups Projects
internationalisation.service.ts 15 KiB
Newer Older
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/util";
import { ServiceFactory } from "./service-factory";
import { FormulaireService } from "./formulaire.service";
mathias.chouet's avatar
mathias.chouet committed
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 */
    // eslint-disable-next-line @typescript-eslint/naming-convention
    /** to avoid loading language files multiple times */
    private _languageCache = {};

    constructor(
        private applicationSetupService: ApplicationSetupService,
        private httpService: HttpService
    ) {
        super();
        this._availableLanguages = {
            fr: "Français",
            en: "English"
        };
mathias.chouet's avatar
mathias.chouet committed
        // add language preferences observer
        this.applicationSetupService.addObserver(this);
        return this._availableLanguages;
        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) {
        const childCalculatorType: CalculatorType[] = [
            CalculatorType.Section, CalculatorType.Structure, CalculatorType.CloisonAval, CalculatorType.YAXN,
            CalculatorType.LechaptCalmon, CalculatorType.PressureLossLaw
        // ensure 2-letter language code
        code = code.substring(0, 2);
    
        // Check if the language is supported, default to English if not
        if (!Object.keys(this._availableLanguages).includes(code)) {
            code = "en";  // Default to English if the detected language is not supported
    
        // did language change?
        if (this._currentLanguage !== code) {
            this._currentLanguage = code;
            // 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) {
    }

    /**
     * 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;
                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, vars: {} = {}) {
        if (! this._Messages) {
            return `*** messages not loaded: ${this._currentLanguage} ***`;
        }
        if (this._Messages[textKey] !== undefined) {
            return this.translateMessage(this._Messages[textKey], vars);
            // 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} ***`;
     * Translate a text optionally subtituting variables denoted by %XXX%
     * @param m message to translate
     * @param vars variable map
     * @returns translated message with variables value
    private translateMessage(m: string, vars: {}) {
        // replace %X% by formatted value of vars.X
        for (const k in vars) {
            if (vars.hasOwnProperty(k)) {
                const v: any = vars[k];
David Dorchies's avatar
David Dorchies committed
                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());
David Dorchies's avatar
David Dorchies committed
                } else {
David Dorchies's avatar
David Dorchies committed
                }
                m = this.replaceAll(m, "%" + k + "%", s);
            }
Mathias Chouet's avatar
Mathias Chouet 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) => {
Mathias Chouet's avatar
Mathias Chouet committed
            // cannot inject FormulaireService => cyclic dependency :/
            const form = ServiceFactory.formulaireService.getFormulaireFromNubId(p1);
Mathias Chouet's avatar
Mathias Chouet committed
            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 decodeHtml(m);
    }

    /**
     * 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 m: string = this.getMessageFromCode(r.code);

        let text: string = this.translateMessage(m, r.extraVar);

        // 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) + " : ";
                }
                // 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)
                );
            prefixed = this.prefix(n.parent, prefixed, short);
    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_]
Mathias Chouet's avatar
Mathias Chouet committed
     * Exemple pour la langue "fr" : "ENUM_STRUCTUREFLOWREGIME_1" => "INFO_ENUM_STRUCTUREFLOWREGIME_1" => "Partiellement noyé"
     */
    public formatResult(label: string, value: number): string {
        const match = label.indexOf("ENUM_");
        if (match > -1) {
            return this.localizeText(`INFO_${label.toUpperCase()}_${value}`);
mathias.chouet's avatar
mathias.chouet committed
    /**
     * Returns the localized name for the children type of the current Nub's parent;
     * "short" and "plural" options are mutually exclusive
mathias.chouet's avatar
mathias.chouet committed
     * @param plural if true, will return plural name
     * @param short if true, will return short name
mathias.chouet's avatar
mathias.chouet committed
     */
    public childName(nub: Nub, plural: boolean = false, short: boolean = false) {
        let k = "INFO_CHILD_TYPE_" + nub.intlType.toUpperCase();
        if (short) {
            k += "_SHORT";
        } else if (plural) {
mathias.chouet's avatar
mathias.chouet committed
            k += "_PLUR";
        }
        return this.localizeText(k);
    }

mathias.chouet's avatar
mathias.chouet committed
    // interface Observer

    /**
     * Should only be triggered once at app startup, when setup service tries loading language,
     * then everytime language is changed through Preferences screen
mathias.chouet's avatar
mathias.chouet committed
     * @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);
mathias.chouet's avatar
mathias.chouet committed
                            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;
            }
        }
    }